From b6ebf6dd22c127e9a55bca0521377bab77653876 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 14:28:37 -0400 Subject: [PATCH 001/361] docs: record the NUMA-sharding design session and queue its repairs Opens a repo-wide design session on deferred namespace operations feeding NUMA-partitioned IoRing execution domains, and queues the two repairs its first measurement produced. The session is explicitly OPEN -- this records the survey and one measurement, not converged decisions. Measured on the development machine: a Snapdragon X2 Elite (X2E80100, Qualcomm Oryon; 12 cores, no SMT) reports **zero** L3 cache domains. WMI gives L3CacheSize = 0, and GetLogicalProcessorInformationEx yields L1 and L2 only, with L2 forming two domains of six processors that agree with the two Module domains. It also reports zero Win32_NumaNode instances, matching the signature the ioring notes already recorded for a VM. That falsifies a stated justification for the last-level-cache heuristic -- "it is meaningful on Intel and ARM too" -- on a shipping consumer part. It does not falsify the heuristic's preference over the NUMA node, which stands. Two claims made while reasoning about this were wrong and are recorded as corrections rather than quietly dropped: - "ByL3 yielding zero domains would create zero rings" is false. Reading Policy::select shows it already degrades to a whole-machine domain and returns degraded = true so the sample can report it. There is no defect in ring_copy, and M20 says so explicitly so the next reader does not go looking for one. - "The epoch_log example does sector-aligned I/O" is false, and was part of the session's opening premise. It opens a plain file and gets alignment structurally from 4096-byte slots; the real FILE_FLAG_NO_BUFFERING work is in tests/flush_barrier.rs and tests/handover.rs. NUMA-affined buffers and sector-aligned unbuffered I/O are two separate things in two separate places, never yet combined. Also established and recorded, because they constrain the design rather than decorate it: registration is one-shot per ring for both buffers and file handles (BuildIoRingRegister* replaces the whole table), so a freshly opened handle cannot be added to a running ring; ring count is forced to equal pinned-thread count because submission is not thread-safe; and crossbeam provides no doorbell -- its Select takes only channel operations, so WaitForMultipleObjects cannot see a crossbeam channel and Select cannot see the IoRing completion event. The workspace's own file-watcher queue already solves that with a lazily created event signalled under the queue lock. M20 queues only documentation and policy-test repairs. The design questions the session opened are deliberately not queued: it is still open, and its conclusions belong to it until it converges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PLANS.md | 2 +- crates/windows-ioring-sys/CHECKLIST.md | 39 ++- crates/windows-ioring-sys/PLANS.md | 2 +- ...08-30-numa-sharded-io-execution-domains.md | 250 ++++++++++++++++++ 4 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md diff --git a/PLANS.md b/PLANS.md index 95259ca6..edd2e01b 100644 --- a/PLANS.md +++ b/PLANS.md @@ -19,6 +19,6 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | [CHECKLIST.md](CHECKLIST.md) | not started | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | -| [crates/windows-ioring-sys/CHECKLIST.md](crates/windows-ioring-sys/CHECKLIST.md) | in progress | Memory-safe Rust over the Windows `IoRing` submission/completion ring, as a new crate. M1-M7 (ring lifecycle through the `ring-copy` topology-aligned sample) are complete and archived. The parked, pinned-thread `M6+` work and the new M10 contract audit remain. | [crates/windows-ioring-sys/DESIGN-NOTES.md](crates/windows-ioring-sys/DESIGN-NOTES.md) | +| [crates/windows-ioring-sys/CHECKLIST.md](crates/windows-ioring-sys/CHECKLIST.md) | in progress | Memory-safe Rust over the Windows `IoRing` submission/completion ring, as a new crate. M1-M19 are complete (0.2.0 shipped 2026-08-30, restoring availability after all three 0.1.x versions were yanked); M1-M18 are archived. **M20** queues documentation and policy-test repairs from the 2026-08-30 NUMA-sharding measurement, and the pinned-thread `M6+` work stays parked. | [crates/windows-ioring-sys/DESIGN-NOTES.md](crates/windows-ioring-sys/DESIGN-NOTES.md) | Add a row here when new work is planned, against [CHECKLIST.md](CHECKLIST.md) or any crate's. diff --git a/crates/windows-ioring-sys/CHECKLIST.md b/crates/windows-ioring-sys/CHECKLIST.md index 466e1a0c..183c83b5 100644 --- a/crates/windows-ioring-sys/CHECKLIST.md +++ b/crates/windows-ioring-sys/CHECKLIST.md @@ -10,7 +10,7 @@ own dated groups, M8-M10 and M15-M18 [here](COMPLETED-CHECKLIST.md#moved-2026-08-30----m15-through-m18-the-testing-strategy-response-to-eight-defects). -**Only `M6+` remains, and it is parked rather than pending** -- see the `M{n}+` convention: it is gated work +**`M20` is pending; `M6+` is parked rather than pending** -- see the `M{n}+` convention: it is gated work with no current obligation, not an unfinished milestone. `M19` below is complete and awaits archival with the next group. @@ -74,6 +74,43 @@ the API whose breaking change 0.2.0 is being cut for, and it is reachable with n and D-45 is added to its table of shipped defects of this shape. **Swept the count restatements too:** that file said "three defects" in four places and is now four, which is the restatement drift the repository's own conventions warn about. + +## M20 -- Repairs from the 2026-08-30 NUMA-sharding measurement + +Queued from +[DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](../../design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md), +which measured a shipping ARM laptop and found the L3 heuristic's justification does not hold there. These +are documentation and policy repairs only; **no defect was found in `ring_copy`** -- `Policy::select` +already degrades to a whole-machine domain and reports it, which an initial reading of the session got +wrong and the code corrected. + +The design questions the session opened are deliberately **not** queued here. It is still open, and its +conclusions belong to it until it converges. + +- [ ] **M20.1** -- Correct the L3 heuristic's justification in + [DESIGN-NOTES.md](DESIGN-NOTES.md). It currently says the last-level-cache domain "is meaningful on Intel + and ARM too, where the NUMA node often is not." **Measured counter-example:** a Snapdragon X2 Elite + (X2E80100, Qualcomm Oryon; 12 cores, no SMT) reports **zero** L3 cache domains -- `L3CacheSize = 0` from + WMI, and `GetLogicalProcessorInformationEx` yields L1 and L2 only, with L2 forming two domains of six + processors that agree with the two `Module` domains. The claim that L3 is meaningful on ARM is false on a + shipping part. Keep the finding that L3 beats the NUMA node; restate the rule as **the outermost cache + level that actually partitions the machine**, and say what happens when no such level is reported. Sweep + every restatement of the L3 rule per the repository's blast-radius convention, including the README and + `ring_copy`'s `policy.rs` doc comments, not only the one sentence quoted above. + +- [ ] **M20.2** -- Record the measurement itself as a decision in + [DESIGN-NOTES.md](DESIGN-NOTES.md), so the next reader inherits the datapoint rather than re-measuring: + an ARM Windows laptop with no L3 at all, and zero `Win32_NumaNode` instances, is the *common* consumer + shape now rather than an exotic one. This is the ARM sibling of the existing zero-NUMA-node VM + observation and belongs beside it. + +- [ ] **M20.3** -- Make `ring_copy`'s degraded-fallback path observable in a test. The whole-machine + fallback in `Policy::select` is the branch every zero-relation machine takes, and this session was the + first time anyone confirmed it runs. Assert both halves on a synthetic topology: that a policy whose + relation is absent returns one whole-machine domain with `degraded = true`, and that a policy whose + relation is present is **not** flagged degraded -- the second half matters because a test of the first + alone would pass against a function that always degrades. + ## M6+ -- Model B: explicit-thread delivery and affinity Parked, not pending. Deferred by the engineer's explicit direction during the 2026-08-22 design session, diff --git a/crates/windows-ioring-sys/PLANS.md b/crates/windows-ioring-sys/PLANS.md index 1cb4183a..214d9808 100644 --- a/crates/windows-ioring-sys/PLANS.md +++ b/crates/windows-ioring-sys/PLANS.md @@ -6,4 +6,4 @@ contained are archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md). Desi | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| -| [CHECKLIST.md](CHECKLIST.md) | in progress | Memory-safe Rust over the Windows 11 / Server 2022 `IoRing` submission/completion ring, as a separate crate from `windows-overlapped-io-sys` (duplicate-then-decide). Covers ring lifecycle and capability negotiation, zero-allocation token-owned buffers, the batch submission builder, threadless delivery through `ThreadpoolWait`, file/buffer registration, consumer-facing documentation, and the `ring-copy` topology-aligned sample (M1-M7 archived). The pinned-thread (Model B) architecture remains parked as `M6+` by the engineer's explicit direction. `M8` (complete) closed a PR #20 review finding: `FileRef::Raw(HANDLE)`'s lifetime gap, fixed with `unsafe fn` raw entry points plus a safe, `Arc`-backed `SharedFile` wrapper for the common case. `M9` (complete) closed further PR #20 review findings: cross-ring `Token`/`RegisteredFile`/`RegisteredBuffers` confusion (a new per-ring `RingId`, checked at claim/push time), `PendingBufferRegistration` freeing its buffers instead of leaking them on an unclaimed drop, and `Batch::do_submit` letting `Drop` silently retry an already-attempted, already-failed submit. `M10` (active) finished auditing the ring completion contract against all ten specification-gap categories (M10.1-M10.3 complete): category 3 found that `supports` answers for the kernel's op table rather than this crate's push surface and that the registration one-shot is spent by queueing rather than succeeding (D-28); categories 1, 2, 6, 8 and 9 established the load-bearing rule that **every successfully queued SQE produces exactly one completion**, plus that this crate deliberately joins nothing (D-29, D-30); and D-14's registration-index continuity assumption was **dissolved rather than measured** -- the collision it guarded against needs a second registration, which was forbidden the day after D-14 was written, leaving only the reserved-not-confirmed meaning of the public counts to state (D-31). The audit also surfaced two API gaps, now queued as work rather than left in the design notes: `M10.4` (complete) gave `FileRef::Registered` safe entry points, since a registered index carries no lifetime obligation and the `unsafe` guarding it was vacuous (D-29): the safe pushes are now generic over a sealed `FileTarget` trait whose associated `Guard` type carries the one real difference between the two targets, which also made the fully-registered (registered file *and* registered buffer) combination expressible for the first time, non-breakingly (D-33). Investigating M10's own recorded test failures then found a **live use-after-free in shipped 0.1.2** and fixed it as `M10.6`: `BuildIoRingRegisterBuffers` reads its `IORING_BUFFER_INFO` array when the op runs rather than at build time -- the opposite of its file-handle sibling, which the rustdoc had wrongly generalized across -- so the array is now owned by the `IoRing` (D-32). `M10.5` (complete) added named predicates for the conditions a consumer must branch on -- `IORING_E_SUBMISSION_QUEUE_FULL` above all, which every push's rustdoc names as the backpressure signal but which `io::Error::kind()` cannot discriminate (D-30): a complete `RingCondition` enum, predicates for the runtime-actionable conditions, and a sealed `IoRingErrorExt` that puts them on `io::Error` so the downcast is named once rather than hand-rolled per call site (D-34). **M10 is complete.** **`M11` is complete and archived** (2026-08-28): it made the completion event a ring primitive, prompted by an external consumer proposal. `IoRing::completion_event` returns an owned duplicate of the ring's own event so a caller can wait on the ring alongside other handles without surrendering it (D-20); its contract is pinned by eleven sabotage-verified tests; `EventDelivery` is re-expressed on top of it, leaving one `SetIoRingCompletionEvent` call site; `windows-threadpool-sys` moved behind a default-on `threadpool` feature with CI building both combinations (D-22); the wakeup shapes and the barrier's ring-edge limit were swept across every place that states them; and `examples/model_b_multiplexed.rs` works the multiplexed shape end to end. The spike that answered the proposal established that the completion event is **edge-triggered** on the completion queue going empty to non-empty (D-19) -- which also exposed a live bug in shipped 0.1.2, where `EventDelivery` permanently stranded completions queued before handover, fixed in M11.3 by the same change that consolidated `EventDelivery` onto the new primitive. **`M12` is complete and archived** (2026-08-28): it addressed durability, from the same exchange. A spike had established that a flush **without** `DRAIN_PRECEDING_OPS` does not cover preceding writes (D-23) while the barrier that fixes it is a full ring-wide stall spanning submissions (D-24), making `Batch::flush` with default options a silent data-loss bug rather than a missing feature. `Batch::flush`/`flush_raw` now require an explicit `FlushCoverage`, so that spelling no longer exists; a `NO_BUFFERING` integration test proves the barrier's behaviour rather than its flag, and measured that *which direction* the reordering shows in is device-dependent (amending D-23); and the parameters the crate had hardcoded away are exposed as `WriteCaching` (`FILE_WRITE_FLAGS`) and `FlushMode` (`FILE_FLUSH_MODE`, whose `NoSync` is the one mode that commits nothing). Durability had been absent from `lib.rs` and `README.md` entirely, and both now state the three facts. **`M13` is complete and archived** (2026-08-29): the `epoch_log` sample is the vehicle D-26 makes for carrying durability *policy* to consumers without this crate owning it. Its own durability contract is written down first, in its own words (Design Autonomy), then implemented -- records composed into a registered arena and appended, epochs closed by one covering flush whose completion is what makes `is_durable` answer `true`, and a multiplexed wait on the ring's completion event alongside a shutdown latch. The replay pass is what turns it from a demonstration into evidence: it holds the durable region to a strict standard, tolerates a torn tail as the contract requires, and is itself proved able to fail by a negative control. Writing it also found and fixed a gap in the crate (D-35: per-buffer outstanding accounting and `RegisteredBuffers::get_mut`). **`M14` is complete and archived** (2026-08-29): the second half of the sample, covering the two things the ring cannot do for a consumer. A non-ring `FSCTL` (`FSCTL_SET_ZERO_DATA`, reclaiming a retired segment) is ordered against ring epochs by the log itself, since `drain_preceding` orders SQEs against SQEs (D-24) and reaches across neither the ring boundary nor a second ring; a thread-pool control plane runs checkpointing as Model A on a *second* ring while the log thread keeps Model B for the data path, because D-21 forbids a ring given to `EventDelivery` also being waited on directly -- so the ordering chain crosses log thread to pool thread to reclaim worker and back with the log thread blocking for none of it. All three epoch-commit strategies are implemented behind one interface, checked both by replay and by requiring the three to be **byte-identical**, and measured on the running machine. The measurement's finding is that the three are **indistinguishable** here -- the cross-strategy spread is the size of one strategy's run-to-run spread, because every strategy pays one device flush per epoch at hundreds of microseconds while their real differences land in the tens -- and it found two harness bugs nothing else caught, including one where a barrier benchmark that awaits each commit before appending again measures the barrier as free. Both findings are promoted into "Durability on the ring" in [DESIGN-NOTES.md](DESIGN-NOTES.md), since both are about the design rather than the demonstration. **`M15`-`M18` (not started) are the testing-strategy response to the eight defects the 0.1.x line and the M11-M14 branch produced.** They are organised by *defect population* rather than by technique, because the populations need different tools and one of them needs a tool that does not exist: (A) preconditions never varied -- every `event_delivery` test handed over a fresh ring, which is why [#47](https://github.com/MikeGrier/windows-threadpool-sys/issues/47) survived; (B) failure paths never taken -- no test ever ran `completion.result()` returning `Err`, which is why the checkpoint path could authorise a reclaim after a failed write; and (C) *permissions rather than behaviour* -- `&mut Vec` permits `reserve`/`resize`/reassign though no code path performs it, which is [D-35](DESIGN-NOTES.md#d-35) and [D-36](DESIGN-NOTES.md#d-36), the two most severe findings, and **no runtime technique reaches that population at all**. M15 and M16 gate the 0.2.0 release. M15 is deterministic memory instrumentation: a guard-page global allocator, chosen over Application Verifier / PageHeap by measurement rather than assumption ([D-37](DESIGN-NOTES.md#d-37) -- PageHeap works and `reg add` alone is enough, but IFEO is keyed by image file name and one test target produced six distinct hashed names in a day), plus a tracked poison pattern covering the gap guard pages structurally cannot see ([D-38](DESIGN-NOTES.md#d-38) -- a guard page catches access to memory that should not be touched, and is blind to the kernel writing into a live, valid buffer, which is exactly what `write_registered` and `read_registered` promise it will not do). M16 makes the contract executable: a public `RingContract` rendering [the category-2 rule](DESIGN-NOTES.md#one-sqe-one-completion), "one SQE, exactly one completion", checkable rather than merely stated, plus a fault-injection seam that finally takes the failure paths. M17 covers A by generating over the operation space instead of enumerating it by hand, gated on an explicit decision about randomized sampling that this component's conventions require be approved and recorded rather than assumed. M18 covered C, where review is a *primary* technique rather than a backstop, and added `cargo-mutants` against a measured rate of vacuous tests. **M8 through M18 are now complete and archived**, leaving only `M6+`, which is parked rather than pending. The strategy as a whole -- which technique reaches which population, what each one actually found, and what none of them reach -- is recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md#testing-strategy-m185); mutation coverage went from 79.7% to 95.8%, and the borrow-surface audit found one further defect of the same shape as the two that prompted it ([D-43](DESIGN-NOTES.md#d-43)). A mock `IoRing` was considered and **rejected**: both shipped defects were the kernel behaving differently from this crate's assumptions, so a mock would have encoded the same assumptions and passed both bugs green. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-28-completion-event-multiplexing.md](design-sessions/DESIGN-SESSION-2026-08-28-completion-event-multiplexing.md), [DESIGN-SESSION-2026-08-28-external-consumer-correspondence.md](design-sessions/DESIGN-SESSION-2026-08-28-external-consumer-correspondence.md) | +| [CHECKLIST.md](CHECKLIST.md) | in progress | Memory-safe Rust over the Windows 11 / Server 2022 `IoRing` submission/completion ring, as a separate crate from `windows-overlapped-io-sys` (duplicate-then-decide). Covers ring lifecycle and capability negotiation, zero-allocation token-owned buffers, the batch submission builder, threadless delivery through `ThreadpoolWait`, file/buffer registration, consumer-facing documentation, and the `ring-copy` topology-aligned sample (M1-M7 archived). The pinned-thread (Model B) architecture remains parked as `M6+` by the engineer's explicit direction. `M8` (complete) closed a PR #20 review finding: `FileRef::Raw(HANDLE)`'s lifetime gap, fixed with `unsafe fn` raw entry points plus a safe, `Arc`-backed `SharedFile` wrapper for the common case. `M9` (complete) closed further PR #20 review findings: cross-ring `Token`/`RegisteredFile`/`RegisteredBuffers` confusion (a new per-ring `RingId`, checked at claim/push time), `PendingBufferRegistration` freeing its buffers instead of leaking them on an unclaimed drop, and `Batch::do_submit` letting `Drop` silently retry an already-attempted, already-failed submit. `M10` (active) finished auditing the ring completion contract against all ten specification-gap categories (M10.1-M10.3 complete): category 3 found that `supports` answers for the kernel's op table rather than this crate's push surface and that the registration one-shot is spent by queueing rather than succeeding (D-28); categories 1, 2, 6, 8 and 9 established the load-bearing rule that **every successfully queued SQE produces exactly one completion**, plus that this crate deliberately joins nothing (D-29, D-30); and D-14's registration-index continuity assumption was **dissolved rather than measured** -- the collision it guarded against needs a second registration, which was forbidden the day after D-14 was written, leaving only the reserved-not-confirmed meaning of the public counts to state (D-31). The audit also surfaced two API gaps, now queued as work rather than left in the design notes: `M10.4` (complete) gave `FileRef::Registered` safe entry points, since a registered index carries no lifetime obligation and the `unsafe` guarding it was vacuous (D-29): the safe pushes are now generic over a sealed `FileTarget` trait whose associated `Guard` type carries the one real difference between the two targets, which also made the fully-registered (registered file *and* registered buffer) combination expressible for the first time, non-breakingly (D-33). Investigating M10's own recorded test failures then found a **live use-after-free in shipped 0.1.2** and fixed it as `M10.6`: `BuildIoRingRegisterBuffers` reads its `IORING_BUFFER_INFO` array when the op runs rather than at build time -- the opposite of its file-handle sibling, which the rustdoc had wrongly generalized across -- so the array is now owned by the `IoRing` (D-32). `M10.5` (complete) added named predicates for the conditions a consumer must branch on -- `IORING_E_SUBMISSION_QUEUE_FULL` above all, which every push's rustdoc names as the backpressure signal but which `io::Error::kind()` cannot discriminate (D-30): a complete `RingCondition` enum, predicates for the runtime-actionable conditions, and a sealed `IoRingErrorExt` that puts them on `io::Error` so the downcast is named once rather than hand-rolled per call site (D-34). **M10 is complete.** **`M11` is complete and archived** (2026-08-28): it made the completion event a ring primitive, prompted by an external consumer proposal. `IoRing::completion_event` returns an owned duplicate of the ring's own event so a caller can wait on the ring alongside other handles without surrendering it (D-20); its contract is pinned by eleven sabotage-verified tests; `EventDelivery` is re-expressed on top of it, leaving one `SetIoRingCompletionEvent` call site; `windows-threadpool-sys` moved behind a default-on `threadpool` feature with CI building both combinations (D-22); the wakeup shapes and the barrier's ring-edge limit were swept across every place that states them; and `examples/model_b_multiplexed.rs` works the multiplexed shape end to end. The spike that answered the proposal established that the completion event is **edge-triggered** on the completion queue going empty to non-empty (D-19) -- which also exposed a live bug in shipped 0.1.2, where `EventDelivery` permanently stranded completions queued before handover, fixed in M11.3 by the same change that consolidated `EventDelivery` onto the new primitive. **`M12` is complete and archived** (2026-08-28): it addressed durability, from the same exchange. A spike had established that a flush **without** `DRAIN_PRECEDING_OPS` does not cover preceding writes (D-23) while the barrier that fixes it is a full ring-wide stall spanning submissions (D-24), making `Batch::flush` with default options a silent data-loss bug rather than a missing feature. `Batch::flush`/`flush_raw` now require an explicit `FlushCoverage`, so that spelling no longer exists; a `NO_BUFFERING` integration test proves the barrier's behaviour rather than its flag, and measured that *which direction* the reordering shows in is device-dependent (amending D-23); and the parameters the crate had hardcoded away are exposed as `WriteCaching` (`FILE_WRITE_FLAGS`) and `FlushMode` (`FILE_FLUSH_MODE`, whose `NoSync` is the one mode that commits nothing). Durability had been absent from `lib.rs` and `README.md` entirely, and both now state the three facts. **`M13` is complete and archived** (2026-08-29): the `epoch_log` sample is the vehicle D-26 makes for carrying durability *policy* to consumers without this crate owning it. Its own durability contract is written down first, in its own words (Design Autonomy), then implemented -- records composed into a registered arena and appended, epochs closed by one covering flush whose completion is what makes `is_durable` answer `true`, and a multiplexed wait on the ring's completion event alongside a shutdown latch. The replay pass is what turns it from a demonstration into evidence: it holds the durable region to a strict standard, tolerates a torn tail as the contract requires, and is itself proved able to fail by a negative control. Writing it also found and fixed a gap in the crate (D-35: per-buffer outstanding accounting and `RegisteredBuffers::get_mut`). **`M14` is complete and archived** (2026-08-29): the second half of the sample, covering the two things the ring cannot do for a consumer. A non-ring `FSCTL` (`FSCTL_SET_ZERO_DATA`, reclaiming a retired segment) is ordered against ring epochs by the log itself, since `drain_preceding` orders SQEs against SQEs (D-24) and reaches across neither the ring boundary nor a second ring; a thread-pool control plane runs checkpointing as Model A on a *second* ring while the log thread keeps Model B for the data path, because D-21 forbids a ring given to `EventDelivery` also being waited on directly -- so the ordering chain crosses log thread to pool thread to reclaim worker and back with the log thread blocking for none of it. All three epoch-commit strategies are implemented behind one interface, checked both by replay and by requiring the three to be **byte-identical**, and measured on the running machine. The measurement's finding is that the three are **indistinguishable** here -- the cross-strategy spread is the size of one strategy's run-to-run spread, because every strategy pays one device flush per epoch at hundreds of microseconds while their real differences land in the tens -- and it found two harness bugs nothing else caught, including one where a barrier benchmark that awaits each commit before appending again measures the barrier as free. Both findings are promoted into "Durability on the ring" in [DESIGN-NOTES.md](DESIGN-NOTES.md), since both are about the design rather than the demonstration. **`M15`-`M18` (not started) are the testing-strategy response to the eight defects the 0.1.x line and the M11-M14 branch produced.** They are organised by *defect population* rather than by technique, because the populations need different tools and one of them needs a tool that does not exist: (A) preconditions never varied -- every `event_delivery` test handed over a fresh ring, which is why [#47](https://github.com/MikeGrier/windows-threadpool-sys/issues/47) survived; (B) failure paths never taken -- no test ever ran `completion.result()` returning `Err`, which is why the checkpoint path could authorise a reclaim after a failed write; and (C) *permissions rather than behaviour* -- `&mut Vec` permits `reserve`/`resize`/reassign though no code path performs it, which is [D-35](DESIGN-NOTES.md#d-35) and [D-36](DESIGN-NOTES.md#d-36), the two most severe findings, and **no runtime technique reaches that population at all**. M15 and M16 gate the 0.2.0 release. M15 is deterministic memory instrumentation: a guard-page global allocator, chosen over Application Verifier / PageHeap by measurement rather than assumption ([D-37](DESIGN-NOTES.md#d-37) -- PageHeap works and `reg add` alone is enough, but IFEO is keyed by image file name and one test target produced six distinct hashed names in a day), plus a tracked poison pattern covering the gap guard pages structurally cannot see ([D-38](DESIGN-NOTES.md#d-38) -- a guard page catches access to memory that should not be touched, and is blind to the kernel writing into a live, valid buffer, which is exactly what `write_registered` and `read_registered` promise it will not do). M16 makes the contract executable: a public `RingContract` rendering [the category-2 rule](DESIGN-NOTES.md#one-sqe-one-completion), "one SQE, exactly one completion", checkable rather than merely stated, plus a fault-injection seam that finally takes the failure paths. M17 covers A by generating over the operation space instead of enumerating it by hand, gated on an explicit decision about randomized sampling that this component's conventions require be approved and recorded rather than assumed. M18 covered C, where review is a *primary* technique rather than a backstop, and added `cargo-mutants` against a measured rate of vacuous tests. **M8 through M18 are now complete and archived**, leaving only `M6+`, which is parked rather than pending. The strategy as a whole -- which technique reaches which population, what each one actually found, and what none of them reach -- is recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md#testing-strategy-m185); mutation coverage went from 79.7% to 95.8%, and the borrow-surface audit found one further defect of the same shape as the two that prompted it ([D-43](DESIGN-NOTES.md#d-43)). A mock `IoRing` was considered and **rejected**: both shipped defects were the kernel behaving differently from this crate's assumptions, so a mock would have encoded the same assumptions and passed both bugs green. **M20** queues the documentation and policy-test repairs from the 2026-08-30 NUMA-sharding measurement: a shipping ARM laptop reports no L3 cache domain at all, which falsifies the justification given for the last-level-cache heuristic (though not the heuristic's preference over the NUMA node, and not `ring_copy`, whose degraded fallback already handles it correctly). | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-28-completion-event-multiplexing.md](design-sessions/DESIGN-SESSION-2026-08-28-completion-event-multiplexing.md), [DESIGN-SESSION-2026-08-28-external-consumer-correspondence.md](design-sessions/DESIGN-SESSION-2026-08-28-external-consumer-correspondence.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](../../design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md new file mode 100644 index 00000000..41f97b8d --- /dev/null +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -0,0 +1,250 @@ +# Design session -- NUMA-sharded I/O execution domains (2026-08-30) + +> Tier-3 record. [DESIGN-NOTES.md](../DESIGN-NOTES.md) is authoritative and wins +> on any conflict. This file records how the discussion went, what was measured, +> and what is still open. + +**Status: OPEN. This session has only just begun.** What follows is the opening +survey and the first measurement, not a set of converged decisions. No decision +below is settled unless it says so explicitly. + +Repo-wide by scope: it touches +[windows-ioring-sys](../crates/windows-ioring-sys/DESIGN-NOTES.md), +[windows-threadpool-sys](../crates/windows-threadpool-sys/README.md) (which has no +DESIGN-NOTES.md of its own), +[windows-topology-sys](../crates/windows-topology-sys/DESIGN-NOTES.md), and the +deferred namespace facility designed in +[DESIGN-SESSION-2026-08-27-pseudo-async-namespace-operations.md](DESIGN-SESSION-2026-08-27-pseudo-async-namespace-operations.md). + +## Starting intent + +The engineer's framing: a high-performance flow of deferred `CreateFile` -> +offloaded `CreateFile` -> `IoRing` (or several), with NUMA-affined buffers +backing sector-aligned I/O, organized as Seastar-style shards over queues. + +Stated afterwards, and worth recording because it shapes how the material below +should be read: the topic was seeded to see what would grow from prior work +planted around the repository, rather than started from nothing. + +## What already exists, so it is not redesigned + +- **The namespace/data plane split is decided.** Win32 is asynchronous on the + data plane (overlapped I/O, `IoRing`) and synchronous-only on the namespace + plane (open, delete, rename, attributes). "Deferred `CreateFile` feeding an + `IoRing`" is precisely a namespace-plane operation handing to a data-plane one. +- **The Win32 ring is not the `IoRing`** (that session's decision 7): share the + ring type, not the storage, and unify at the wait. +- **The namespace facility owns no threads**: pooled, elastic, `runs_long` + mandatory, quarantine ceiling. +- **Model B is already specified** in the ioring crate's + [DESIGN-NOTES.md](../crates/windows-ioring-sys/DESIGN-NOTES.md), naming the unit + as an execution domain -- one pinned thread, its ring, its node-local + registered buffer pool, its shard of the work -- with + [D-27](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-27) arguing pinning is + what makes per-thread a proxy for per-CPU. +- **`ring_copy` already implements it end to end**: `SetThreadGroupAffinity` per + domain, `VirtualAllocExNuma` buffers, one ring per domain, policy selectable + as `ByL3` / `ByNode` / `ByPackage` / `ByCore` / `Single`. +- **[D-8](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-8) reserves the + abstraction** being discussed here: a `RingFleet`-style layer, deferred until + "there is evidence about what sharding actually helps." + +## Constraints established during the session + +- **Registration is one-shot per ring, for both buffers and file handles.** + Verified in `Batch::register_buffers` and `Batch::register_files`: a second + call is refused because `BuildIoRingRegister*` **replaces the whole table**, + invalidating every index already handed out. A freshly opened handle therefore + **cannot be added to a running ring's file table**. Any "deferred `CreateFile` + -> ring" pipeline that expects to register each new file hits this on + operation two. +- **Ring count is forced to equal pinned-thread count**, because the submission + queue is not thread-safe. Choosing a coarse policy does not give many threads + sharing few rings; it gives *few threads*. +- **Processor groups are a hard floor**: above 64 logical processors the + partition is forced whether wanted or not. +- **No library in this workspace can pin a thread.** `windows-topology-sys` is + read-only and reports `ProcessorSet` without applying it; + `windows-threadpool-sys` has no affinity, NUMA, or pinning support at all. The + only pinning in the repository is inline in the `ring_copy` example. +- **No SPSC or MPSC queue exists in this workspace.** There is a domain-specific + bounded queue in `windows-file-watcher` and `std::sync::mpsc` in a probe. + Nothing general, and no `crossbeam` dependency. + +## Crossbeam: assessed, and it does not provide the doorbell + +The engineer asked specifically about crossbeam's doorbell mechanism and its +buffer management. Checked against the published API rather than reputation: + +- **`crossbeam-queue`** (`ArrayQueue`, `SegQueue`) is lock-free MPMC. `pop` + returns `Option`; it never blocks and never signals. There is nothing to wait + on. +- **`crossbeam-channel`** blocks in `recv`, but parks on its own internal + primitive and exposes **no waitable HANDLE**. Its `Select` is built purely + from channel operations (`sel.recv`, `sel.send`); there is no method to + register a foreign OS object. + +So the mismatch is symmetric and fatal for a Model B shard: +`WaitForMultipleObjects` cannot see a crossbeam channel, and crossbeam's +`Select` cannot see the `IoRing` completion event. A shard that must park on +"my ring completed something **or** a peer sent me work" cannot express that +wait with crossbeam, and would have to poll one while blocking on the other. + +**This workspace already solved the doorbell problem** in +[queue.rs](../crates/windows-file-watcher/src/queue.rs), whose module +documentation states the general principle: on Windows a HANDLE **is** the +universal waitable currency, so an event is the native composition point rather +than a lowest common denominator. It hands out a lazily created manual-reset +event, signalled under the same lock a receiver holds while deciding there is +nothing to take, "so a wakeup cannot be lost in the gap between those two +decisions, because there is no gap" -- the same lost-wakeup hazard class as +[D-19](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-19)'s edge-triggered ring +contract. + +**Buffer management, two senses, and conflating them is a trap.** `ArrayQueue` +allocates a fixed buffer at construction and fails `push` when full -- that +failure is the backpressure signal. `SegQueue` is unbounded, allocates segments +on demand, and needs deferred reclamation, which couples shards that were +supposed to share nothing. But either way crossbeam manages **message** storage, +never **I/O buffer** storage: the I/O buffers are the registered pool, allocated +once, NUMA-affined, sector-aligned, one-shot per ring. A cross-shard queue must +carry descriptors (buffer index, handle, completion record), never bytes -- if it +carried bytes, the copy would have defeated the reason for registering. + +*Not yet decided:* whether to adopt `crossbeam-queue` for the data structure and +add a doorbell beside it, or build the queue with the doorbell integral. The +argument for integral is the file-watcher's: an external doorbell cannot be +signalled under the queue's own lock, which reintroduces the gap. + +## What "shard" means here + +Asked directly, and answered: the shard is **the execution domain -- one pinned +thread**, with its ring, its node-local registered pool, and its slice of state. +It is **not** the last-level cache domain. L3 is one *policy* for deciding how +many shards and where to pin them. + +A consequence that is easy to miss: because ring count equals thread count, +selecting `ByL3` on a 64-core, 8-CCX part does not yield 64 threads over 8 rings. +It yields **8 pinned threads in total**. That is correct for `ring_copy`, a +bandwidth-bound copy where a few threads saturate memory, and probably wrong for +a general execution substrate, where Seastar shards per logical core and uses +NUMA only to place each shard's memory. The repository currently has one answer +where the effort may need two. + +## Measurement M-1: a shipping ARM laptop reports no L3 at all + +Probed with `Topology::discover()` on the development machine. + +**Snapdragon(R) X2 Elite - X2E80100 - Qualcomm Oryon(TM) CPU**, 12 cores, 12 +logical processors, no SMT: + +``` +processor groups : 1 {0: 12} +domains by kind : Cache 26, Core 12, Group 1, Memory(NUMA) 1, Module 2, Package 1 +cache by level : L1 -> 24 domains (1 processor each) + L2 -> 2 domains (6 processors each) + L3 -> none +pinned-thread count each ring_copy policy would produce: + ByCore 12 | ByL3 0 | ByNode 1 | ByPackage 1 | Single 1 +``` + +Corroborated by WMI: `L3CacheSize = 0`, and **zero `Win32_NumaNode` instances** -- +the same signature the ioring notes already recorded for the machine they were +investigated on. + +**What this does and does not show.** An initial reading of this session claimed +`ByL3` returning zero domains would create zero rings. **That was wrong, and +checking the code corrected it**: `Policy::select` falls through to a +whole-machine domain when the preferred relation matches nothing, and reports +`degraded = true` so the sample can say so honestly. There is no defect in +`ring_copy`. + +What the measurement does falsify is narrower and is in the prose. The ioring +notes justify the L3 heuristic partly by saying it "is meaningful on Intel and +ARM too, where the NUMA node often is not." On this ARM part L3 is **not** +meaningful, because it does not exist; the natural cluster boundary is **L2**, +two domains of six, corroborated by the two `Module` domains the same probe +reported. The heuristic is still right that L3 beats the NUMA node. The durable +idea underneath it appears to be "the outermost cache level that actually +partitions the machine," not "L3 specifically" -- and on this machine the +difference is between describing two clusters and describing one whole machine. + +Whether the two-cluster structure is worth using is a separate question this +session has not answered. + +## Working position on domain counts (not a decision) + +Only the first row is measured. The rest are from published topologies and must +be treated as unverified until probed. + +| Configuration | Cores / LPs | Cache domains | Nodes | Groups | I/O domains | +|---|---|---|---|---|---| +| Snapdragon X2 Elite (measured) | 12 / 12 | 2 x L2, no L3 | 1 | 1 | 1 | +| Intel Core Ultra laptop (P+E+LPE) | ~16 / 22 | 1 x L3 | 1 | 1 | 1 | +| Cloud VM, 4-8 vCPU | 4-8 | 0-1 | 0-1 | 1 | 1 | +| Ryzen 7800X3D / 9800X3D | 8 / 16 | 1 x L3 | 1 | 1 | 1 | +| Ryzen 7950X | 16 / 32 | 2 x L3 (2 CCD) | 1 | 1 | 1-2 | +| Threadripper / 1-socket Xeon | 32-64 | 2-8 | 1-4 | 1-2 | 2-4 | +| EPYC 9004 96-core NPS1 | 96 / 192 | 12 x L3 | 1 | 3 forced | 4-12 | +| Dual-socket Xeon SPR + SNC | 64 / 128 | 2 x L3 | 4 | 2 | 2-4 | + +The reasoning behind the numbers matters more than the numbers: + +- **The bound is the storage device, not the CPU.** Shard-per-core is a Seastar + *application* answer, where the shard owns application state and I/O is + incidental. For an *I/O domain* count, throughput comes from queue depth, not + thread count: one consumer NVMe drive is saturated by one or two submitting + threads at moderate queue depth, and further threads add registered pools and + device-queue contention without adding throughput. +- **The notes already point here.** "Buffer placement probably dominates thread + placement": a buffer on a node remote from the device means every byte crosses + the interconnect forever, where the callback's location is a one-time + cache-warmth question. If placement relative to the *device* dominates, the + device sets the partition. +- **Registration punishes guessing high.** Registration pins pages and is + one-shot per ring, so N domains means N separately pinned pools: a 256 MiB + working set is 256 MiB pinned at one domain, or 3 GiB at twelve, or twelve + pools too small to keep a device busy. +- **The mapping needed is not discoverable.** The ioring notes state that file + handle to device NUMA node "has no clean user-mode path" and that the crate + will not offer an automatic placement. Device topology is therefore + configuration, not detection. + +**Proposed default, not yet agreed:** one domain, adding one only when the +device it serves can be named; above 64 logical processors take the group floor +and no more. + +## Open questions + +- **Which end to design from.** Three candidates were put: (a) the seam between + the pooled namespace plane and the pinned data plane, given one-shot + registration and no shared ring storage; (b) the `RingFleet` that + [D-8](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-8) deferred; (c) the queue + and doorbell substrate. Not yet chosen. +- **How a newly opened handle reaches a shard at all**, given registration cannot + be extended. Candidates not yet evaluated: pre-registered fixed tables with + slot management, unregistered handles on the hot path, ring recycling. +- **Shard count for a general substrate**: per core, or per outermost + partitioning cache. Related: whether an I/O domain count and an application + shard count are the same number, which the "all three coincide" argument + assumes. +- **Heterogeneous cores.** Intel P/E/LPE and ARM performance/efficiency clusters + mean pinning an I/O shard to an efficiency core is a latency trap. + `windows-topology-sys` exposes `efficiency_class` on `Core`, so the input + exists; no policy consumes it. +- **Whether the L2 cluster structure on ARM parts is worth sharding on**, or + whether one whole-machine domain is the right answer there anyway. + +## Corrections made during the session + +- **"`ByL3` yielding zero domains would create zero rings" was wrong.** + `Policy::select` already degrades to a whole-machine domain and flags it. The + surviving finding is about the *justification prose* in the ioring notes, not + about the sample's behaviour. +- **"The `epoch_log` example does sector-aligned I/O" was wrong**, and was part + of the session's opening premise. That example opens a plain file and gets + alignment structurally from 4096-byte slots. The actual + `FILE_FLAG_NO_BUFFERING` work lives in `tests/flush_barrier.rs` and + `tests/handover.rs`. NUMA-affined buffers (`ring_copy`) and sector-aligned + unbuffered I/O (those tests) are currently **two separate things in two + separate places**, never combined. From 1db3206c328a226d26b176ba4eaa59d35cd4d849 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 14:47:45 -0400 Subject: [PATCH 002/361] docs: record F-1, the file-handle NUMA finding, and check in its spike The engineer's research corrects a claim this crate's DESIGN-NOTES makes. The notes say mapping a file handle to its backing device's NUMA node "has no clean user-mode path" and "means walking volume to disk to device instance". That is wrong on mechanism: FSCTL_QUERY_VOLUME_NUMA_INFO is documented in the IFS docs, takes a handle to a file or directory directly, and returns FSCTL_QUERY_VOLUME_NUMA_INFO_OUTPUT { ULONG NumaNode }. Confirmed independently against the documentation. The notes' conclusion survives for a better reason, which is the point of the correction now queued as M20.4: the documented meaning is the node the *volume* resides on, not where the file's extents live, so it cannot answer "which ring should this file's I/O use" even when it succeeds, and it is absent whenever the device advertised no proximity domain. GetNumaNodeNumberFromHandle is recorded as the other path -- NtQueryInformationFile with FileNumaNodeInformation, class 53 -- and as reserved for system use per PHNT and the WDK, so this crate must not build on it. Checks in file-handle-numa-spike.rs as a ready instrument with no result. This is a named hardware blocker rather than a deferral: settling it needs more than one NUMA node and storage whose PDO advertises a proximity domain, and this machine has a single node and reports zero Win32_NumaNode instances. The spikes README already invites runs on other hardware; the new entry states plainly that this one establishes nothing yet. Smoke-running it on the vacuous machine paid for itself twice: - It found a defect in the instrument. Q5 opened the directory with File::open, which fails on a directory without FILE_FLAG_BACKUP_SEMANTICS, so that question could never have been answered. Now CreateFileW. An instrument checked in unrun is one whose bugs are still in it. - It established one narrow fact. On ARM64 Windows, single node, both calls succeed on a garden-variety NTFS data file and on a directory handle, and agree on 0. That is directly responsive to "no published experiment shows GetNumaNodeNumberFromHandle succeeding on a garden-variety NTFS data file". It does not show either naming a *meaningful* node, since 0 is the only node present -- but it does show that an ordinary NTFS file is not itself the no-association case, so absence must come from the device layer. Also records the design consequence, which is favourable: the namespace worker holds the handle at the instant it completes the open, so a volume-granular routing key is available for free at the seam, with no extra open and no device tree walk. That does not make automatic placement correct, and the crate should still not offer it, but the information is cheaper than the notes imply. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-ioring-sys/CHECKLIST.md | 18 ++ .../design-sessions/spikes/README.md | 26 +++ .../spikes/file-handle-numa-spike.rs | 182 ++++++++++++++++++ ...08-30-numa-sharded-io-execution-domains.md | 105 +++++++++- 4 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs diff --git a/crates/windows-ioring-sys/CHECKLIST.md b/crates/windows-ioring-sys/CHECKLIST.md index 183c83b5..81709f9d 100644 --- a/crates/windows-ioring-sys/CHECKLIST.md +++ b/crates/windows-ioring-sys/CHECKLIST.md @@ -111,6 +111,24 @@ conclusions belong to it until it converges. relation is present is **not** flagged degraded -- the second half matters because a test of the first alone would pass against a function that always degrades. +- [ ] **M20.4** -- Correct "What is not reachable" in [DESIGN-NOTES.md](DESIGN-NOTES.md). It says mapping a + file handle to its backing device's NUMA node "has no clean user-mode path" and "means walking volume to + disk to device instance and reading `DEVPKEY_Device_Numa_Node`". **That is wrong on mechanism.** + `FSCTL_QUERY_VOLUME_NUMA_INFO` is documented in the IFS docs, takes a handle to a **file or directory** + directly, and returns `FSCTL_QUERY_VOLUME_NUMA_INFO_OUTPUT { ULONG NumaNode }`. No walking required. + The **conclusion survives for a better reason**, and that is the point of the rewrite: the documented + meaning is the node the *volume* resides on, not where the file's extents live, so it cannot answer + "which ring should this file's I/O go to" even when it succeeds; and it is absent whenever the device + advertised no proximity domain. Record `GetNumaNodeNumberFromHandle` as the other path -- a wrapper over + `NtQueryInformationFile` with `FileNumaNodeInformation` (class 53) -- and that PHNT and the WDK mark that + class **reserved for system use**, so this crate must not build on it. State plainly that no published + measurement of either call succeeding on an ordinary NTFS data file could be found, and cite + [file-handle-numa-spike.rs](design-sessions/spikes/file-handle-numa-spike.rs) as the unrun instrument. + **Blocked on hardware, not on a decision:** settling it needs a multi-node machine with storage whose + PDO advertises a proximity domain. Write the correction now (the documentation defect is independent of + the measurement) and leave the empirical question open. + + ## M6+ -- Model B: explicit-thread delivery and affinity Parked, not pending. Deferred by the engineer's explicit direction during the 2026-08-22 design session, diff --git a/crates/windows-ioring-sys/design-sessions/spikes/README.md b/crates/windows-ioring-sys/design-sessions/spikes/README.md index 1cac4d8f..4685d013 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/README.md +++ b/crates/windows-ioring-sys/design-sessions/spikes/README.md @@ -19,6 +19,32 @@ windows-sys = { version = "0.61.2", default-features = false, features = [ | [completion-event-spike.rs](completion-event-spike.rs) | [D-19](../../DESIGN-NOTES.md#d-19) -- the completion event is edge-triggered on the completion queue going empty to non-empty; also what `SetIoRingCompletionEvent` permits (call at any time, replace, clear with `NULL`, duplicate survives closing the original) | | [drain-ordering-spike.rs](drain-ordering-spike.rs) | [D-23](../../DESIGN-NOTES.md#d-23) -- an unflagged flush does not cover preceding writes; [D-24](../../DESIGN-NOTES.md#d-24) -- `DRAIN_PRECEDING_OPS` is a full, ring-wide barrier spanning submissions | +## One spike here establishes nothing yet + +[file-handle-numa-spike.rs](file-handle-numa-spike.rs) is the exception to the table above: it is a +**ready instrument with no result**, checked in deliberately rather than held back. It asks whether a +file handle yields a NUMA node, and which question that answer answers. + +It is unrun because of a **hardware gap, not a decision to defer**: it needs more than one NUMA node +and storage whose PDO advertises a proximity domain, and the machine this workspace is developed on +has a single node and reports zero `Win32_NumaNode` instances. On such a machine the spike is +vacuous in the same sense the drain spike's control case guards against -- failure would prove +nothing and success could only ever report `0`. It prints that warning itself before running. + +Anyone with a multi-node server and a real NVMe or SAN volume can settle it in a few minutes, and the +result would correct a claim +[DESIGN-NOTES.md](../../DESIGN-NOTES.md) currently makes about what is reachable from user mode. + +It **has** been smoke-run here, which is why it compiles and why its Q5 works: the first version +opened the directory with `File::open`, which fails on a directory without +`FILE_FLAG_BACKUP_SEMANTICS`, so that question could never have been answered. Running an instrument +on hardware where its result is vacuous still validates the apparatus. + +That run also settled one narrow thing worth knowing before you start: on ARM64 Windows with a single +node, **both** calls succeed on an ordinary NTFS data file and on a directory handle, and agree on +`0`. So "ordinary NTFS file" is not the no-association case; absence must come from a device layer +advertising no proximity domain, which is what needs the other hardware. + ## Why the drain spike looks over-built It carries a concurrency check and a control case because the first two versions of it **could not diff --git a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs new file mode 100644 index 00000000..69d9aebc --- /dev/null +++ b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs @@ -0,0 +1,182 @@ +// Copyright (c) Mike Grier +//! Spike: does a file handle yield a NUMA node, and which question does the +//! answer actually answer? +//! +//! **NOT YET RUN ON HARDWARE THAT CAN ANSWER IT.** This file is checked in as a +//! ready instrument, not as a result. It needs a machine with more than one +//! NUMA node and storage whose PDO advertises a proximity domain. On a +//! single-node machine both calls are uninformative: failure proves nothing, +//! and success can only ever say `0`. +//! +//! Questions: +//! Q1 does `FSCTL_QUERY_VOLUME_NUMA_INFO` succeed on a garden-variety NTFS +//! data file handle, and what node does it name? +//! Q2 does `GetNumaNodeNumberFromHandle` succeed on the same handle? +//! Q3 do they agree? If they do, the answer being seen is **volume** +//! locality, not file locality -- which is the whole point of the spike. +//! Q4 what exactly does the negative case look like (which error), since the +//! degradation path has to handle it? +//! Q5 does a directory handle behave the same as a file handle? The IFS docs +//! say the FSCTL accepts either. +//! +//! Why it matters: `DESIGN-NOTES.md` asserts that mapping a file handle to the +//! NUMA node of its backing device "has no clean user-mode path" and "means +//! walking volume to disk to device instance". That is wrong on mechanism -- +//! `FSCTL_QUERY_VOLUME_NUMA_INFO` is documented, takes a file or directory +//! handle directly, and returns `FSCTL_QUERY_VOLUME_NUMA_INFO_OUTPUT { ULONG +//! NumaNode }`. What is *right* is the conclusion, for a different reason: the +//! documented meaning is the node the **volume** resides on, not where the +//! file's extents live, and it is absent whenever the device advertised no +//! proximity domain. +//! +//! `GetNumaNodeNumberFromHandle` is the other path: a Win32 wrapper over +//! `NtQueryInformationFile` with `FileNumaNodeInformation` (class 53, Windows 7 +//! and later), yielding `FILE_NUMA_NODE_INFORMATION { USHORT NodeNumber }`. +//! PHNT and the WDK mark that class **reserved for system use**, so this spike +//! measures it for comparison only. Do not build on it. +//! +//! Run with: +//! ```toml +//! [dependencies] +//! windows-sys = { version = "0.61.2", default-features = false, features = [ +//! "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", +//! "Win32_System_IO", "Win32_System_Ioctl", +//! ] } +//! ``` + +use std::ffi::c_void; +use std::fs; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::io::AsRawHandle; +use std::path::Path; + +use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_READ, HANDLE, INVALID_HANDLE_VALUE}; +use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + OPEN_EXISTING, +}; +use windows_sys::Win32::System::IO::DeviceIoControl; +use windows_sys::Win32::System::Ioctl::FSCTL_QUERY_VOLUME_NUMA_INFO; + +fn to_wide(path: &Path) -> Vec { + path.as_os_str().encode_wide().chain(Some(0)).collect() +} + +// Not present in windows-sys 0.61, so declared here. +#[link(name = "kernel32")] +unsafe extern "system" { + fn GetNumaNodeNumberFromHandle(hFile: HANDLE, NodeNumber: *mut u16) -> i32; +} + +fn probe(label: &str, handle: HANDLE) -> (Option, Option) { + println!("\n-- {label} --"); + + // Q1/Q4: the documented path. Volume node, via a file or directory handle. + let mut out: u32 = u32::MAX; + let mut returned: u32 = 0; + let ok = unsafe { + DeviceIoControl( + handle, + FSCTL_QUERY_VOLUME_NUMA_INFO, + std::ptr::null(), + 0, + (&raw mut out).cast::(), + u32::try_from(size_of::()).unwrap(), + &raw mut returned, + std::ptr::null_mut(), + ) + }; + let volume = if ok != 0 { + println!(" FSCTL_QUERY_VOLUME_NUMA_INFO : ok, NumaNode = {out} ({returned} bytes)"); + Some(out) + } else { + let e = std::io::Error::last_os_error(); + println!( + " FSCTL_QUERY_VOLUME_NUMA_INFO : FAILED, {} (raw {:?})", + e, + e.raw_os_error() + ); + None + }; + + // Q2/Q4: the reserved-for-system-use path, for comparison only. + let mut node: u16 = u16::MAX; + let ok = unsafe { GetNumaNodeNumberFromHandle(handle, &raw mut node) }; + let file = if ok != 0 { + println!(" GetNumaNodeNumberFromHandle : ok, NodeNumber = {node}"); + Some(node) + } else { + let e = std::io::Error::last_os_error(); + println!( + " GetNumaNodeNumberFromHandle : FAILED, {} (raw {:?})", + e, + e.raw_os_error() + ); + None + }; + + // Q3: the discriminating comparison. Agreement means volume locality is + // what is being observed, and that there is no per-file answer here. + match (volume, file) { + (Some(v), Some(f)) if u32::from(f) == v => { + println!(" => AGREE on {v}: this is VOLUME locality, not file locality."); + } + (Some(v), Some(f)) => { + println!(" => DISAGREE (volume {v}, handle {f}) -- interesting, investigate."); + } + (None, None) => println!(" => neither reports a node: no association for this volume."), + _ => println!(" => only one path answered; record which."), + } + + (volume, file) +} + +fn main() -> std::io::Result<()> { + println!("NOTE: on a single-NUMA-node machine this spike is VACUOUS."); + println!("Check the node count first; if it is 1, these results say nothing.\n"); + + let path = std::env::temp_dir().join("numa-probe-target.bin"); + fs::write(&path, vec![0_u8; 4096])?; + + // Q1-Q4: a garden-variety data file, opened the ordinary way. This is the + // case for which no published measurement could be found. + let file = fs::File::open(&path)?; + probe( + &format!("regular NTFS data file: {}", path.display()), + file.as_raw_handle() as HANDLE, + ); + + // Q5: a directory handle on the same volume. The IFS docs say the FSCTL + // accepts a file *or* directory, so this should match the file above. + // + // A directory needs `FILE_FLAG_BACKUP_SEMANTICS`; plain `File::open` fails + // with ERROR_PATH_NOT_FOUND. The first version of this spike used + // `File::open` and could never have answered Q5 -- caught by running it on + // hardware where the rest of the spike is vacuous, which is a decent + // argument for smoke-running an instrument even when its result cannot be. + let dir = to_wide(&std::env::temp_dir()); + let handle = unsafe { + CreateFileW( + dir.as_ptr(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + println!( + "\n-- directory handle -- CreateFileW failed: {}", + std::io::Error::last_os_error() + ); + } else { + probe("directory handle (temp dir)", handle); + unsafe { CloseHandle(handle) }; + } + + drop(file); + let _ = fs::remove_file(&path); + Ok(()) +} diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index 41f97b8d..17597ac6 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -172,6 +172,101 @@ difference is between describing two clusters and describing one whole machine. Whether the two-cluster structure is worth using is a separate question this session has not answered. +## Finding F-1: a file handle's NUMA node is reachable, and answers a coarser question + +Contributed by the engineer as research, **not measured here**. It corrects the +ioring notes on mechanism while leaving their conclusion standing for a better +reason. + +- **`FSCTL_QUERY_VOLUME_NUMA_INFO` is documented and takes a file or directory + handle directly**, returning `FSCTL_QUERY_VOLUME_NUMA_INFO_OUTPUT { ULONG + NumaNode }`. Confirmed independently against the IFS documentation during the + session. The ioring notes say this mapping "has no clean user-mode path" and + "means walking volume to disk to device instance and reading + `DEVPKEY_Device_Numa_Node`". **That is wrong**: there is one documented call, + and it accepts the handle a caller already has. +- **What it returns is the node the *volume* resides on**, not where the file's + extents live. NTFS does not expose per-file or per-extent NUMA through this + API, and nothing states that `FileNumaNodeInformation` is filled from MFT or + runlist locality. +- **`GetNumaNodeNumberFromHandle` is the other path**: a Win32 wrapper over + `NtQueryInformationFile` with `FileNumaNodeInformation` (class 53, Windows 7 + and later), yielding `FILE_NUMA_NODE_INFORMATION { USHORT NodeNumber }`. PHNT + and the WDK mark that class **reserved for system use**. Documented Win32 + behaviour when there is no node is `FALSE` with an undefined `NodeNumber`. + This crate must not build on it. +- **The volume node exists only when the device layer advertised one**: + `IoGetDeviceNumaNode` on the PDO, or user-mode + `DEVPKEY_Numa_Proximity_Domain` with `GetNumaProximityNode`. A single-node + machine, a PDO returning `STATUS_NOT_FOUND`, or a software or virtual disk + with no proximity data is precisely the "no association" case. +- **No published experiment could be found** showing either call succeeding on a + garden-variety NTFS data file and naming a node. There is also no evidence + that success depends on `FILE_FLAG_NO_BUFFERING`, on overlapped I/O, or on + which process opened the file. + +### F-1a: one weak datapoint from the vacuous machine + +The spike was smoke-run on the development machine, not for an answer but to +prove the instrument works before handing it to someone with real hardware. It +was worth doing twice over. + +**It found a defect in itself.** The first version opened the directory for Q5 +with `File::open`, which fails on a directory without +`FILE_FLAG_BACKUP_SEMANTICS`, so Q5 could never have been answered. Corrected to +`CreateFileW`. An instrument checked in unrun is one whose bugs are still in it; +running it on hardware where the *result* is vacuous still validates the +*apparatus*. + +**And it does establish one thing, narrowly.** On ARM64 Windows, single node: + +``` +regular NTFS data file : FSCTL ok, NumaNode = 0 | GetNumaNodeNumberFromHandle ok, NodeNumber = 0 +directory handle : FSCTL ok, NumaNode = 0 | GetNumaNodeNumberFromHandle ok, NodeNumber = 0 +``` + +Both calls **succeed** on a garden-variety NTFS data file, and agree. That is +directly responsive to "no published experiment shows +`GetNumaNodeNumberFromHandle` succeeding on a garden-variety NTFS data file": +here it does. It also shows the FSCTL accepting a directory handle, as the IFS +docs say. + +**What it does not establish**, and the distinction is the whole value of the +result: node `0` is the *only* node this machine has, so neither call is shown +to name a *meaningful* node. What is refined is the negative case -- the +documented "returns FALSE when the object has no node" did **not** occur here, +so "ordinary NTFS file" is not itself the absent case. Absence must come from +the device layer advertising no proximity domain, which is exactly what cannot +be reproduced on this hardware. + +**Why this matters to the seam, and it is an opportunity rather than a +problem.** The discriminating check is to call both on the same handle: if they +agree, what is being observed is volume locality. And volume locality, though +coarse, arrives at exactly the right moment -- the namespace worker has the +handle in hand at the instant it completes the open, so a routing key for +"which domain should own this file's I/O" is available **for free at the seam**, +with no extra open and no device-tree walk. That does not make automatic +placement correct, and the conclusion below stands, but it does mean the +information is cheaper than the notes imply. + +The conclusion the notes draw survives, restated: the crate should still not +offer "put this file's I/O on the right ring," because the answer is +volume-granular, frequently absent, and meaningless for spanned volumes and +Storage Spaces where one volume sits on several devices. It also still does not +pin thread-pool completions. + +**Named blocker, per the repository's deferral protocol.** Settling this +empirically needs a multi-node machine with storage whose PDO advertises a +proximity domain. The development machine has one node and reports zero +`Win32_NumaNode` instances, so any run here is vacuous: failure would prove +nothing and success could only report `0`. The instrument is therefore checked +in unrun as +[file-handle-numa-spike.rs](../crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs), +with the hardware gap stated in the spikes +[README.md](../crates/windows-ioring-sys/design-sessions/spikes/README.md). The +documentation defect is independent of the measurement and is queued as M20.4 +regardless. + ## Working position on domain counts (not a decision) Only the first row is measured. The rest are from published topologies and must @@ -205,10 +300,12 @@ The reasoning behind the numbers matters more than the numbers: one-shot per ring, so N domains means N separately pinned pools: a 256 MiB working set is 256 MiB pinned at one domain, or 3 GiB at twelve, or twelve pools too small to keep a device busy. -- **The mapping needed is not discoverable.** The ioring notes state that file - handle to device NUMA node "has no clean user-mode path" and that the crate - will not offer an automatic placement. Device topology is therefore - configuration, not detection. +- **The mapping needed is coarse and often absent** -- see F-1 above, which + corrects the ioring notes' stronger claim that it is unreachable. A volume's + node is one documented FSCTL away, but it is volume-granular, frequently has + no answer, and is meaningless where one volume spans several devices. Device + topology is therefore still configuration rather than detection, for a + narrower reason than the notes currently give. **Proposed default, not yet agreed:** one domain, adding one only when the device it serves can be named; above 64 logical processors take the group floor From e81e6444df8da9f3acd153ac7aa9efe8b294b3c4 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 15:11:44 -0400 Subject: [PATCH 003/361] docs: converge the session on one uniform tunable architecture Records the round in which the session's own framing was corrected, plus three further convergences and an extension to the unrun spike. The correction matters most. An earlier turn concluded that because almost every consumer machine yields a single domain, the apparatus is "server-class-only and should be sized as such". The engineer rejected that: it sounds like the feature does not work on laptops, when in truth it extends up and down smoothly. He is right, and right against a standing rule rather than on taste -- PLATFORM INTEGRITY forbids narrowing the platform to serve the visible goal, and the ioring notes already said a machine yielding one ring is "correct", not degraded. So: one shape at every size, sized by the topology. A laptop runs one domain, a server several, and N=1 is the optimal partition of a single-node single-LLC machine rather than a fallback. What is additive above one domain is only the cross-domain queue and the routing policy, and both are absent at N=1 rather than stubbed -- nothing consults a router that always answers zero. That inverts the build order this session had implied: N=1 is the first deliverable and the substrate, not the leftover. The mechanism that makes "smooth" concrete is that affinity is a set, not a point. SetThreadGroupAffinity takes a mask and windows-topology-sys already hands out ProcessorSet, so a domain is affinitized to the ProcessorSet of its partition -- the whole machine on a uniform laptop, the performance cluster on a heterogeneous one, each CCD on an EPYC. Same call, different set. That also separates two knobs previously conflated: domain count and pinning tightness are independent, and heterogeneity means even N=1 wants a mask, because an unconstrained thread can land on an efficiency core. Three further convergences: - Round-robin assignment is incoherent with the model, not merely suboptimal. It breaks the ownership premise Model B exists for: a file whose I/O lands on different domains over time is owned by no shard, which is sharing on the data path. Acceptable only as an explicitly chosen default, named to admit what it is. - Report, do not route. An arbitrary completion cannot be posted into an IoRing CQ -- the namespace session already rejected that on mechanism -- but the simpler form needs no new mechanism at all: the open's completion carries the node hint and its provenance, and the client routes. That is the ioring notes' "leaves the mapping to whoever knows their storage layout", made concrete. - A domain runtime is not a thread pool. Every distinguishing feature is the absence of a thread-pool feature: no dynamic sizing, no stealing, no quarantine, no injection. The Windows pool cannot affinitize, which is not a reason to rebuild it but the reason the two halves are separate. Extends the spike with Q6, the engineer's Storage Spaces question, which is a distinct question from F-1 rather than a rider on it: a striped or parity space whose columns are NVMe devices on different PCIe roots has three possible outcomes, and reporting one node for a device set that spans several is worse than reporting nothing, because a consumer would act on it. The spike now takes a target directory as argv[1] and prints the reminder that the space's layout must be recorded alongside the output or the result cannot be interpreted. Rebuilt and run on both paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spikes/file-handle-numa-spike.rs | 33 +++- ...08-30-numa-sharded-io-execution-domains.md | 159 ++++++++++++++++++ 2 files changed, 188 insertions(+), 4 deletions(-) diff --git a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs index 69d9aebc..02c760de 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs @@ -18,6 +18,19 @@ //! degradation path has to handle it? //! Q5 does a directory handle behave the same as a file handle? The IFS docs //! say the FSCTL accepts either. +//! Q6 **what does a Storage Space report?** Run this against a file on a +//! striped or parity space whose columns are NVMe devices on different +//! PCIe roots, ideally attached to different nodes. Three outcomes, and +//! they are not equally good: +//! - no answer: honest, and the consumer degrades to "any domain"; +//! - one node that genuinely matches every column: useful; +//! - **one node for a device set that spans several: a fiction, and +//! worse than no answer**, because a consumer would act on it. +//! The third is the case worth knowing about, and it cannot be +//! distinguished from the second without independently knowing where the +//! columns live -- so record the space's layout (`Get-StoragePool`, +//! `Get-PhysicalDisk`) alongside whatever this prints, or the result +//! cannot be interpreted. //! //! Why it matters: `DESIGN-NOTES.md` asserts that mapping a file handle to the //! NUMA node of its backing device "has no clean user-mode path" and "means @@ -135,7 +148,19 @@ fn main() -> std::io::Result<()> { println!("NOTE: on a single-NUMA-node machine this spike is VACUOUS."); println!("Check the node count first; if it is 1, these results say nothing.\n"); - let path = std::env::temp_dir().join("numa-probe-target.bin"); + // Q6: pass a directory on a Storage Space as argv[1] to ask the harder + // question. Default target is the temp directory, i.e. the boot volume. + let dir = match std::env::args().nth(1) { + Some(arg) => { + println!("target directory overridden: {arg}"); + println!("for Q6, record the space's layout (Get-StoragePool,"); + println!("Get-PhysicalDisk) alongside this output, or the result"); + println!("cannot be interpreted.\n"); + std::path::PathBuf::from(arg) + } + None => std::env::temp_dir(), + }; + let path = dir.join("numa-probe-target.bin"); fs::write(&path, vec![0_u8; 4096])?; // Q1-Q4: a garden-variety data file, opened the ordinary way. This is the @@ -154,10 +179,10 @@ fn main() -> std::io::Result<()> { // `File::open` and could never have answered Q5 -- caught by running it on // hardware where the rest of the spike is vacuous, which is a decent // argument for smoke-running an instrument even when its result cannot be. - let dir = to_wide(&std::env::temp_dir()); + let dir_wide = to_wide(&dir); let handle = unsafe { CreateFileW( - dir.as_ptr(), + dir_wide.as_ptr(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, std::ptr::null(), @@ -172,7 +197,7 @@ fn main() -> std::io::Result<()> { std::io::Error::last_os_error() ); } else { - probe("directory handle (temp dir)", handle); + probe(&format!("directory handle: {}", dir.display()), handle); unsafe { CloseHandle(handle) }; } diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index 17597ac6..860fd191 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -267,11 +267,170 @@ with the hardware gap stated in the spikes documentation defect is independent of the measurement and is queued as M20.4 regardless. +## Converged: one uniform, tunable architecture, sized by the topology + +This is the session's first converged position, and it arrived by correcting a +framing this record had already adopted. + +**The framing that was wrong.** An earlier turn concluded that because almost +every consumer machine yields a single domain, "this entire apparatus is +server-class-only, and should be sized and justified as such." The engineer +rejected that, on the grounds that it sounds like the feature does not work on +laptops, and that it should extend up and down smoothly instead. + +That objection is right, and it is right against a **standing rule of this +repository** rather than merely on taste. PLATFORM INTEGRITY says: "do not +narrow the platform to serve the visible goal -- every platform component must +remain a *level* platform, its lower baselines first-class, not optional +trimmings to cut because the current task does not need them." Scoping the work +to server hardware is exactly that narrowing. The ioring notes already had the +correct words where this session lost them: a machine that yields one ring is +"correct", not degraded. + +**The architecture.** There is one shape at every size -- a domain is a pinned +thread, its ring, its node-local registered pool, and its shard of the work. +A laptop runs one. A server runs several. There is no laptop mode and no server +mode; there is one shape and a count. N=1 is not a fallback that lost something: +on a single-node, single-LLC machine one domain **is** the optimal partition. + +**What is genuinely additive above one domain**, and it is additive rather than +a second mode: + +- the cross-domain queue and its doorbell -- with one domain there is no peer to + message; +- the routing policy -- with one domain there is no choice to make, so the + volume-to-node key has nothing to select. + +Both are **absent** at N=1, not stubbed or bypassed. Nothing on the +single-domain path consults a router that always answers zero. + +**Consequence for build order, and it inverts what this session had implied.** +N=1 is the **first deliverable and the substrate**, not the leftover. It is the +common case, it is complete and correct on its own, and N>1 extends it without +disturbing it. That is a better sequencing argument than starting from the +fleet, and it means the first thing built is useful on every machine in the +table below rather than on none of them. + +**The mechanism that makes "smooth" concrete: affinity is a set, not a point.** +`SetThreadGroupAffinity` takes a mask, and `windows-topology-sys` already hands +out `ProcessorSet` with correct multi-group handling. A domain's affinity is +simply the `ProcessorSet` of its partition: + +| Machine | N | Each domain's affinity set | +|---|---|---| +| Uniform laptop or VM | 1 | the whole machine | +| Heterogeneous laptop (P/E, or ARM clusters) | 1 | the performance cluster only | +| 2-CCD desktop | 1-2 | each CCD's processors | +| 12-CCD EPYC | 4-12 | each CCD's processors | + +Same call, same type, different set. Nothing special-cases the small end. + +**Domain count and pinning tightness are separate knobs.** Pinning to a single +core buys locality *relative to other domains*; with one domain there is nothing +to be local relative to, and hard-pinning an I/O thread to one core of a laptop +that is also running everything else may be worse than letting it float across +the performance cores. But **heterogeneity means even N=1 wants an affinity +mask**, because an unconstrained thread can be scheduled onto an efficiency core +or an LPE island. The development machine is the case in point: two clusters of +six, and `efficiency_class` is already exposed on `Core` by the topology crate. +So the small end does not want *no* affinity -- it wants a *set*, which is +exactly what the large end wants too. + +Stated once: **one mechanism, sized by the topology.** A domain is affinitized +to a `ProcessorSet`; how many domains exist, and how wide each set is, falls out +of the machine. + +## Converged: round-robin is incoherent with the model, not merely suboptimal + +The engineer's position was that round-robin assignment "seems actually +dangerous" and that a high-performance consumer might prefer a single thread on +a single processor. Agreed, and the reason is stronger than the averaging +argument that first suggested it. + +Round-robin across domains **breaks the ownership premise Model B exists for**. +If a file's I/O lands on domain 1 now and domain 3 next, that file's state -- +buffer slots, outstanding accounting, continuations -- is owned by no single +shard. That is sharing on the data path, which is the one thing shared-nothing +is defined by. It is not a worse point on the same curve; it is off the curve. + +The consequence for a latency-sensitive consumer follows directly: a single +thread on a single processor is deterministic and owned, where round-robin is +non-deterministic and shared. A known cost can be engineered around; jitter +cannot. + +**Position:** round-robin is acceptable only as an explicitly chosen default for +consumers who have expressed no preference, and it should be named to admit what +it is rather than offered beside "by node" as though it were a peer policy. + +## Converged: report, do not route + +The engineer proposed that when no useful mapping is available, the facility +should message the caller and have them respond with how to proceed. + +**One mechanism correction:** an arbitrary completion cannot be posted into an +`IoRing` completion queue. The namespace session already rejected that path -- +"the `IoRing` API has no post/user-completion entry point, so a namespace +completion cannot be placed in its CQ." (`IORING_OP_NOP` can inject a marker, +but only the ring's owning thread may submit, so a namespace worker on another +thread cannot reach in.) On the facility's **own** ring, which it owns outright, +posting is available. + +**A simpler form needs no new mechanism at all.** Rather than the facility +asking a question and awaiting an answer, the open's completion carries the hint +and the client routes: + +``` +completion = { handle, volume_node: Option, provenance: Measured | Absent | Overridden } +``` + +The client already receives that completion. No round trip, no pending-decision +state, no "what if the client never answers," and no new queue direction. It is +the principle the ioring notes already state -- "leaves the mapping to whoever +knows their storage layout" -- made concrete: the facility reports, the client +routes. + +The upcall form remains the right answer for a higher layer that owns a flow +end to end and cannot hand control back. Both are kept, with the reporting form +as the primitive and the upcall as something built on it if a consumer needs it. + +## Converged: a domain runtime is not a thread pool + +The engineer raised a fear of ending up writing a thread pool, given that the +Windows pool cannot affinitize to any of the objects of interest. + +What Model B needs is a **domain runtime**, and every distinguishing feature is +the *absence* of a thread-pool feature: + +| A thread pool does | A domain runtime does | +|---|---| +| dynamic sizing, injection, retirement | spawn N at startup, join at shutdown | +| work stealing and load balancing | nothing -- stealing would violate share-nothing | +| queue management, priorities, quarantine | one loop per thread: park, drain, run | +| grow under blocking (`runs_long`) | never blocks on namespace work by construction | + +`ring_copy` already contains a working instance of it inline: pin, allocate +node-local, register, loop. The ioring notes already committed to the shape -- +"Model B on the hot data path ... and Model A for the control plane, background, +and cold paths" -- and `M6+.4` already queues binding a ring's thread with +`SetThreadGroupAffinity`. + +The load-bearing point is the one that motivated the fear: the Windows pool +**cannot** affinitize, so this capability cannot come from it. That is not a +reason to rebuild the pool; it is the reason the two halves are separate. The +namespace plane keeps the pool because it needs quarantine and elasticity for +blocking calls; the data plane owns threads because it needs pinning. Neither +can serve the other, and that is the design rather than a compromise. + ## Working position on domain counts (not a decision) Only the first row is measured. The rest are from published topologies and must be treated as unverified until probed. +Read this as **the count the one architecture takes on each machine**, not as a +boundary between supported and unsupported hardware -- see the convergence above. +Every row runs the same domain shape; the rows differ only in N and in how wide +each domain's affinity set is. + | Configuration | Cores / LPs | Cache domains | Nodes | Groups | I/O domains | |---|---|---|---|---|---| | Snapdragon X2 Elite (measured) | 12 / 12 | 2 x L2, no L3 | 1 | 1 | 1 | From 30e87ab1f644ef2a3fd7b5d381c19785f87864bf Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 15:53:16 -0400 Subject: [PATCH 004/361] docs: record the two-layer ring target architecture and its costs Writes up the session's target architecture, its four costs with mitigations, one rejected design, and an honest coherence assessment. The structural resolution: the client-facing SQ/CQ is ours, and the IoRing lives inside a domain as an implementation detail. Four problems fall to that one decision -- submission thread-safety (a third answer to the parked M6+.2, which is neither a handoff nor a lock but a queue only the domain drains), the missing CQ post entry point that the clarification CQE needs, "one ring or two" (the client sees one; the planes keep separate storage, which is decision 7 from the client's side), and the epoch metaphor porting because it already targets a CQ. The thing that actually removes "exotic client programming" is that the client never allocates an I/O buffer: the facility owns the registered pool, node-local and sector-aligned, and hands out slots. The client cannot place a buffer wrongly because it never chooses, and the pool is exactly the long-lived fixed thing that one-shot registration wants. Two claims made earlier in this session are corrected here rather than dropped: - The argument that a client-implemented doorbell would deadlock under the queue lock was wrong. Working the interleavings shows only the *reset* must be under the lock, atomic with the emptiness observation; a late SetEvent produces a spurious wakeup, never a lost one. The trait is still rejected, on three arguments that do hold: set and reset are halves of one invariant and cannot be split across an ownership boundary, a client callback on the producer's submit path is a cadence hazard, and the type parameter propagates through every type that touches a queue. The HANDLE is already the extension point. - The notes' claim that buffer placement should follow the device is incomplete. It counts the DMA and is silent on the client's reads. A DMA writes once; a consumer may read many times, so "near the device" wins only when the consumer barely touches the bytes. The access pattern decides. WaitOnAddress is recorded as unusable for the doorbell, and not on cost: it waits on a memory location, WaitForMultipleObjects waits on kernel objects, and no API combines them -- so a domain waking on either its ring or a peer's message needs both in one wait, and the ring's event is a HANDLE. Same constraint the crossbeam analysis reached from the other direction. The skip-when-busy rule also removes the cost WaitOnAddress would have saved, so they are alternatives and only one composes. Also records: the consulted tier collapsing into the informed tier at the primitive layer once the handle is delivered with the question; the durability model graduating to its own crate, with the constraint that an epoch is per-domain because the barrier stops at the ring's edge; and the composed layer's mandate, with the test that if a client can name any of the six sharp edges, the layer has leaked. The coherence assessment names three structural gaps rather than claiming the design is finished: where the client-facing ring lives (a substantial new artifact existing nowhere in this workspace), CQ cardinality (one shared queue is shared-nothing violated at the last step; one per domain caps at 64 handles), and whether the client ever sees an IoRing at all. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...08-30-numa-sharded-io-execution-domains.md | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index 860fd191..17715729 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -421,6 +421,291 @@ namespace plane keeps the pool because it needs quarantine and elasticity for blocking calls; the data plane owns threads because it needs pinning. Neither can serve the other, and that is the design rather than a compromise. +## The target architecture: a two-layer ring + +The engineer's refined vision: an SQ/CQ against an "async API substrate". You +post a deferred `CreateFileW`; the CQ may return a request to clarify NUMA +placement (behind an option set on the SQ, so the complexity is opt-in); you +answer; you get back a token for high-performance I/O using the epoch/durability +metaphor. All ring-based, NUMA-aware, "without a lot of exotic client +programming". + +**The structural resolution is that the client-facing ring is ours, and the +`IoRing` lives inside a domain as an implementation detail.** + +``` +client thread --post--> [ our SQ: MPSC + doorbell ] --> domain thread --> IoRing SQ +client thread <-drain-- [ our CQ: MPSC + doorbell ] <-- domain thread <-- IoRing CQ +``` + +Four problems collapse into that one decision, which is the main reason to +believe the decomposition is right: + +- **Submission thread-safety.** Only the domain thread touches the real SQ. This + is a *third* answer to `M6+.2`, which is parked with "needs either a + submit-ownership handoff or an internal lock. Neither is obviously right" -- + the answer is neither: a queue only the domain drains. +- **The missing post entry point.** The namespace session rejected posting a + completion into an `IoRing` CQ on mechanism. Our CQ is ours, so the + clarification CQE the vision needs becomes possible. +- **One ring or two.** The client sees one; the namespace and data planes keep + separate storage. That is decision 7 -- "share the ring type, not the storage; + unify at the wait" -- seen from the client's side. +- **The epoch metaphor ports**, because it is already written against a CQ. + +### The client never allocates an I/O buffer + +This, rather than the ring shape, is what removes the "exotic client +programming". The facility owns the registered pool: `VirtualAllocExNuma` on the +domain's node, sector-aligned, registered once. The client acquires a **slot**, +writes into it, submits, and gets it back. + +The client cannot place a buffer wrongly because it never chooses. Every +alternative -- client allocates and we validate, client hints and we advise -- +returns the decision to where the knowledge is not. It also fits the one-shot +registration constraint exactly: the pool is the long-lived fixed thing, so +registration's principal limitation stops being one. + +### Three policy tiers, generalized beyond placement + +| Tier | Client says | Facility does | +|---|---|---| +| Default | nothing | picks; at N=1 there is no choice, so this is free | +| Informed | "tell me" | completion carries the hint and its provenance; client routes | +| Consulted | "ask me" (SQ option) | posts a clarification CQE; client answers | + +The engineer generalized this past NUMA: most things "need to just be taken care +of for people", but policy in general must be expressible either as **optional +parameters** or as **queries via CQE/SQE pairs**. So the tiers are the shape for +every policy decision the facility must make, not a placement-specific device. + +## Costs of the target architecture, and their mitigations + +### C-1 The queue hop + +Every foreign-thread submission crosses an MPSC push plus a doorbell before +reaching the ring, where a run-to-completion client would have none. + +- **The push is cheap; the doorbell is the syscall.** Signal only on the + empty-to-non-empty edge, and only when the consumer is parked. A queue that + stays non-empty needs no signal at all, so a busy domain -- the case that + matters -- pays approximately zero doorbells. +- **A brief consumer spin before parking** removes the park/unpark round trip + under load. Spin duration is another knob sized by the topology: generous when + a domain owns a core exclusively, zero when it shares one with the rest of a + laptop. +- **The hop is where batching happens.** N submissions become N pushes, one + doorbell, one drain, and **one** `SubmitIoRing`. Under load it plausibly + reduces syscalls rather than adding them. +- **It should be pay-for-what-you-use**: a client whose continuation runs on the + domain thread submits directly, with no queue and no doorbell. +- **Measurable now**, on this hardware, with no infrastructure: time `SetEvent`, + an uncontended atomic push, and a `SubmitIoRing` round trip. If `SetEvent` is + a few percent of the syscall, the hop is noise. + +### C-1a Why the doorbell must be a HANDLE, and cannot be `WaitOnAddress` + +`WaitOnAddress` is plausibly cheaper in isolation. It is still unusable here, +and cost does not enter into it: `WaitOnAddress` waits on a **memory location**, +`WaitForMultipleObjects` waits on **kernel objects**, and no API combines them. +A domain that must wake on either "my ring completed" or "a peer sent me work" +waits on both at once, and the ring's completion event is a HANDLE. + +This is the same structural constraint reached from a different direction than +the crossbeam analysis above, which is good evidence it is a property of the +platform rather than of a library. + +The two ideas also interact decisively: **the skip-when-busy rule removes the +very cost `WaitOnAddress` would save.** They are alternatives, not complements, +and only one of them composes. + +A corollary worth stating plainly: a domain parked in the fused +`SubmitIoRing(wait_n)` cannot observe a queue at all. **A domain that accepts +foreign submissions must use [D-20](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-20)'s +multiplexed-wait row.** Accepting foreign work and using the lowest-overhead +park are mutually exclusive. + +### C-1b Which side of the lock the doorbell is touched on + +Asked directly by the engineer: why can the event not be signalled after the +lock is released, and does it have to represent the fullness of the queue? + +**It can be signalled outside the lock. Only the reset must be inside.** A late +`SetEvent` can at worst arrive after the consumer already drained that item and +parked, producing a spurious wakeup -- the consumer wakes, finds nothing, parks +again. A reset outside the lock is fatal: + +``` +Consumer: lock, drain to empty, unlock +Producer: lock, push(B), unlock, SetEvent +Consumer: ResetEvent <-- clears the signal for B +Consumer: park <-- lost wakeup; B is stranded +``` + +So the invariant is precisely: **the reset must be atomic with the observation +that there is nothing to take.** And yes -- the event represents the fullness of +the queue. It is *level* state, a function of the contents rather than a record +of edges, which is why it is manual-reset and why a redundant signal is free +while a stale reset is fatal. + +### C-2 The pending-clarification handle -- dissolved + +Deliver the handle **with** the question rather than holding it behind the +question. The client then owns it under ordinary rules; a client that never +answers has leaked its own resource, not stranded a facility-held object with no +owner and no deadline. + +Following that through: **at the primitive layer the consulted tier collapses +into the informed tier.** Ask what the facility would do with the answer. +Register the file into that domain's ring? Unavailable -- registration is +one-shot. Allocate slots on the right node? Those come from the domain when the +client asks it. There is no work the answer unlocks that the client cannot do by +submitting to the domain it chose. The round trip has no payload. + +The consulted tier survives where the facility performs I/O **on the client's +behalf** -- a higher-level "read this whole file" API that must choose placement +and has nobody to ask. That is a layer above the primitive. + +Whatever the facility still holds transiently needs a deadline and a disposal +path **allowed to block**, since closing a handle to a dead network path is +exactly the work this facility exists to keep off a caller's thread. + +### C-3 The durability model graduates to its own crate + +Recorded as the engineer's decision: the `epoch_log` sample becomes a canonical +layer, probably a separate crate. Durability groups are a natural capability +whose absence is surprising. + +It composes because the mechanism it needs was already measured: +[D-23](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-23) (an unflagged flush +does **not** cover preceding writes) and +[D-24](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-24) +(`DRAIN_PRECEDING_OPS` is a full ring-wide barrier spanning submissions). Group +commit is policy over that mechanism, which is a textbook reason for a separate +crate rather than a feature. + +**One constraint to carry from the start:** the barrier stops at the ring's +edge, so a durability epoch is **per-domain**. A client writing through two +domains and wanting one durability point needs two flushes and an explicit join. +The crate must represent that or refuse it; it must not quietly imply an epoch +spans domains. + +### C-4 The composed layer swallows the sharp edges + +The engineer's direction: primitives matter, but this is the "build layers that +compose them" phase, and the composed layer should not expose sharp edges. + +The mechanism is already recorded as the namespace session's decision 8 -- a +type-level traversal where each step offers only the legal next steps -- and +this repository already applies it (`RingScope` so no `&mut IoRing` escapes; +`get(&mut self)` so a hazard is a type error). The edges to swallow: + +- edge-triggered drain ([D-19](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-19)): + drain to empty every pass; a single `try_pop` deadlocks; +- buffer slot lifecycle: acquire, outstanding accounting, release only on an + observed completion; +- one-shot registration: fixed at construction, never named by the client; +- token claiming: `claim_if` matching both `user_data` and `ring_id`; +- flush barrier semantics (D-23); +- handle disposal, including the blocking close. + +**If the client can name any of these, the layer has leaked.** + +## Rejected: a `Ring` trait with a client-implemented `ring_doorbell()` + +Proposed during the session, and rejected -- but the first argument offered +against it was wrong and is corrected here rather than quietly dropped. + +**The withdrawn argument.** It was claimed that a client callback would run +under the queue lock and therefore risk deadlock. C-1b shows the signal side +need not be under the lock at all, so that argument does not hold. + +**The arguments that do hold:** + +1. **Set and reset are two halves of one invariant** and cannot be split across + an ownership boundary. The reset must happen under our lock, atomic with the + emptiness observation; if the client owns the signalling object, we cannot + perform it. This is the file-watcher's recorded reason: owning the doorbell + "makes the reset discipline an internal invariant rather than a client + obligation". +2. **A client callback on the producer's submit path is a cadence hazard** -- if + it blocks, it stalls the producer. +3. **Type-parameter propagation**, which is the cost the file-watcher actually + measured: a `Doorbell` trait "would have made `Monitor`, `Session`, and + `Sender` all generic over it". + +And the extension point already exists at no cost: hand out the HANDLE. The +client composes it into `WaitForMultipleObjects`, a `ThreadpoolWait`, an async +reactor, or ignores it -- outside our lock, on their own schedule, with no type +parameter reaching `Ring`, `Domain`, and every producer handle. + +On the narrower question of generics versus `dyn` should polymorphism be needed +elsewhere: static dispatch on the hot path, but the cost that actually bites is +the type parameter infecting every type that touches a queue, not the dispatch. + +## Two locality consumers, not one + +Raised by the engineer's question about what a client gets back and whether it +is affinitized to the calling thread. It exposes an incompleteness in the ioring +notes' strongest placement claim. + +There are **two** consumers of a buffer's locality: + +- the **device**, which DMAs into it; +- the **client**, which reads it after completion. + +The notes argue placement dominates because "a buffer on a node remote from the +device means every byte crosses the interconnect, on every operation, forever". +That is true of the DMA and silent about the read side. With the device on one +node and the client thread on another, both cannot be satisfied: + +| Buffer placed | DMA cost | Client read cost | +|---|---|---| +| near the device | local | remote, on every read | +| near the client | one crossing | local | + +**A DMA writes once; a consumer may read many times.** So "near the device" is +not automatically right -- it wins when the consumer barely touches the bytes, +and loses when the consumer works over them repeatedly. The notes' claim is +incomplete rather than wrong, and the completion is that the *access pattern* +decides. + +**On binding to the calling thread: no, not implicitly.** An unpinned client +thread migrates, so a binding inferred from where it happened to be is stale +before it is used. That is the namespace session's decision 9 -- "ambient state +is derived from an explicit binding, never the origin of one". + +**Proposed instead:** the client *asks* -- "which domain is nearest me?" -- and +then uses it explicitly. Topology becomes an input the client may consult, never +an authority applied behind its back. A foreign thread has in any case already +accepted a hop and probably a remote read; a client wanting true consumer +locality should be running *on* the domain. + +## Coherence assessment + +Honest state of the design at the end of this session, since it was asked +directly. + +**Solid.** The two-layer ring resolves submission thread-safety, the missing CQ +post entry point, and "one ring or two" with a single decision; three +constraints falling to one choice is usually a sign the decomposition is right. +The domain-owned pool makes NUMA invisible rather than merely easy. The uniform +architecture sizes from 1 to N without a second mode. + +**Structurally open, and not to be papered over:** + +1. **The client-facing ring is a substantial new artifact** -- MPSC, doorbell, + descriptor format, completion tagging -- existing nowhere in this workspace. + It needs a home and probably its own crate, and it is larger than anything + this session has called "the seam". +2. **CQ cardinality.** One shared CQ is a contention point every domain writes + to, which is shared-nothing violated at the last step. One per domain + preserves the model but makes the client multiplex, and + `WaitForMultipleObjects` caps at 64 handles. +3. **Whether the client ever sees an `IoRing`.** If it is fully wrapped, + `windows-ioring-sys` becomes an implementation detail of a higher crate -- + a layering statement to be made deliberately rather than discovered. + ## Working position on domain counts (not a decision) Only the first row is measured. The rest are from published topologies and must @@ -472,6 +757,10 @@ and no more. ## Open questions +- **The three structural gaps** in the coherence assessment above: where the + client-facing ring lives, CQ cardinality, and whether the client ever sees an + `IoRing`. These are now the session's principal open questions and they + supersede the framing below. - **Which end to design from.** Three candidates were put: (a) the seam between the pooled namespace plane and the pinned data plane, given one-shot registration and no shared ring storage; (b) the `RingFleet` that From d52491dacd8af22916837e3114f138be5faa360d Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 16:04:44 -0400 Subject: [PATCH 005/361] docs: specify the SQ/CQ queues, resolve CQ cardinality and thread placement Turns three open items into specification and resolution, and corrects one mechanism claim. The queue specification (R1-R10) captures the requirements the session's conclusions imply. The two directions are not the same shape: the SQ is MPSC (many client threads, one domain thread) and the CQ is SPSC (one domain thread, one drainer), because drain-to-empty is ambiguous with two racing drainers and per-domain CQs already give a client N drainers. Two requirements are worth calling out because they correct assumptions made earlier in this same session: - R3 forbids a producer-side mutex. Earlier turns kept saying "under the queue lock", inherited from the file-watcher, but a producer-side lock serializes exactly what multi-producer exists to parallelize. Park and notify go through an eventcount instead: publish intent to park, re-check, then wait. So the file-watcher's *invariant* is reused and its *implementation* is not, and the writeup says so explicitly to stop "reuse the queue" becoming reuse of the wrong half. - R9 requires counting doorbells actually rung, so the skip-when-busy rule in R4 is measurable rather than assumed, and sabotage-verifiable: disabling the skip must change the number. Mechanism correction from the engineer: an earlier turn claimed ThreadpoolWait "internally manages the 64-handle groups". It does not -- modern thread-pool waits are backed by kernel-side wait completion packets associated with the pool's completion port, with no user-mode grouping and no thread per group. That improves the answer: wide waits cost the dispatch hop, not a thread per 64. So CQ cardinality resolves to one CQ per domain, one HANDLE each, client picks WaitForMultipleObjects or ThreadpoolWait. That preserves shared-nothing and is the payoff from rejecting the Ring trait -- because the extension point is a HANDLE, this strategy did not have to be anticipated. Thread placement resolves to option (c): the facility reports placement and the client places its own threads. Records the three thread populations and why each is owned where it is. The concrete form is smaller than the CreateThread wrapper that was floated: the missing capability is not creating a thread but placing one, so the facility offers bind_current_thread() returning a restore guard. That is slope-proof because we never own a thread -- no handle kept, no lifetime managed, nothing to restart, so there is no first step down the slope. The guard is required rather than decorative, since a client binding a pool thread must restore it, and that places the feature in the family windows-thread-ambient-sys already handles. Also marks the third structural gap resolved: the client does not see an IoRing. windows-ioring-sys becomes an implementation detail of the higher crate, which is layering rather than absorption -- it remains published in its own right and gains a dependent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...08-30-numa-sharded-io-execution-domains.md | 147 +++++++++++++++++- 1 file changed, 140 insertions(+), 7 deletions(-) diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index 17715729..c78be34f 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -698,13 +698,146 @@ architecture sizes from 1 to N without a second mode. descriptor format, completion tagging -- existing nowhere in this workspace. It needs a home and probably its own crate, and it is larger than anything this session has called "the seam". -2. **CQ cardinality.** One shared CQ is a contention point every domain writes - to, which is shared-nothing violated at the last step. One per domain - preserves the model but makes the client multiplex, and - `WaitForMultipleObjects` caps at 64 handles. -3. **Whether the client ever sees an `IoRing`.** If it is fully wrapped, - `windows-ioring-sys` becomes an implementation detail of a higher crate -- - a layering statement to be made deliberately rather than discovered. +2. ~~**CQ cardinality.**~~ **Resolved** -- one CQ per domain, client chooses the + observation strategy. See "Resolved: CQ cardinality" above. +3. ~~**Whether the client ever sees an `IoRing`.**~~ **Resolved by the + engineer: it does not.** `windows-ioring-sys` becomes an implementation + detail of the higher crate. This is layering rather than absorption -- it + remains a published crate in its own right (0.2.0 shipped 2026-08-30) and + gains a dependent; direct consumers can still use it. + +## Specification: the submission and completion queues + +Requested as a specification rather than a sketch. These are the requirements +the session's conclusions imply. The two directions are **not** the same shape. + +**R1 Cardinality.** The SQ is **MPSC** -- many client threads, one domain +thread. The CQ is **SPSC** -- one domain thread, one drainer. The CQ constraint +is deliberate: "drain to empty" is ambiguous with two racing drainers, and +drain-to-empty is what +[D-19](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-19) requires. Nothing is +lost, because per-domain CQs already give a client N drainers; a client wanting +parallel processing drains on one thread and dispatches. + +**R2 Bounded.** Fixed capacity at construction. `push` on a full queue returns a +typed error; it never blocks and never grows. **That failure is the +backpressure** -- an unbounded queue has none, which is why `SegQueue` was the +wrong model. + +**R3 Lock-free producers.** No mutex on the producer path. A producer-side lock +serializes precisely what multi-producer exists to parallelize. Park and notify +go through an **eventcount**: the consumer publishes intent to park, re-checks +the queue, and only then waits. That re-check closes the lost-wakeup gap without +a lock. + +**R4 Doorbell.** A queue-owned **manual-reset event**, created **lazily** so a +polling-only consumer allocates no kernel object. Level semantics: signalled +exactly when the consumer has something to observe. **The reset is atomic with +the emptiness observation; the signal may be outside any lock** (see C-1b). The +signal is *skipped* when the queue was already non-empty, or when the consumer +is not parked. Handed out as a borrowed handle plus an owned duplicate. + +**R5 Wakeup safety.** No lost wakeups. Spurious wakeups are permitted, and the +consumer must tolerate them. Drain to empty on every pass. + +**R6 Parking.** Optional consumer spin before parking, with the duration tunable +and **sized by the topology** -- generous when a domain owns a core exclusively, +zero when it shares one with the rest of a laptop. + +**R7 Payload.** POD descriptors only, never bytes: operation, target, buffer +slot index, offset, user tag. **No allocation on push.** Carrying bytes would +mean copying out of the registered pool, defeating the reason to register. + +**R8 Shutdown.** The consumer learns when all producers are gone; producers +learn when the consumer is gone and fail with a typed error. Descriptors in +flight at teardown are **accounted, not dropped** -- some own handles, and their +disposal must be allowed to block. + +**R9 Observability.** Depth and high-water for tuning, plus **a count of +doorbells actually rung**. That makes R4's skip rule measurable rather than +assumed, and sabotage-verifiable: disabling the skip must change the number. + +**R10 No client callbacks.** No trait and no closure on the producer or consumer +path. The HANDLE is the extension point. + +**What is reused from the file-watcher, and what is not.** The *invariant* is +reused: the event is level state, signalled exactly when there is something to +observe, with the reset atomic against the emptiness decision. The +*implementation* is not: [queue.rs](../crates/windows-file-watcher/src/queue.rs) +uses `Mutex` and `Condvar`, which is right for change-notification cadence and +wrong for an I/O hot path, because it puts a lock on the producer side. Stating +this explicitly so that "reuse the queue" does not become reuse of the wrong +half. + +## Resolved: CQ cardinality, and a correction about how wide waits work + +**Correction, from the engineer.** An earlier turn in this session claimed +`ThreadpoolWait` "internally manages the 64-handle groups". That is wrong. Modern +thread-pool waits are backed by **kernel-side wait completion packets** +associated with the pool's completion port; there is no user-mode grouping and +no fan-out of waiting threads per 64 handles. + +That improves the answer rather than complicating it: wide waits cost the +dispatch hop, not a thread per group. + +**Resolution: one CQ per domain, one HANDLE each, and the client chooses how to +observe them** -- `WaitForMultipleObjects` on its own thread when the count is +within the limit and no hop is wanted, `ThreadpoolWait` when wider or when the +hop is acceptable. + +This preserves shared-nothing, since there is no single queue every domain +writes to, and it pushes the trade-off to the only party that knows which side +of it it is on. It is also the payoff from rejecting the `Ring` trait: because +the extension point is a HANDLE, this strategy did not have to be anticipated. + +## Resolved: how a client places its own threads + +Following from "two locality consumers" above and the question of whether the +facility would end up building a thread pool. There are three thread +populations, and each has a distinct justification: + +| Population | Owner | Why | +|---|---|---| +| Namespace and blocking operations | the **Windows pool** | needs quarantine and elasticity; `runs_long`; must survive a wedged network call | +| Domain I/O threads | **us**, one per domain | pinning; the Windows pool cannot affinitize | +| Client continuations | **the client** | see below | + +Three options were considered for the third row: (a) continuations run on the +domain thread, Seastar-style -- best locality, but client code must never block; +(b) placed worker threads per domain, which is the thread pool the engineer +wanted to avoid; (c) the facility reports placement and the client places its +own threads. + +**Chosen: (c)**, with (a) available for consumers who want it, and (b) only +against a real consumer that neither serves. The risk worth guarding is not +building (b) but building it *first*, before knowing whether (c) suffices. + +**And (c) is smaller than a `CreateThread` wrapper.** The missing platform +capability is not *creating* a thread -- `std::thread::spawn` does that -- it is +**placing** one. So the facility provides exactly that: + +```rust +let _guard = domain.bind_current_thread()?; // restores on drop +``` + +applied by the client from its own thread, whatever created it. This is +slope-proof for one reason: **we never own a thread.** There is no handle kept, +no lifetime managed, no failure to respond to, nothing to restart -- so there is +no first step to take. It also composes with thread sources that could not be +anticipated: `std::thread`, an existing worker, another runtime's pool. + +The restore guard is required rather than decorative: a client binding a *pool* +thread must restore it, because the thread-pool contract is that a callback +restores any thread state it changes. That observation also places the feature -- +affinity is thread-scoped state applied and restored, which is exactly the family +[windows-thread-ambient-sys](../crates/windows-thread-ambient-sys/README.md) +already handles. It either belongs there or must mirror that crate's guard +discipline rather than inventing a second pattern. + +So (c) is concrete: the domain's `ProcessorSet`, an answer to "which domain is +nearest me", and a one-call binder with a restore guard. Every piece needed to +process a CQ on the right thread with the right affinity, and not one thread of +ours. ## Working position on domain counts (not a decision) From 55a078c39cc81db5598fe0100cd98f3603fdcee5 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 16:16:25 -0400 Subject: [PATCH 006/361] docs: record the SMOP principle, two-tier thread construction, and Q7 Three related records, all stemming from the engineer correcting a position this session had adopted. The correction: an earlier turn concluded that a one-call affinity binder was sufficient and that a CreateThread wrapper would edge too close to a slippery slope. That was wrong. A thread's stack is allocated at creation time on the creating thread's node, so binding afterwards cannot move it -- every local, every spill, every call frame stays remote for the life of the thread, and no later SetThreadGroupAffinity helps. There is also a window before the bind lands in which the thread runs on the wrong processor. It is a missing layer rather than a convenience because Rust cannot express the correct construction at all: creation-time affinity needs CreateRemoteThreadEx with a PROC_THREAD_ATTRIBUTE_LIST carrying GROUP_AFFINITY and IDEAL_PROCESSOR, assembled through a two-pass sizing call with an opaque buffer whose attribute values must outlive it. std::thread::Builder sets a stack size and nothing else. So a Rust consumer has no path to a correctly constructed thread without dropping to raw Win32, and once there must also re-supply what std was doing, notably catching unwind so a panic does not cross an extern "system" boundary. Each step is simple; collectively they are a minefield nobody crosses. So the line moves one word: the facility helps *construct* a thread and never *owns* one. The slope is ownership, not construction. The binder stays as the honestly-labelled degraded path for threads the client did not create. Promotes the reasoning behind that correction to a governing principle in the root DESIGN-NOTES, because it decides questions that otherwise get decided by an instinct to keep scope small, and because it would have caught this error: - "It is only a SMOP" is never an argument against building something; it is the explanation for why it is still missing. - The measure of success is whether the correct path is easier to reach than the obvious wrong one. fprintf and a default CreateFileW win by being reachable. - When the correct construction is difficult, providing the constructor is the feature. The failure mode it prevents is optimizing to protect the library from scope when the purpose is to absorb difficulty on the consumer's behalf. It sharpens the founding theme's existing observation that windows-sys "does little to help turn the alphabet and phrasebook into a useful programming model". It schedules no work of its own, and says so, so the absent checklist item is visibly intentional. Adds Q7 to the spike questions and indexes it in the spikes README: does a thread created with PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY receive a node-local stack? Measurable via QueryWorkingSetEx, whose PSAPI_WORKING_SET_EX_BLOCK carries a Node field, by comparing a thread created with a remote-node affinity attribute against one created with none. Recorded rather than written: it needs a second spike using CreateRemoteThreadEx, and it is vacuous on the single-node development machine exactly like the existing questions. If it comes back showing the same node, creation-time affinity does not govern stack placement and the design must stop claiming it does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DESIGN-NOTES.md | 45 ++++++++++ .../design-sessions/spikes/README.md | 16 ++++ .../spikes/file-handle-numa-spike.rs | 16 ++++ ...08-30-numa-sharded-io-execution-domains.md | 87 ++++++++++++++----- 4 files changed, 142 insertions(+), 22 deletions(-) diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index 0222a043..01cdd64b 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -40,6 +40,51 @@ wants to avoid contributing to it. The Windows threadpool types are inherently m choices up to the developer. The `windows-sys` crate published by Microsoft helps with the basics of the FFI to the APIs, but does little to help turn the alphabet and phrasebook into a useful programming model. +## The value is existence, not cleverness: "it is only a SMOP" is why it is missing, not a reason to skip it + +A governing principle for the whole repository, stated because it decides +questions that otherwise get decided by an instinct to keep scope small. + +Nothing here is magic. Every layer in this repository is a Simple Matter Of +Programming -- attribute lists, guard pages, ring buffers, affinity masks, all +of it documented and none of it clever. **The reason these layers are worth +building is precisely that they do not exist**, and their absence is why +capable people muddle along with the most reachable tools: `fprintf`, +`CreateFileW` with default parameters, a thread created with no attributes at +all. Not because those are believed to be right, but because the correct +alternative was never within reach. + +Three rules follow, in decreasing order of how often they are needed: + +1. **"It is only a SMOP" is never an argument against building something.** It + is the explanation for why it is still missing. The observation that a thing + is straightforward is evidence *for* providing it, since straightforward work + nobody has done is exactly the gap a platform layer fills. Difficulty is not + what makes a layer valuable; availability is. + +2. **The measure of success is whether the correct path is easier to reach than + the obvious wrong one.** `fprintf` and a default `CreateFileW` win by being + reachable. A faster, safer, more correct facility that is harder to reach + than the wrong thing has failed, however good it is, because it will not be + reached. Ergonomics is not polish applied at the end; it is the feature. + +3. **When the correct construction is difficult, providing the constructor is + the feature.** If getting a thing right requires a two-pass sizing call, an + opaque buffer with lifetime rules, and three attributes that must be set + before creation rather than after, then assembling that correctly *is* the + deliverable. Declining it on the grounds that each step is simple leaves the + consumer exactly where they started. + +The failure mode this rule exists to prevent is an assistant or an engineer +optimizing to protect *the library* from scope, when the whole purpose is to +absorb difficulty on the *consumer's* behalf. The question is never "is this +small enough to be worth our while"; it is "is the correct thing currently +within a consumer's reach, and if not, what would put it there". + +**This principle schedules no work of its own.** It is a decision rule for +weighing future proposals, not a change to existing code, so the absence of a +checklist item for it is intentional rather than an oversight. + ## Windows SDK model and constraints This crate targets the object-based thread pool API (introduced in Windows Vista) rather than the legacy diff --git a/crates/windows-ioring-sys/design-sessions/spikes/README.md b/crates/windows-ioring-sys/design-sessions/spikes/README.md index 4685d013..a863f99c 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/README.md +++ b/crates/windows-ioring-sys/design-sessions/spikes/README.md @@ -45,6 +45,22 @@ node, **both** calls succeed on an ordinary NTFS data file and on a directory ha `0`. So "ordinary NTFS file" is not the no-association case; absence must come from a device layer advertising no proximity domain, which is what needs the other hardware. +### Q7 is recorded there but not implemented + +That file's Q7 asks a different question and needs a **second spike that does not exist yet**: does a +thread created with `PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY` receive a node-local *stack*? It matters +because a stack is allocated at thread creation on the creating thread's node, so binding affinity +afterwards cannot move it -- which is the whole argument for constructing domain threads with the +affinity already set rather than applying it later. + +It is measurable: `QueryWorkingSetEx` reports a `Node` per page, so take the address of a local in +the new thread and compare a thread created with a remote-node affinity attribute against one created +with none. Different nodes means creation-time affinity governs stack placement and the thread +builder is justified; the same node means it does not, and the design must stop claiming otherwise. + +Writing it needs `CreateRemoteThreadEx` with an attribute list. It is recorded rather than written +because it is vacuous on the single-node development machine, exactly like the questions above. + ## Why the drain spike looks over-built It carries a concurrency check and a control case because the first two versions of it **could not diff --git a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs index 02c760de..0093c624 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs @@ -31,6 +31,22 @@ //! columns live -- so record the space's layout (`Get-StoragePool`, //! `Get-PhysicalDisk`) alongside whatever this prints, or the result //! cannot be interpreted. +//! Q7 **does a thread created with `PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY` +//! get a node-local stack?** Binding a thread's affinity *after* it starts +//! cannot move its stack, which was allocated at creation on the creating +//! thread's node -- so a domain runtime must construct threads with the +//! affinity already set, and this asks whether the kernel then honours it +//! for the stack allocation. Measured with `QueryWorkingSetEx`, whose +//! `PSAPI_WORKING_SET_EX_BLOCK` carries a `Node` field: take the address +//! of a local in the new thread and ask which node its page is on. +//! Compare a thread created with an affinity attribute for a *remote* +//! node against one created with none. If the stacks report different +//! nodes, creation-time affinity governs stack placement and the builder +//! is justified; if they match, it does not and the design should stop +//! claiming it does. +//! **This question is not implemented below.** It needs a second spike +//! with `CreateRemoteThreadEx` and an attribute list, and is recorded here +//! so it is not lost -- see the session record. //! //! Why it matters: `DESIGN-NOTES.md` asserts that mapping a file handle to the //! NUMA node of its backing device "has no clean user-mode path" and "means diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index c78be34f..8152145e 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -812,32 +812,75 @@ own threads. against a real consumer that neither serves. The risk worth guarding is not building (b) but building it *first*, before knowing whether (c) suffices. -**And (c) is smaller than a `CreateThread` wrapper.** The missing platform -capability is not *creating* a thread -- `std::thread::spawn` does that -- it is -**placing** one. So the facility provides exactly that: - -```rust -let _guard = domain.bind_current_thread()?; // restores on drop -``` - -applied by the client from its own thread, whatever created it. This is -slope-proof for one reason: **we never own a thread.** There is no handle kept, -no lifetime managed, no failure to respond to, nothing to restart -- so there is -no first step to take. It also composes with thread sources that could not be -anticipated: `std::thread`, an existing worker, another runtime's pool. - -The restore guard is required rather than decorative: a client binding a *pool* -thread must restore it, because the thread-pool contract is that a callback -restores any thread state it changes. That observation also places the feature -- -affinity is thread-scoped state applied and restored, which is exactly the family +### Two tiers of thread construction, because binding afterwards is not equivalent + +An earlier form of this section concluded that a one-call binder was sufficient +and that a `CreateThread` wrapper would be "edging too close to the slippery +slope". **That was wrong, and the engineer corrected it**: constructing a thread +so that it has the right attributes *from the beginning* -- stack as well as +processor affinity -- is the difficult part, and it is the part a consumer +cannot easily do. + +**Why binding afterwards is strictly worse.** A thread's stack is allocated at +creation time, on whatever node the *creating* thread's policy selects. Spawn +from node 0, bind to node 1, and the stack stays on node 0 permanently -- every +local, every spill, every call frame is a remote access for the life of the +thread, and no amount of later `SetThreadGroupAffinity` moves it. There is also +a window before the bind lands in which the thread runs on the wrong processor +and warms the wrong caches. + +**Why this is a missing layer rather than a convenience.** Creation-time +affinity requires `CreateRemoteThreadEx` against one's own process with a +`PROC_THREAD_ATTRIBUTE_LIST` carrying `PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY` and +`PROC_THREAD_ATTRIBUTE_IDEAL_PROCESSOR`, assembled through +`InitializeProcThreadAttributeList` and `UpdateProcThreadAttribute` -- a two-pass +sizing call, a manually managed opaque buffer, and lifetime rules requiring the +attribute values to outlive the call. `std::thread::Builder` can set a stack +size and **nothing else**; it spawns with no attribute list. So a Rust consumer +has *no path* to a correctly constructed thread without dropping to raw Win32, +and once there must also re-supply what `std` was doing for it, notably catching +unwind at the entry so a panic does not cross an `extern "system"` boundary. + +Each of those steps is simple. Collectively they are a minefield nobody crosses, +which is the [SMOP principle](../DESIGN-NOTES.md#the-value-is-existence-not-cleverness) +exactly: the value is existence, and when the correct construction is difficult, +providing the constructor *is* the feature. + +**The line is ownership, not construction.** The facility helps *construct* a +thread and never *owns* one: a builder assembles the attribute list, applies the +domain's `GROUP_AFFINITY`, sets a stack reservation, wraps the entry in +`catch_unwind`, and hands back a thread **the client owns**. No handle kept, +nothing monitored, nothing restarted. The slope is ownership; construction is +not a step down it. + +| | correct from birth | for threads you did not create | +|---|---|---| +| stack placement | follows the creation-time affinity | already fixed, possibly remote | +| pre-bind window | none | exists | +| API | domain thread builder | `bind_current_thread()` plus restore guard | + +The binder remains, honestly labelled as the degraded path, for threads the +client did not create -- a pool thread, an existing worker. Its restore guard is +required rather than decorative: a client binding a *pool* thread must restore +it, because the thread-pool contract is that a callback restores any thread +state it changes. That also places the feature, since affinity is thread-scoped +state applied and restored, exactly the family [windows-thread-ambient-sys](../crates/windows-thread-ambient-sys/README.md) already handles. It either belongs there or must mirror that crate's guard discipline rather than inventing a second pattern. -So (c) is concrete: the domain's `ProcessorSet`, an answer to "which domain is -nearest me", and a one-call binder with a restore guard. Every piece needed to -process a CQ on the right thread with the right affinity, and not one thread of -ours. +**Unverified, and worth measuring rather than assuming:** whether a thread +created with `PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY` actually receives a +node-local *stack*. It is measurable -- `QueryWorkingSetEx` returns +`PSAPI_WORKING_SET_EX_BLOCK` with a `Node` field, so the address of a local in +the new thread can be asked which node its page is on -- and it is added as Q7 +to [file-handle-numa-spike.rs](../crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs). +Same hardware blocker as F-1: on a single-node machine the answer is always 0. + +So option (c) is concrete: the domain's `ProcessorSet`, an answer to "which +domain is nearest me", a builder that constructs a correctly placed thread, and +a binder for threads that already exist. Every piece needed to process a CQ on +the right thread with the right affinity, and not one thread of ours. ## Working position on domain counts (not a decision) From 04338992ac4862bda8b36a54e8c4e456a656529f Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 16:27:23 -0400 Subject: [PATCH 007/361] docs: record what is blocked on NUMA hardware, and what breaks without it A genuine multi-node machine is expected but not soon, so this design rests on documentation rather than measurement for a while. That is a different grade of evidence than most decisions in this repository and must not blur into them. Sorts every conclusion by whether it depends on an unverified NUMA claim. The pattern in the blocked column is the useful finding: none of those claims threaten the structure, only the justification and the tuning. If every one came back badly, the architecture would stand and the NUMA-specific features would be decorative rather than wrong. Sharper still, the first deliverable depends on none of them. At N=1 there is no routing, no placement choice, and the buffer goes on the only node there is. So "build N=1 first" -- chosen earlier because it is the common case and the substrate -- is also the plan that needs no NUMA hardware. That was not why it was chosen, and it is a welcome coincidence rather than a justification constructed after the fact. Three practices for the interval: - Mark documented-but-unwitnessed claims distinctly from measured ones. This repository's decisions are unusually well measured, which creates the hazard that a reader cannot tell D-23 -- measured, with a control case -- from a claim taken off a documentation page. - Quarantine each unverified claim so a correction is surgical. If Q7 returns false, the response should be editing one rationale, not restructuring. - Pre-build the instruments now. Time on a borrowed machine should be spent measuring, not writing attribute-list code. F-1's spike is already written and smoke-tested, and that smoke run found a real defect in it that would otherwise have surfaced on the borrowed machine. Also records an ordered run list for when the machine is available, so a short session yields the most: F-1 as-is, then Q6 against a Storage Space with its layout recorded, then Q7 once written, then the magnitude benchmark -- local against remote registered pool, which is the number the entire domain-count argument rests on and which nothing in this session has measured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...08-30-numa-sharded-io-execution-domains.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index 8152145e..f10fea6c 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -931,6 +931,76 @@ The reasoning behind the numbers matters more than the numbers: device it serves can be named; above 64 logical processors take the group floor and no more. +## Working under a hardware gap: what is blocked, and what breaks + +The engineer expects access to a genuine multi-node NUMA machine eventually, but +not soon. Until then this design rests on documentation rather than measurement, +which is a different grade of evidence than most decisions in this repository +and must not be allowed to blur into them. + +**Sorted by dependence on an unverified NUMA claim:** + +| Not blocked -- verifiable on the development machine | Blocked on NUMA hardware | +|---|---| +| the MPSC, eventcount, and doorbell (R1-R10 is pure concurrency) | whether the FSCTL names a *meaningful* volume node (F-1) | +| the two-layer ring and the client-facing API shape | Q6, whether a Storage Space reports honestly or reports a fiction | +| one-shot registration semantics (already established) | Q7, whether creation-time affinity yields a node-local stack | +| the C-1 doorbell measurement (`SetEvent` against `SubmitIoRing`) | the *magnitude* of the buffer-placement benefit | +| the composed layer's type-level traversal | domain-count tuning above one | +| the durability crate, whose mechanism was already measured as D-23/D-24 | | +| whether `CreateRemoteThreadEx` with an attribute list works at all | | + +**The pattern in the blocked column is the reassuring part: none of those +threaten the structure. They threaten the justification and the tuning.** If +every one came back badly, the architecture would stand and the NUMA-specific +features would be decorative rather than wrong. + +**And the first deliverable depends on none of them.** At N=1 there is no +routing, no placement choice, and the buffer goes on the only node there is. So +"build N=1 first", chosen above because it is the common case and the substrate, +is *also* the plan that needs no NUMA hardware. The whole first deliverable and +most of the second can be built before the machine exists. + +### Practices to adopt while the gap lasts + +1. **Mark documented-but-unwitnessed claims distinctly from measured ones.** + This repository's decisions are unusually well measured, which creates its own + hazard: a reader cannot tell + [D-23](../crates/windows-ioring-sys/DESIGN-NOTES.md#d-23) -- measured, with a + control case -- from a claim taken off a documentation page. Anything + load-bearing that rests on documentation must say so *in the decision*, the + way F-1 above says "contributed by the engineer as research, not measured + here". + +2. **Quarantine each unverified claim so that a correction is surgical.** Do not + let "creation-time affinity yields a node-local stack" become load-bearing for + anything beyond the thread builder's justification. If Q7 returns false, the + response should be editing one rationale, not restructuring a design. + +3. **Pre-build the instruments now, while the context is fresh.** Time on a + borrowed machine is likely to be short and should be spent measuring, not + writing `CreateRemoteThreadEx` attribute-list code. F-1's spike is already + written *and smoke-tested*, and that smoke run found a real defect in it -- + the `File::open` directory failure that would otherwise have surfaced on the + borrowed machine. Every remaining question deserves the same treatment before + the hardware appears. + +### What to run when the machine is available + +Ordered so that a short session yields the most: + +1. **F-1 / Q1-Q5** -- [file-handle-numa-spike.rs](../crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs), + as-is, against an ordinary volume. Establishes whether either call names a + meaningful node, and whether they agree. +2. **Q6** -- the same spike with a Storage Space directory as `argv[1]`, with the + space's layout recorded alongside. Distinguishes an honest answer from a + fiction, which is the outcome that would be worse than no answer. +3. **Q7** -- the thread-stack spike, once written. Decides whether the thread + builder's principal justification holds. +4. **Magnitude** -- a read benchmark with the registered pool placed local + against remote, which is the number the entire domain-count argument rests on + and which nothing in this session has measured. + ## Open questions - **The three structural gaps** in the coherence assessment above: where the From 7034a736f4f173dbba4fe3e35756c2e0fac0f66f Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 16:32:04 -0400 Subject: [PATCH 008/361] docs: add the Q7 thread-stack NUMA spike, written and smoke-tested Q7 was recorded last commit as a question with no instrument. This writes it, so that a short session on borrowed hardware is spent measuring rather than writing CreateRemoteThreadEx attribute-list code. The question: does a thread created with PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY receive a node-local stack? It matters because a stack is allocated at thread creation on the creating thread's node, so binding afterwards cannot move it -- which is the entire argument for a domain runtime constructing its threads with the affinity already set. That argument is currently assumed. Three threads discriminate the possibilities: A created with the affinity attribute, B created with no attribute list at all (the baseline, and what std::thread does), and C created plain then bound to the far node from inside itself, which is the shape a naive consumer writes. A-far with C-near means creation-time affinity governs and the builder is justified; A equal to B means it does not and the design must stop claiming it. Each thread reports two stack pages rather than one, and that refinement is what makes the result interpretable. Windows commits stack pages on demand, so a shallow page in the entry frame and a deep page behind a 64 KiB frame may be placed by different mechanisms. shallow != deep on either thread means pages follow first touch under the running affinity rather than a decision made once at creation, which would make the question subtler than the design assumes. Reporting only one page would have conflated the two and produced a confident answer to the wrong question. Valid is reported beside every node because QueryWorkingSetEx only fills Node for a resident page, and the spike refuses to print a conclusion when a probe was non-resident or when the machine has one node, rather than emitting a confident zero. Node is extracted from the Flags arm of the union by shift and mask, so it does not depend on how the binding chose to model the bitfield. Smoke-run here. The result is vacuous -- one node, everything reports 0 -- but the apparatus is proven: the two-pass attribute list assembles, all three threads are created including the one carrying the attribute, GetNumaNodeProcessorMaskEx returns mask 0xfff matching this machine's twelve cores, all six probes come back resident, SetThreadGroupAffinity succeeds, and the vacuity guard fires instead of concluding. One cosmetic defect found and fixed by that run: the third label overflowed its column and misaligned the table, which is exactly the sort of thing that should not be discovered on a borrowed machine. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../design-sessions/spikes/README.md | 40 +- .../spikes/file-handle-numa-spike.rs | 6 +- .../spikes/thread-stack-numa-spike.rs | 420 ++++++++++++++++++ ...08-30-numa-sharded-io-execution-domains.md | 7 +- 4 files changed, 453 insertions(+), 20 deletions(-) create mode 100644 crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs diff --git a/crates/windows-ioring-sys/design-sessions/spikes/README.md b/crates/windows-ioring-sys/design-sessions/spikes/README.md index a863f99c..1d4bc441 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/README.md +++ b/crates/windows-ioring-sys/design-sessions/spikes/README.md @@ -45,21 +45,31 @@ node, **both** calls succeed on an ordinary NTFS data file and on a directory ha `0`. So "ordinary NTFS file" is not the no-association case; absence must come from a device layer advertising no proximity domain, which is what needs the other hardware. -### Q7 is recorded there but not implemented - -That file's Q7 asks a different question and needs a **second spike that does not exist yet**: does a -thread created with `PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY` receive a node-local *stack*? It matters -because a stack is allocated at thread creation on the creating thread's node, so binding affinity -afterwards cannot move it -- which is the whole argument for constructing domain threads with the -affinity already set rather than applying it later. - -It is measurable: `QueryWorkingSetEx` reports a `Node` per page, so take the address of a local in -the new thread and compare a thread created with a remote-node affinity attribute against one created -with none. Different nodes means creation-time affinity governs stack placement and the thread -builder is justified; the same node means it does not, and the design must stop claiming otherwise. - -Writing it needs `CreateRemoteThreadEx` with an attribute list. It is recorded rather than written -because it is vacuous on the single-node development machine, exactly like the questions above. +### The second unrun spike: does creation-time affinity place the stack? + +[thread-stack-numa-spike.rs](thread-stack-numa-spike.rs) is the other ready-instrument-without-a-result. +It asks whether a thread created with `PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY` receives a node-local +*stack*. That matters because a stack is allocated at thread creation on the creating thread's node, +so binding affinity afterwards cannot move it -- which is the entire argument for constructing domain +threads with the affinity already set rather than applying it later. The argument is currently +**assumed**, and this measures it. + +Three threads discriminate the possibilities: **A** created with the affinity attribute, **B** created +with no attribute list at all (the baseline, and what `std::thread` does), and **C** created plain then +bound to the far node from inside itself -- the shape a naive consumer writes. Each reports the node of +a **shallow** stack page and a **deep** one behind a 64 KiB frame, because Windows commits stack pages +on demand and the two may be placed by different mechanisms. `shallow != deep` on either thread means +pages follow **first touch** under the running affinity rather than a decision made once at creation, +which would make the whole question subtler than the design assumes. + +It reports `Valid` beside every node, because `QueryWorkingSetEx` only fills `Node` for a resident +page, and it refuses to print a conclusion when a probe was non-resident or when the machine has one +node -- rather than emitting a confident zero. + +Smoke-run here, so the apparatus is proven even though the result is vacuous: the attribute list +assembles, all three threads are created, `GetNumaNodeProcessorMaskEx` returns `mask 0xfff` matching +this machine's twelve cores, all six probes come back resident, and the vacuity guard fires instead of +concluding. What remains untested is only what one node cannot show. ## Why the drain spike looks over-built diff --git a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs index 0093c624..64e68cfb 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs @@ -44,9 +44,9 @@ //! nodes, creation-time affinity governs stack placement and the builder //! is justified; if they match, it does not and the design should stop //! claiming it does. -//! **This question is not implemented below.** It needs a second spike -//! with `CreateRemoteThreadEx` and an attribute list, and is recorded here -//! so it is not lost -- see the session record. +//! **This question is not implemented below**, because it needs +//! `CreateRemoteThreadEx` and an attribute list rather than a file handle. +//! It now has its own instrument: `thread-stack-numa-spike.rs`. //! //! Why it matters: `DESIGN-NOTES.md` asserts that mapping a file handle to the //! NUMA node of its backing device "has no clean user-mode path" and "means diff --git a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs new file mode 100644 index 00000000..28cd3388 --- /dev/null +++ b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs @@ -0,0 +1,420 @@ +// Copyright (c) Mike Grier +//! Spike: does creation-time affinity govern where a thread's *stack* lives? +//! +//! **NOT YET RUN ON HARDWARE THAT CAN ANSWER IT.** Checked in as a ready +//! instrument, not a result. It needs a machine with at least two NUMA nodes +//! that both have processors. On a single-node machine every answer is `0` and +//! the run proves only that the apparatus works. +//! +//! # Why this matters +//! +//! A thread's stack is allocated when the thread is created, on whatever node +//! the *creating* thread's policy selects. If that is true and irreversible, +//! then binding a thread's affinity after it starts cannot move its stack -- +//! every local, every spill, every call frame stays remote for the life of the +//! thread. That is the entire argument for a domain runtime *constructing* its +//! threads with `PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY` already set, rather than +//! spawning them and calling `SetThreadGroupAffinity` afterwards. +//! +//! The argument is currently **assumed**. This measures it. +//! +//! # Design +//! +//! Three threads, which together discriminate the possibilities: +//! +//! A `created-far` -- created with `PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY` +//! naming the far node. +//! B `control-near` -- created with no attribute list at all. Its stack +//! should sit on the creator's node, and it is the +//! baseline the other two are read against. +//! C `bound-after` -- created with no attributes, then immediately +//! `SetThreadGroupAffinity` to the far node from inside +//! the thread. This is the shape a naive consumer writes. +//! +//! Each thread reports the NUMA node of **two** stack pages, because Windows +//! commits stack pages on demand and the two may be placed by different +//! mechanisms: +//! +//! shallow -- a local in the entry frame, on a page committed at or near +//! thread creation; +//! deep -- a local behind a 64 KiB frame, on a page committed later, while +//! the thread is already running under its final affinity. +//! +//! Reading the two together is what makes the result interpretable: +//! +//! | A.shallow | C.shallow | Conclusion | +//! |---|---|---| +//! | far | near | Creation-time affinity governs stack placement. The builder is justified and binding afterwards genuinely cannot fix it. | +//! | near | near | Creation-time affinity does **not** govern it. The builder's principal justification fails and the design must stop claiming it. | +//! | far | far | Something moved C's stack too; investigate before believing either. | +//! +//! And independently, for either thread: `shallow != deep` means pages are +//! placed by **first touch** under the running affinity, not by a decision made +//! once at creation -- which would mean a deep stack is local even when the +//! shallow one is not, and that the whole question is subtler than the design +//! assumes. +//! +//! `Valid` is reported alongside every node, because `QueryWorkingSetEx` only +//! fills `Node` for a resident page. A node read from a non-resident page is +//! meaningless, and treating one as an answer is the obvious way to get a +//! confident wrong result here. +//! +//! Run with: +//! ```toml +//! [dependencies] +//! windows-sys = { version = "0.61.2", default-features = false, features = [ +//! "Win32_Foundation", "Win32_Security", "Win32_System_Threading", +//! "Win32_System_SystemInformation", "Win32_System_ProcessStatus", +//! ] } +//! ``` + +use std::ffi::c_void; + +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0}; +use windows_sys::Win32::System::ProcessStatus::{ + PSAPI_WORKING_SET_EX_INFORMATION, QueryWorkingSetEx, +}; +use windows_sys::Win32::System::SystemInformation::GROUP_AFFINITY; +use windows_sys::Win32::System::Threading::{ + CreateRemoteThreadEx, DeleteProcThreadAttributeList, GetCurrentProcess, GetCurrentThread, + GetNumaHighestNodeNumber, GetNumaNodeProcessorMaskEx, INFINITE, + InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY, + SetThreadGroupAffinity, UpdateProcThreadAttribute, WaitForSingleObject, +}; + +/// Bit layout of `PSAPI_WORKING_SET_EX_BLOCK` for a valid page: `Valid` at bit +/// 0, `ShareCount` at 1..4, `Win32Protection` at 4..15, `Shared` at 15, and +/// `Node` at 16..22. Extracted from `Flags` rather than through the anonymous +/// bitfield struct, so this does not depend on how the binding chose to model +/// it. +mod ws_block { + pub const VALID: usize = 1; + pub const NODE_SHIFT: u32 = 16; + pub const NODE_MASK: usize = 0x3F; +} + +/// The NUMA node of the page containing `addr`, and whether that page was +/// resident. `None` means the query itself failed. +fn page_node(addr: *const c_void) -> Option<(bool, u32)> { + let mut info = PSAPI_WORKING_SET_EX_INFORMATION { + VirtualAddress: addr as *mut c_void, + ..unsafe { std::mem::zeroed() } + }; + let ok = unsafe { + QueryWorkingSetEx( + GetCurrentProcess(), + (&raw mut info).cast::(), + u32::try_from(size_of::()).unwrap(), + ) + }; + if ok == 0 { + return None; + } + // SAFETY: reading the `Flags` arm of the union, which is always valid to + // read as a `usize` regardless of which arm was written. + let flags = unsafe { info.VirtualAttributes.Flags }; + let valid = flags & ws_block::VALID != 0; + let node = ((flags >> ws_block::NODE_SHIFT) & ws_block::NODE_MASK) as u32; + Some((valid, node)) +} + +#[derive(Default, Clone, Copy)] +struct Probe { + valid: bool, + node: u32, + queried: bool, +} + +impl Probe { + fn take(addr: *const c_void) -> Self { + match page_node(addr) { + Some((valid, node)) => Probe { + valid, + node, + queried: true, + }, + None => Probe::default(), + } + } + + fn show(self) -> String { + if !self.queried { + "query FAILED".to_string() + } else if !self.valid { + "page not resident -- node meaningless".to_string() + } else { + format!("node {}", self.node) + } + } +} + +#[repr(C)] +struct Slot { + label: &'static str, + /// Thread C: bind to `far` from inside the thread, after it is running. + bind_far_after_start: bool, + far: GROUP_AFFINITY, + shallow: Probe, + deep: Probe, + bind_after_ok: Option, +} + +/// Forces a page deeper in the stack to be committed, then probes it. The +/// array is written to, because a page that is merely reserved is not resident +/// and its reported node would be meaningless. +#[inline(never)] +fn deep_probe() -> Probe { + let mut filler = [0_u8; 64 * 1024]; + // Touch both ends so the whole span is committed, and defeat any attempt to + // optimize the array away. + filler[0] = 1; + let last = filler.len() - 1; + filler[last] = 1; + std::hint::black_box(&filler); + Probe::take((&raw const filler[last]).cast::()) +} + +unsafe extern "system" fn entry(param: *mut c_void) -> u32 { + // SAFETY: `param` is the `Slot` this thread was created for; `main` joins + // every thread before the slots go out of scope. + let slot = unsafe { &mut *param.cast::() }; + + if slot.bind_far_after_start { + let ok = unsafe { SetThreadGroupAffinity(GetCurrentThread(), &raw const slot.far, std::ptr::null_mut()) }; + slot.bind_after_ok = Some(ok != 0); + } + + let shallow_local = 0_u64; + std::hint::black_box(&shallow_local); + slot.shallow = Probe::take((&raw const shallow_local).cast::()); + slot.deep = deep_probe(); + 0 +} + +/// Creates a thread, optionally with a group-affinity attribute applied at +/// creation. Returns the thread handle. +fn spawn(slot: &mut Slot, affinity: Option<&GROUP_AFFINITY>) -> Result { + let param = (&raw mut *slot).cast::(); + + let Some(affinity) = affinity else { + // Control: no attribute list at all, which is what every ordinary + // spawn does, including `std::thread`. + let h = unsafe { + CreateRemoteThreadEx( + GetCurrentProcess(), + std::ptr::null(), + 0, + Some(entry), + param, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + return if h.is_null() { + Err(format!("CreateRemoteThreadEx: {}", std::io::Error::last_os_error())) + } else { + Ok(h) + }; + }; + + // Two-pass sizing: the first call is expected to fail with + // ERROR_INSUFFICIENT_BUFFER and fill in `size`. + let mut size: usize = 0; + unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &raw mut size) }; + if size == 0 { + return Err("InitializeProcThreadAttributeList reported a zero size".into()); + } + let mut buf = vec![0_u8; size]; + let list = buf.as_mut_ptr().cast::(); + if unsafe { InitializeProcThreadAttributeList(list, 1, 0, &raw mut size) } == 0 { + return Err(format!( + "InitializeProcThreadAttributeList: {}", + std::io::Error::last_os_error() + )); + } + + // `affinity` must outlive the CreateRemoteThreadEx call: the attribute list + // stores the pointer, not a copy. + let ok = unsafe { + UpdateProcThreadAttribute( + list, + 0, + PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY as usize, + (affinity as *const GROUP_AFFINITY).cast::(), + size_of::(), + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + }; + if ok == 0 { + let e = std::io::Error::last_os_error(); + unsafe { DeleteProcThreadAttributeList(list) }; + return Err(format!("UpdateProcThreadAttribute: {e}")); + } + + let handle = unsafe { + CreateRemoteThreadEx( + GetCurrentProcess(), + std::ptr::null(), + 0, + Some(entry), + param, + 0, + list, + std::ptr::null_mut(), + ) + }; + unsafe { DeleteProcThreadAttributeList(list) }; + + if handle.is_null() { + Err(format!( + "CreateRemoteThreadEx (with affinity): {}", + std::io::Error::last_os_error() + )) + } else { + Ok(handle) + } +} + +fn join(handle: HANDLE) { + unsafe { + if WaitForSingleObject(handle, INFINITE) != WAIT_OBJECT_0 { + eprintln!("WaitForSingleObject: {}", std::io::Error::last_os_error()); + } + CloseHandle(handle); + } +} + +fn main() { + let mut highest: u32 = 0; + if unsafe { GetNumaHighestNodeNumber(&raw mut highest) } == 0 { + eprintln!( + "GetNumaHighestNodeNumber failed: {}", + std::io::Error::last_os_error() + ); + return; + } + println!("highest NUMA node number: {highest} (nodes: {})", highest + 1); + + if highest == 0 { + println!(); + println!("*** VACUOUS ON THIS MACHINE ***"); + println!("One NUMA node means every answer below is 0 and nothing is"); + println!("discriminated. Running anyway only validates the apparatus."); + } + + // The far node is the highest-numbered one that actually has processors; a + // memory-only node cannot host a thread, so it cannot answer this question. + let mut far = GROUP_AFFINITY::default(); + let mut far_node = u32::MAX; + for candidate in (0..=highest).rev() { + let mut ga = GROUP_AFFINITY::default(); + if unsafe { GetNumaNodeProcessorMaskEx(candidate as u16, &raw mut ga) } != 0 && ga.Mask != 0 + { + far = ga; + far_node = candidate; + break; + } + } + if far_node == u32::MAX { + println!("no NUMA node reports any processors; cannot proceed."); + return; + } + println!( + "far node chosen: {far_node} (group {}, mask {:#x})", + far.Group, far.Mask + ); + + let mut slots = [ + Slot { + label: "A created-far (affinity attribute at creation)", + bind_far_after_start: false, + far, + shallow: Probe::default(), + deep: Probe::default(), + bind_after_ok: None, + }, + Slot { + label: "B control-near (no attribute list at all)", + bind_far_after_start: false, + far, + shallow: Probe::default(), + deep: Probe::default(), + bind_after_ok: None, + }, + Slot { + label: "C bound-after (spawned plain, bound to far)", + bind_far_after_start: true, + far, + shallow: Probe::default(), + deep: Probe::default(), + bind_after_ok: None, + }, + ]; + + let with_affinity = [true, false, false]; + let mut handles = Vec::new(); + for (slot, &use_attr) in slots.iter_mut().zip(with_affinity.iter()) { + match spawn(slot, if use_attr { Some(&far) } else { None }) { + Ok(h) => handles.push(h), + Err(e) => { + println!("spawn failed for {}: {e}", slot.label); + return; + } + } + } + for h in handles { + join(h); + } + + println!("\n{:<50} {:<28} {}", "thread", "shallow stack page", "deep stack page"); + for slot in &slots { + println!( + "{:<50} {:<28} {}", + slot.label, + slot.shallow.show(), + slot.deep.show() + ); + if let Some(ok) = slot.bind_after_ok { + println!("{:<50} SetThreadGroupAffinity ok = {ok}", ""); + } + } + + // Only interpret when the pages are resident and the machine has more than + // one node; otherwise say so rather than printing a confident conclusion. + let usable = slots + .iter() + .all(|s| s.shallow.queried && s.shallow.valid && s.deep.queried && s.deep.valid); + println!(); + if highest == 0 { + println!("=> VACUOUS: one node. Apparatus works; question unanswered."); + } else if !usable { + println!("=> INCONCLUSIVE: a probed page was not resident or the query failed."); + } else { + let (a, b, c) = ( + slots[0].shallow.node, + slots[1].shallow.node, + slots[2].shallow.node, + ); + if a == far_node && c == b { + println!("=> Creation-time affinity GOVERNS stack placement, and binding"); + println!(" afterwards does not move the stack. The thread builder is justified."); + } else if a == b && c == b { + println!("=> Creation-time affinity does NOT govern stack placement."); + println!(" The builder's principal justification FAILS; stop claiming it."); + } else { + println!("=> Unexpected combination (A={a}, B={b}, C={c}, far={far_node})."); + println!(" Investigate before believing any of it."); + } + for slot in &slots { + if slot.shallow.node != slot.deep.node { + println!( + " NOTE: {} has shallow != deep, so pages are placed by FIRST TOUCH", + slot.label.split_whitespace().next().unwrap_or("?") + ); + println!(" under the running affinity, not once at creation."); + } + } + } +} diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index f10fea6c..d85265f2 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -995,8 +995,11 @@ Ordered so that a short session yields the most: 2. **Q6** -- the same spike with a Storage Space directory as `argv[1]`, with the space's layout recorded alongside. Distinguishes an honest answer from a fiction, which is the outcome that would be worse than no answer. -3. **Q7** -- the thread-stack spike, once written. Decides whether the thread - builder's principal justification holds. +3. **Q7** -- [thread-stack-numa-spike.rs](../crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs), + written and smoke-tested. Decides whether the thread builder's principal + justification holds. Three threads (attribute at creation, plain control, + plain-then-bound) each report a shallow and a deep stack page, which also + distinguishes creation-time placement from first-touch placement. 4. **Magnitude** -- a read benchmark with the registered pool placed local against remote, which is the number the entire domain-count argument rests on and which nothing in this session has measured. From eb0ffd87427b6e1463725476bf805b224bcd3cd0 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 16:53:35 -0400 Subject: [PATCH 009/361] feat(probes): run the topology and NUMA spikes in CI so results accumulate Hosted runners are a heterogeneous fleet, so printing what the machine looks like on every build turns ordinary CI into a slow survey of real hardware. The design currently rests on documentation for several NUMA questions because no multi-node machine is available; this makes every build a chance that one of those questions answers itself. Adds probe-topology to windows-platform-probes, following that crate's existing shape: the observation is a library function, the binary prints it, the tests assert it, so the fact has one implementation. It reads the shipping windows-topology-sys parse rather than a second parse written here -- same reasoning the pool-growth probe uses the real thread-pool crate -- and then cross-checks it against GetActiveProcessorCount, GetActiveProcessorGroupCount and GetNumaHighestNodeNumber read independently. That cross-check is asserted, so a parsing regression in windows-topology-sys fails the build on whatever hardware CI happens to run on. It also computes the rule the design actually wants: the outermost cache level that *partitions* the machine, not "L3". On the ARM64 development machine that is L2 with two domains of six, because the part reports no L3 at all -- and the probe says so explicitly rather than silently reporting nothing. Running the probe here surfaced something the earlier ad-hoc measurement missed: this machine reports **two efficiency classes**, so it is heterogeneous. That is concrete support for the session's conclusion that even a single domain wants an affinity mask, since an unconstrained thread can land on a slow core. Adds a numa-spikes job that builds and runs the two standalone spikes through the scratch-crate procedure their own README documents. They are not made workspace members because each is deliberately written against windows-sys alone, so that what it measures is the operating system and not us. A useful side effect: the job is the executable form of that README instruction, so the instruction cannot rot without turning the step red. The job is continue-on-error by design. A spike reporting "vacuous" is the expected outcome on a single-node runner and must not be a build break. The script exits non-zero only when a spike fails to BUILD, which is a defect in the instrument rather than a finding about the machine -- sabotage-verified by introducing a type error and confirming the run reports ::error:: and exits 1, while the healthy spike still ran. Extends the file-handle spike to capture the storage topology, on the engineer's observation that whether the node query succeeds at all is interesting even at one node. It now records bus type and product identity (which separates real NVMe from the virtual disks hosted runners use), the physical disk number, and how many disks back the volume. That last one is the Q6 spanned-volume question and needs no NUMA hardware at all: more than one extent means a single reported node cannot be true of every device, which is the outcome worse than no answer. Measured here: NVMe bus, single device, single node, and both node queries succeed. If a runner shows a virtual bus and the queries fail, that correlation is the finding. Both spikes and the topology probe now emit one JSON line each -- tagged x-probe-topology, x-spike-file-handle-numa, x-spike-thread-stack-numa -- so accumulated build logs can be mined mechanically instead of read. The thread-stack line reports `usable` so a miner can discard runs where a probed page was not resident rather than reading a meaningless node out of them. Results go to the job summary and to an uploaded artifact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 49 +++ .../spikes/file-handle-numa-spike.rs | 289 +++++++++++++++++- .../spikes/thread-stack-numa-spike.rs | 32 ++ crates/windows-platform-probes/Cargo.toml | 8 + .../src/bin/topology.rs | 148 +++++++++ crates/windows-platform-probes/src/lib.rs | 1 + crates/windows-platform-probes/src/tests.rs | 129 ++++++++ .../windows-platform-probes/src/topology.rs | 254 +++++++++++++++ tools/run-numa-spikes.ps1 | 160 ++++++++++ 9 files changed, 1066 insertions(+), 4 deletions(-) create mode 100644 crates/windows-platform-probes/src/bin/topology.rs create mode 100644 crates/windows-platform-probes/src/topology.rs create mode 100644 tools/run-numa-spikes.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f17340dd..e7ff6940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,55 @@ jobs: run: cargo run -p windows-platform-probes --bin probe-ioring --locked - name: probe magnitudes (completion port) run: cargo run -p windows-platform-probes --bin probe-completion-port --locked + # Printed on every build on purpose. Hosted runners are a heterogeneous + # fleet, so accumulating this across builds turns ordinary CI into a slow + # survey of what real machines look like -- and the negative result, that + # cloud runners are consistently single-node, is itself evidence for how + # the execution-domain design should size itself by default. The probe + # emits one `x-probe-topology` JSON line so the results can be mined out + # of logs mechanically rather than read by eye. + - name: probe magnitudes (topology) + run: cargo run -p windows-platform-probes --bin probe-topology --locked + + # The NUMA questions the 2026-08-30 design session could not answer, run + # against whatever machine the runner fleet supplies. + # + # BOTH SPIKES ARE EXPECTED TO BE VACUOUS on a single-node runner, and they say + # so themselves rather than printing a confident zero. That is the point: the + # cost is a minute per build, and the payoff is that if a multi-node runner + # ever appears, the answer is already in that build's log. A design decision + # is currently resting on documentation because no such machine is available. + # + # A FAILURE HERE MUST NOT BREAK THE BUILD. These are observations, not + # assertions -- `continue-on-error` is deliberate, not laziness. + # + # They are compiled through the scratch-crate procedure their own README + # documents, rather than being made workspace members, because each is + # deliberately written against `windows-sys` alone so that what it measures is + # the operating system and not us. A useful side effect: this job is the + # executable form of that README instruction, so the instruction cannot rot + # without turning this step red. + numa-spikes: + name: NUMA spikes (observational, never fails the build) + runs-on: windows-latest + continue-on-error: true + timeout-minutes: 15 + env: + RUSTUP_TOOLCHAIN: stable + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Run the spikes + shell: pwsh + run: ./tools/run-numa-spikes.ps1 -Summary $env:GITHUB_STEP_SUMMARY + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: numa-spikes-${{ github.run_id }}-${{ github.run_attempt }} + path: .scratch/numa-spikes/*.txt + if-no-files-found: warn feature-matrix: name: windows-overlapped-io-sys (${{ matrix.feature }} only) diff --git a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs index 64e68cfb..36d5dd8f 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs @@ -64,6 +64,34 @@ //! PHNT and the WDK mark that class **reserved for system use**, so this spike //! measures it for comparison only. Do not build on it. //! +//! # The storage topology is captured too, and it is useful even at one node +//! +//! Whether the node query *succeeds at all* is itself a finding, independent of +//! how many nodes exist. On the ARM64 development machine both calls succeed +//! and report `0` -- so "an ordinary NTFS file" is not the no-association case. +//! If the same calls **fail** on some other host, the association depends on +//! the storage stack rather than on node count, and the interesting question +//! becomes *which* stacks have it. +//! +//! So the spike also records what the volume is made of: +//! +//! - the bus type and product identity (`IOCTL_STORAGE_QUERY_PROPERTY`), +//! which distinguishes real NVMe from a virtual disk -- and hosted CI +//! runners are virtual; +//! - the physical disk number (`IOCTL_STORAGE_GET_DEVICE_NUMBER`); +//! - **how many disks back the volume** +//! (`IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS`). More than one means the volume +//! spans devices, which is exactly the Q6 hazard: a single reported node +//! for a multi-device volume is a fiction, and worse than no answer. +//! +//! That last one needs no NUMA hardware, so a spanned volume anywhere in a CI +//! fleet is a result. +//! +//! # Machine-readable output +//! +//! The final `x-spike-file-handle-numa` line is a single JSON object, so +//! accumulated build logs can be mined mechanically instead of read. +//! //! Run with: //! ```toml //! [dependencies] @@ -73,7 +101,7 @@ //! ] } //! ``` -use std::ffi::c_void; +use std::ffi::{OsStr, c_void}; use std::fs; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::AsRawHandle; @@ -82,15 +110,205 @@ use std::path::Path; use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_READ, HANDLE, INVALID_HANDLE_VALUE}; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, - OPEN_EXISTING, + GetVolumePathNameW, IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS, OPEN_EXISTING, }; use windows_sys::Win32::System::IO::DeviceIoControl; -use windows_sys::Win32::System::Ioctl::FSCTL_QUERY_VOLUME_NUMA_INFO; +use windows_sys::Win32::System::Ioctl::{ + FSCTL_QUERY_VOLUME_NUMA_INFO, IOCTL_STORAGE_GET_DEVICE_NUMBER, IOCTL_STORAGE_QUERY_PROPERTY, + PropertyStandardQuery, STORAGE_DEVICE_DESCRIPTOR, STORAGE_DEVICE_NUMBER, + STORAGE_PROPERTY_QUERY, StorageDeviceProperty, +}; fn to_wide(path: &Path) -> Vec { path.as_os_str().encode_wide().chain(Some(0)).collect() } +/// What the volume under a path is physically made of. +/// +/// Every field is optional because every query can fail, and a failure is a +/// result here rather than an error: it says this host does not expose that +/// fact, which is precisely what varies across a runner fleet. +#[derive(Default)] +struct Storage { + volume_root: Option, + bus_type: Option, + product: Option, + removable: Option, + disk_number: Option, + /// Disks backing the volume. Greater than one means it spans devices, and a + /// single NUMA node reported for it cannot be true of all of them. + disk_extents: Option, +} + +/// `STORAGE_BUS_TYPE` values worth naming. A virtual bus is the tell for a +/// hosted runner; NVMe and SAS are the cases where device proximity data +/// plausibly exists. +fn bus_name(bus: u8) -> &'static str { + match bus { + 0x01 => "SCSI", + 0x02 => "ATAPI", + 0x03 => "ATA", + 0x04 => "1394", + 0x05 => "SSA", + 0x06 => "Fibre", + 0x07 => "USB", + 0x08 => "RAID", + 0x09 => "iSCSI", + 0x0A => "SAS", + 0x0B => "SATA", + 0x0C => "SD", + 0x0D => "MMC", + 0x0E => "Virtual", + 0x0F => "FileBackedVirtual", + 0x10 => "Spaces", + 0x11 => "NVMe", + 0x12 => "SCM", + 0x13 => "UFS", + _ => "unknown", + } +} + +fn open_volume(root: &str) -> HANDLE { + // `\\.\C:` form: strip the trailing separator the volume-path API returns. + let device = format!(r"\\.\{}", root.trim_end_matches('\\')); + let wide: Vec = OsStr::new(&device).encode_wide().chain(Some(0)).collect(); + // Zero desired access is enough for query-only IOCTLs and, unlike + // GENERIC_READ, does not require elevation -- which matters because this is + // meant to run unprivileged in CI. + unsafe { + CreateFileW( + wide.as_ptr(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + 0, + std::ptr::null_mut(), + ) + } +} + +fn describe_storage(path: &Path) -> Storage { + let mut out = Storage::default(); + + // Which volume is this path on? + let wide = to_wide(path); + let mut root = vec![0_u16; 260]; + let ok = unsafe { + GetVolumePathNameW( + wide.as_ptr(), + root.as_mut_ptr(), + u32::try_from(root.len()).unwrap(), + ) + }; + if ok == 0 { + return out; + } + let len = root.iter().position(|&c| c == 0).unwrap_or(root.len()); + out.volume_root = Some(String::from_utf16_lossy(&root[..len])); + + let Some(volume_root) = out.volume_root.clone() else { + return out; + }; + let handle = open_volume(&volume_root); + if handle == INVALID_HANDLE_VALUE { + return out; + } + + let mut returned: u32 = 0; + + // Bus type and product identity: distinguishes real NVMe from a virtual + // disk, which is the correlation worth having when the node query fails. + let query = STORAGE_PROPERTY_QUERY { + PropertyId: StorageDeviceProperty, + QueryType: PropertyStandardQuery, + AdditionalParameters: [0], + }; + let mut buf = vec![0_u8; 1024]; + let ok = unsafe { + DeviceIoControl( + handle, + IOCTL_STORAGE_QUERY_PROPERTY, + (&raw const query).cast::(), + u32::try_from(size_of::()).unwrap(), + buf.as_mut_ptr().cast::(), + u32::try_from(buf.len()).unwrap(), + &raw mut returned, + std::ptr::null_mut(), + ) + }; + if ok != 0 && (returned as usize) >= size_of::() { + // SAFETY: the driver filled at least a descriptor's worth of `buf`. + let desc = unsafe { &*buf.as_ptr().cast::() }; + out.bus_type = Some(desc.BusType as u8); + out.removable = Some(desc.RemovableMedia); + // The ID offsets are byte offsets into the same buffer, or 0 for absent. + let text_at = |offset: u32| -> Option { + if offset == 0 || offset as usize >= buf.len() { + return None; + } + let start = offset as usize; + let end = buf[start..] + .iter() + .position(|&b| b == 0) + .map_or(buf.len(), |n| start + n); + let s = String::from_utf8_lossy(&buf[start..end]).trim().to_string(); + (!s.is_empty()).then_some(s) + }; + let vendor = text_at(desc.VendorIdOffset); + let product = text_at(desc.ProductIdOffset); + out.product = match (vendor, product) { + (Some(v), Some(p)) => Some(format!("{v} {p}")), + (Some(v), None) => Some(v), + (None, Some(p)) => Some(p), + (None, None) => None, + }; + } + + // Which physical disk. + let mut number = STORAGE_DEVICE_NUMBER::default(); + let ok = unsafe { + DeviceIoControl( + handle, + IOCTL_STORAGE_GET_DEVICE_NUMBER, + std::ptr::null(), + 0, + (&raw mut number).cast::(), + u32::try_from(size_of::()).unwrap(), + &raw mut returned, + std::ptr::null_mut(), + ) + }; + if ok != 0 { + out.disk_number = Some(number.DeviceNumber); + } + + // How many disks back this volume. This is the Q6 question, and it needs no + // NUMA hardware: more than one extent means a reported node cannot be true + // of every device the volume sits on. + let mut extents = vec![0_u8; 4096]; + let ok = unsafe { + DeviceIoControl( + handle, + IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS, + std::ptr::null(), + 0, + extents.as_mut_ptr().cast::(), + u32::try_from(extents.len()).unwrap(), + &raw mut returned, + std::ptr::null_mut(), + ) + }; + if ok != 0 && (returned as usize) >= size_of::() { + // SAFETY: the first field of VOLUME_DISK_EXTENTS is NumberOfDiskExtents. + let count = unsafe { *extents.as_ptr().cast::() }; + out.disk_extents = Some(count); + } + + unsafe { CloseHandle(handle) }; + out +} + // Not present in windows-sys 0.61, so declared here. #[link(name = "kernel32")] unsafe extern "system" { @@ -179,10 +397,40 @@ fn main() -> std::io::Result<()> { let path = dir.join("numa-probe-target.bin"); fs::write(&path, vec![0_u8; 4096])?; + // What the volume is physically made of. Printed before the node queries so + // that a reader has the context to interpret a failure: a virtual disk + // failing to report a node means something different from an NVMe failing. + let storage = describe_storage(&path); + println!("-- storage under {} --", path.display()); + println!( + " volume root : {}", + storage.volume_root.as_deref().unwrap_or("(unknown)") + ); + match storage.bus_type { + Some(bus) => println!(" bus type : {bus} ({})", bus_name(bus)), + None => println!(" bus type : (query failed)"), + } + println!( + " product : {}", + storage.product.as_deref().unwrap_or("(unknown)") + ); + match storage.disk_number { + Some(n) => println!(" disk number : {n}"), + None => println!(" disk number : (query failed)"), + } + match storage.disk_extents { + Some(1) => println!(" disk extents : 1 (single device)"), + Some(n) => println!( + " disk extents : {n} -- THIS VOLUME SPANS {n} DEVICES, so any single \ + node reported for it cannot be true of all of them" + ), + None => println!(" disk extents : (query failed)"), + } + // Q1-Q4: a garden-variety data file, opened the ordinary way. This is the // case for which no published measurement could be found. let file = fs::File::open(&path)?; - probe( + let (file_volume_node, file_handle_node) = probe( &format!("regular NTFS data file: {}", path.display()), file.as_raw_handle() as HANDLE, ); @@ -217,6 +465,39 @@ fn main() -> std::io::Result<()> { unsafe { CloseHandle(handle) }; } + // One machine-readable line, so accumulated CI logs can be mined without + // parsing the prose above. + let json_opt_u32 = |v: Option| v.map_or("null".to_string(), |n| n.to_string()); + let json_opt_str = |v: Option<&str>| { + v.map_or("null".to_string(), |s| { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) + }) + }; + println!( + concat!( + r#"{{"reason":"x-spike-file-handle-numa","arch":"{}","volume_root":{},"#, + r#""bus_type":{},"bus_name":{},"product":{},"removable":{},"disk_number":{},"#, + r#""disk_extents":{},"spans_devices":{},"fsctl_volume_node":{},"#, + r#""handle_node":{},"both_succeeded":{}}}"# + ), + std::env::consts::ARCH, + json_opt_str(storage.volume_root.as_deref()), + json_opt_u32(storage.bus_type.map(u32::from)), + json_opt_str(storage.bus_type.map(bus_name)), + json_opt_str(storage.product.as_deref()), + storage + .removable + .map_or("null".to_string(), |b| b.to_string()), + json_opt_u32(storage.disk_number), + json_opt_u32(storage.disk_extents), + storage + .disk_extents + .map_or("null".to_string(), |n| (n > 1).to_string()), + json_opt_u32(file_volume_node), + json_opt_u32(file_handle_node.map(u32::from)), + file_volume_node.is_some() && file_handle_node.is_some(), + ); + drop(file); let _ = fs::remove_file(&path); Ok(()) diff --git a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs index 28cd3388..0e07c068 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs @@ -417,4 +417,36 @@ fn main() { } } } + + // One machine-readable line, so accumulated CI logs can be mined without + // parsing the prose above. `usable` is reported so a miner can discard runs + // where a probed page was not resident rather than reading a meaningless + // node out of them. + let probe_json = |p: Probe| { + if p.queried && p.valid { + p.node.to_string() + } else { + "null".to_string() + } + }; + println!( + concat!( + r#"{{"reason":"x-spike-thread-stack-numa","arch":"{}","numa_nodes":{},"#, + r#""far_node":{},"vacuous":{},"usable":{},"#, + r#""created_far":{{"shallow":{},"deep":{}}},"#, + r#""control_near":{{"shallow":{},"deep":{}}},"#, + r#""bound_after":{{"shallow":{},"deep":{}}}}}"# + ), + std::env::consts::ARCH, + highest + 1, + far_node, + highest == 0, + usable, + probe_json(slots[0].shallow), + probe_json(slots[0].deep), + probe_json(slots[1].shallow), + probe_json(slots[1].deep), + probe_json(slots[2].shallow), + probe_json(slots[2].deep), + ); } diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index 185b2206..ad473e72 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -47,11 +47,19 @@ path = "src/bin/ioring.rs" name = "probe-pool-growth" path = "src/bin/pool_growth.rs" +[[bin]] +name = "probe-topology" +path = "src/bin/topology.rs" + [dependencies] # The pool-growth probe measures the shipping API rather than a # reimplementation of the SDK's inline environment helpers, so it depends on the # real crate. windows-threadpool-sys = { version = "0.1.3", path = "../windows-threadpool-sys" } +# Same reason: the topology probe measures what the shipping parse produces, not +# a second parse written here, which would only measure itself. The raw Win32 +# counters it cross-checks against are read independently through windows-sys. +windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys" } [dependencies.windows-sys] version = "0.61.2" diff --git a/crates/windows-platform-probes/src/bin/topology.rs b/crates/windows-platform-probes/src/bin/topology.rs new file mode 100644 index 00000000..5e4de388 --- /dev/null +++ b/crates/windows-platform-probes/src/bin/topology.rs @@ -0,0 +1,148 @@ +// Copyright (c) Mike Grier. + +//! Prints the machine's processor topology, and how many execution domains each +//! candidate partitioning policy would produce on it. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! Running this on every CI build is deliberate: hosted runners are a +//! heterogeneous fleet, so the accumulated output is a slow survey of what real +//! machines look like. The line tagged `x-probe-topology` is emitted as a single +//! JSON object so those results can be mined out of build logs mechanically +//! rather than read by eye. + +use windows_platform_probes::topology::measure; + +fn main() { + println!("== processor topology, and what each partitioning policy would yield ==\n"); + + let observation = match measure() { + Ok(observation) => observation, + Err(error) => { + println!("Topology::discover failed: {error}"); + println!("(Reported rather than measured: a probe that cannot read its"); + println!("subject must say so instead of printing a misleading shape.)"); + return; + } + }; + + println!("processors (online) : {}", observation.online_processors); + println!("processor groups : {}", observation.groups); + println!("packages : {}", observation.packages); + println!( + "NUMA domains : {} ({} with no processors)", + observation.numa_domains, observation.memoryless_numa_domains + ); + println!("physical cores : {}", observation.cores.len()); + + let smt = observation + .cores + .iter() + .filter(|c| c.simultaneous_multithreading) + .count(); + let mut classes: Vec = observation + .cores + .iter() + .map(|c| c.efficiency_class) + .collect(); + classes.sort_unstable(); + classes.dedup(); + println!(" cores with SMT : {smt}"); + println!(" efficiency classes: {classes:?}"); + if classes.len() > 1 { + println!(" (heterogeneous: an I/O thread left unconstrained can land on an"); + println!(" efficiency core, which is why even a single domain wants a mask)"); + } + + println!("\ncaches:"); + if observation.caches.is_empty() { + println!(" none reported"); + } + for cache in &observation.caches { + println!( + " L{:<2} {:>3} domain(s), processors per domain: {:?}", + cache.level, cache.domains, cache.processors_per_domain + ); + } + + match observation.outermost_partitioning_cache() { + Some(cache) => println!( + "\noutermost cache that partitions this machine: L{} ({} domains)", + cache.level, cache.domains + ), + None => println!("\nno cache level partitions this machine: every level is machine-wide"), + } + if !observation.caches.iter().any(|c| c.level == 3) { + println!("NOTE: this machine reports no L3 at all, so a policy keyed literally"); + println!("on \"L3\" would find nothing here. That is the measured case behind"); + println!("phrasing the rule as \"the outermost level that partitions\"."); + } + + println!("\ndomains each policy would produce:"); + for (name, count) in observation.domain_counts() { + println!(" {name:<34} {count}"); + } + + println!("\ncross-check against independently read Win32 counters:"); + println!( + " GetActiveProcessorCount : {}", + observation.raw_active_processors + ); + println!( + " GetActiveProcessorGroupCount: {}", + observation.raw_group_count + ); + match observation.raw_highest_numa_node { + Some(highest) => println!( + " GetNumaHighestNodeNumber : {highest} (so {} nodes)", + highest + 1 + ), + None => println!(" GetNumaHighestNodeNumber : failed"), + } + let complaints = observation.cross_check(); + if complaints.is_empty() { + println!(" => agree. windows-topology-sys parsed this machine consistently."); + } else { + println!(" => DISAGREE. This is a finding, not a nuisance:"); + for complaint in &complaints { + println!(" - {complaint}"); + } + } + + // One machine-readable line, so accumulated CI logs can be mined without + // parsing the prose above. Kept to a single line on purpose. + let cache_json: Vec = observation + .caches + .iter() + .map(|c| format!(r#"{{"level":{},"domains":{}}}"#, c.level, c.domains)) + .collect(); + let policy_json: Vec = observation + .domain_counts() + .into_iter() + .map(|(name, count)| format!(r#""{name}":{count}"#)) + .collect(); + println!( + concat!( + r#"{{"reason":"x-probe-topology","arch":"{}","processors":{},"groups":{},"#, + r#""packages":{},"numa_domains":{},"memoryless_numa_domains":{},"cores":{},"#, + r#""efficiency_classes":{},"caches":[{}],"outermost_partitioning_cache_level":{},"#, + r#""policies":{{{}}},"cross_check_ok":{}}}"# + ), + std::env::consts::ARCH, + observation.online_processors, + observation.groups, + observation.packages, + observation.numa_domains, + observation.memoryless_numa_domains, + observation.cores.len(), + classes.len(), + cache_json.join(","), + observation + .outermost_partitioning_cache() + .map_or("null".to_string(), |c| c.level.to_string()), + policy_json.join(","), + complaints.is_empty(), + ); +} diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index f809ab72..9f2cac64 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -116,6 +116,7 @@ pub mod error_mode; pub mod handle_state; pub mod ioring; pub mod pool_growth; +pub mod topology; pub mod worker_context; #[cfg(test)] diff --git a/crates/windows-platform-probes/src/tests.rs b/crates/windows-platform-probes/src/tests.rs index b716b9e8..2b0c5d11 100644 --- a/crates/windows-platform-probes/src/tests.rs +++ b/crates/windows-platform-probes/src/tests.rs @@ -693,3 +693,132 @@ fn the_impersonation_guard_reverts_even_while_unwinding() { "the thread must not have been left impersonating after the unwind" ); } + +// --- topology ------------------------------------------------------------- +// +// Every interesting number the topology probe prints is host-specific, so +// nothing here asserts a *value*. What is asserted is internal consistency, +// which must hold on any machine and therefore catches a parsing regression in +// `windows-topology-sys` on whatever hardware CI happens to run on -- which is +// the whole reason the probe reads the shipping crate rather than a second +// parse written here. + +#[test] +fn the_machine_reports_at_least_one_processor_one_group_and_one_core() { + let observation = crate::topology::measure().expect("topology discovery"); + + assert!( + observation.online_processors >= 1, + "a running process implies at least one online processor" + ); + assert!( + observation.groups >= 1, + "every machine has at least one processor group" + ); + assert!( + !observation.cores.is_empty(), + "a machine with processors must report cores" + ); + assert!( + observation.packages >= 1, + "every machine has at least one package" + ); +} + +#[test] +fn the_shipping_parse_agrees_with_the_raw_win32_counters() { + let observation = crate::topology::measure().expect("topology discovery"); + + // This is the cross-check that makes the probe worth running everywhere: a + // disagreement means windows-topology-sys parsed + // GetLogicalProcessorInformationEx differently from what the simple + // counters report on this host. + let complaints = observation.cross_check(); + assert!( + complaints.is_empty(), + "topology crate disagrees with the raw counters: {complaints:?}" + ); +} + +#[test] +fn every_core_reports_processors_and_smt_agrees_with_the_count() { + let observation = crate::topology::measure().expect("topology discovery"); + + for core in &observation.cores { + assert!( + core.processors >= 1, + "a core with no processors is a parse error, not a machine" + ); + assert_eq!( + core.simultaneous_multithreading, + core.processors > 1, + "SMT is exactly the condition of a core carrying more than one processor" + ); + } +} + +#[test] +fn every_cache_level_reports_at_least_one_domain_with_at_least_one_processor() { + let observation = crate::topology::measure().expect("topology discovery"); + + for cache in &observation.caches { + assert!( + cache.level >= 1, + "a cache level of zero is a parse error, not a machine" + ); + assert_eq!( + cache.domains, + cache.processors_per_domain.len(), + "the domain count must be the length of the per-domain spans" + ); + assert!( + cache.processors_per_domain.iter().all(|&span| span >= 1), + "a cache domain covering no processors is a parse error" + ); + } +} + +#[test] +fn the_outermost_partitioning_cache_is_the_deepest_level_that_splits_the_machine() { + let observation = crate::topology::measure().expect("topology discovery"); + + match observation.outermost_partitioning_cache() { + Some(chosen) => { + assert!( + chosen.domains > 1, + "a level that does not partition cannot be the partitioning level" + ); + // Nothing deeper may also partition, or the wrong one was chosen. + assert!( + observation + .caches + .iter() + .all(|other| other.level <= chosen.level || other.domains <= 1), + "a deeper cache level also partitions this machine, so the outermost \ + one was mis-selected" + ); + } + None => assert!( + observation.caches.iter().all(|cache| cache.domains <= 1), + "no level was chosen even though one partitions the machine" + ), + } +} + +#[test] +fn every_policy_would_produce_at_least_one_domain() { + let observation = crate::topology::measure().expect("topology discovery"); + + // The point of this one is the degenerate cases: a machine reporting zero + // NUMA nodes, or no cache that partitions, must still yield a usable domain + // count rather than zero. A fleet sized at zero domains does no I/O at all, + // and that is exactly the shape the ARM64 laptop in the 2026-08-30 session + // would have produced under a policy keyed literally on L3. + for (name, count) in observation.domain_counts() { + assert!( + count >= 1, + "policy {name} would produce {count} domains, and a fleet of zero \ + domains can perform no I/O" + ); + } +} diff --git a/crates/windows-platform-probes/src/topology.rs b/crates/windows-platform-probes/src/topology.rs new file mode 100644 index 00000000..880792b7 --- /dev/null +++ b/crates/windows-platform-probes/src/topology.rs @@ -0,0 +1,254 @@ +// Copyright (c) Mike Grier. + +//! What shape is the machine, and which cache level actually partitions it? +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! # Why this is a probe rather than a test +//! +//! Almost every number here is host-specific, so there is nothing to assert +//! about its *value* -- only about its internal consistency. That is the +//! binary-plus-asserted split this crate is built around: the binary prints the +//! shape for whoever is reading, and the tests pin the invariants that must hold +//! on any machine, so a parsing regression fails the build even though a core +//! count cannot. +//! +//! Running it in CI is the point. Hosted runners are a heterogeneous fleet, so +//! printing the discovered shape on every build turns ordinary CI into a slow +//! survey of what real machines look like -- including the negative result that +//! cloud runners are consistently single-node, which is itself evidence for how +//! the [uniform tunable architecture](../../../design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) +//! should size itself by default. +//! +//! # It measures the shipping crate, deliberately +//! +//! The parse comes from [`windows_topology_sys::Topology::discover`] rather +//! than from a reimplementation here, for the same reason the pool-growth probe +//! uses the real thread-pool crate: a reimplementation would measure the +//! reimplementation. The raw counters below are then read *independently* +//! through Win32 and compared against it, so this probe doubles as a +//! cross-check on that crate's parsing across every machine CI ever runs on. + +use std::io; + +use windows_sys::Win32::System::Threading::{ + ALL_PROCESSOR_GROUPS, GetActiveProcessorCount, GetActiveProcessorGroupCount, + GetNumaHighestNodeNumber, +}; + +use windows_topology_sys::{DomainKind, Topology}; + +/// One cache level, summarised across the machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheLevel { + /// 1, 2, 3, ... as the firmware reports it. + pub level: u8, + /// How many distinct domains exist at this level. + pub domains: usize, + /// Processors per domain, in discovery order. + pub processors_per_domain: Vec, +} + +/// One core, summarised. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CoreShape { + /// Whether this core carries more than one logical processor. + pub simultaneous_multithreading: bool, + /// The firmware's performance ranking for this core. More than one distinct + /// value across the machine means heterogeneous cores, and therefore that + /// an unconstrained thread can be scheduled onto a slow one. + pub efficiency_class: u8, + /// Logical processors this core covers. + pub processors: usize, +} + +/// The machine's shape, as the shipping topology crate sees it, plus the raw +/// counters read independently for cross-checking. +#[derive(Debug, Clone)] +pub struct Observation { + // --- read through windows-topology-sys --- + /// Logical processors reported as online. + pub online_processors: usize, + /// Processor groups. More than one is a hard affinity boundary: a thread's + /// affinity names exactly one group, so above 64 logical processors the + /// partition is forced whether or not it is wanted. + pub groups: usize, + /// NUMA domains, including any that report no processors. + pub numa_domains: usize, + /// NUMA domains that report no processors at all -- ordinary on machines + /// with CXL expanders or HBM tiers, and the reason a domain count cannot be + /// used as a thread count. + pub memoryless_numa_domains: usize, + /// Physical packages (sockets). + pub packages: usize, + /// Every physical core. + pub cores: Vec, + /// Cache levels, ascending, each summarised across the machine. + pub caches: Vec, + + // --- read independently through Win32 --- + /// `GetActiveProcessorCount(ALL_PROCESSOR_GROUPS)`. + pub raw_active_processors: u32, + /// `GetActiveProcessorGroupCount()`. + pub raw_group_count: u16, + /// `GetNumaHighestNodeNumber()`, or `None` if the call failed. + pub raw_highest_numa_node: Option, +} + +impl Observation { + /// The outermost cache level that actually splits the machine into more + /// than one domain, if any. + /// + /// This is the rule the design wants, and it is deliberately *not* "level + /// 3". A shipping ARM64 laptop measured during the 2026-08-30 session + /// reports **no L3 at all**, with two L2 domains of six processors forming + /// the real cluster boundary, which is why the heuristic is phrased over + /// "the outermost level that partitions" rather than over a fixed number. + #[must_use] + pub fn outermost_partitioning_cache(&self) -> Option<&CacheLevel> { + self.caches + .iter() + .filter(|c| c.domains > 1) + .max_by_key(|c| c.level) + } + + /// How many execution domains each candidate policy would produce. + /// + /// Reported rather than recommended. The point of printing all of them is + /// that they disagree, and the disagreement is the finding. + #[must_use] + pub fn domain_counts(&self) -> Vec<(&'static str, usize)> { + vec![ + ("single", 1), + ("by-package", self.packages), + ( + "by-numa-domain-with-processors", + self.numa_domains - self.memoryless_numa_domains, + ), + ( + "by-outermost-partitioning-cache", + self.outermost_partitioning_cache().map_or(1, |c| c.domains), + ), + ("by-core", self.cores.len()), + ] + } + + /// Whether the independently-read Win32 counters agree with what the + /// topology crate parsed. + /// + /// A disagreement is a real finding: it means the shipping crate's parse of + /// `GetLogicalProcessorInformationEx` diverges from what the simple + /// counters report on this machine. + #[must_use] + pub fn cross_check(&self) -> Vec { + let mut complaints = Vec::new(); + if self.online_processors != self.raw_active_processors as usize { + complaints.push(format!( + "online processors: topology crate says {}, GetActiveProcessorCount says {}", + self.online_processors, self.raw_active_processors + )); + } + if self.groups != self.raw_group_count as usize { + complaints.push(format!( + "groups: topology crate says {}, GetActiveProcessorGroupCount says {}", + self.groups, self.raw_group_count + )); + } + if let Some(highest) = self.raw_highest_numa_node + && self.numa_domains != highest as usize + 1 + { + complaints.push(format!( + "NUMA domains: topology crate says {}, GetNumaHighestNodeNumber implies {}", + self.numa_domains, + highest + 1 + )); + } + complaints + } +} + +/// Discover the machine's shape. +/// +/// # Errors +/// +/// Propagates a failure from [`Topology::discover`]. +pub fn measure() -> io::Result { + let topology = Topology::discover()?; + + let online_processors = topology.processors.iter().filter(|p| p.online).count(); + + let mut groups = 0usize; + let mut numa_domains = 0usize; + let mut memoryless_numa_domains = 0usize; + let mut packages = 0usize; + let mut cores = Vec::new(); + let mut by_level: Vec<(u8, Vec)> = Vec::new(); + + for domain in &topology.domains { + match &domain.kind { + DomainKind::Group => groups += 1, + DomainKind::Package => packages += 1, + DomainKind::Memory { .. } => { + numa_domains += 1; + if domain.processors.is_empty() { + memoryless_numa_domains += 1; + } + } + DomainKind::Core { + simultaneous_multithreading, + efficiency_class, + } => cores.push(CoreShape { + simultaneous_multithreading: *simultaneous_multithreading, + efficiency_class: *efficiency_class, + processors: domain.processors.len(), + }), + DomainKind::Cache { level, .. } => { + let count = domain.processors.len(); + match by_level.iter_mut().find(|(l, _)| l == level) { + Some((_, spans)) => spans.push(count), + None => by_level.push((*level, vec![count])), + } + } + _ => {} + } + } + + by_level.sort_by_key(|(level, _)| *level); + let caches = by_level + .into_iter() + .map(|(level, processors_per_domain)| CacheLevel { + level, + domains: processors_per_domain.len(), + processors_per_domain, + }) + .collect(); + + // SAFETY: both take no pointer arguments and cannot fail in a way that + // matters here; `ALL_PROCESSOR_GROUPS` is the documented way to ask for the + // machine-wide count. + let raw_active_processors = unsafe { GetActiveProcessorCount(ALL_PROCESSOR_GROUPS) }; + let raw_group_count = unsafe { GetActiveProcessorGroupCount() }; + + let mut highest = 0u32; + // SAFETY: `highest` is a live local for the duration of the call. + let raw_highest_numa_node = if unsafe { GetNumaHighestNodeNumber(&raw mut highest) } != 0 { + Some(highest) + } else { + None + }; + + Ok(Observation { + online_processors, + groups, + numa_domains, + memoryless_numa_domains, + packages, + cores, + caches, + raw_active_processors, + raw_group_count, + raw_highest_numa_node, + }) +} diff --git a/tools/run-numa-spikes.ps1 b/tools/run-numa-spikes.ps1 new file mode 100644 index 00000000..62b74f95 --- /dev/null +++ b/tools/run-numa-spikes.ps1 @@ -0,0 +1,160 @@ +# Copyright (c) 2026 Mike Grier. All rights reserved. +<# +.SYNOPSIS + Builds and runs the standalone NUMA spikes, capturing their output. + +.DESCRIPTION + The spikes under crates/windows-ioring-sys/design-sessions/spikes are + deliberately NOT workspace members: each is a single file written against + windows-sys alone, so that what it measures is the operating system's + behaviour and not ours. Their README documents the way to run one -- drop it + into a scratch binary crate with a single dependency -- and this script is + that procedure, automated. + + Running it in CI has a side effect worth having: it is the executable form + of that README instruction, so the instruction cannot rot without turning + the step red. + + ON A SINGLE-NODE MACHINE EVERY SPIKE HERE IS VACUOUS, and each says so in + its own output rather than printing a confident zero. That is expected. The + cost is a minute; the payoff is that if a multi-node runner ever appears, + the answer is already in that build's log. + + This script never fails on a spike's result. It exits non-zero only if a + spike fails to BUILD, which is a real defect in the instrument. + +.PARAMETER Summary + Optional path to append a rendered summary to, for $env:GITHUB_STEP_SUMMARY. + +.PARAMETER OutputDirectory + Where to write per-spike transcripts. Defaults to .scratch/numa-spikes. +#> +[CmdletBinding()] +param( + [string] $Summary, + [string] $OutputDirectory = (Join-Path $PSScriptRoot '..\.scratch\numa-spikes') +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') +$spikeDir = Join-Path $repoRoot 'crates\windows-ioring-sys\design-sessions\spikes' + +# name -> the windows-sys features that spike's own doc comment asks for. +$spikes = @( + @{ + Name = 'file-handle-numa' + File = 'file-handle-numa-spike.rs' + Features = @( + '"Win32_Foundation"', '"Win32_Security"', '"Win32_Storage_FileSystem"', + '"Win32_System_IO"', '"Win32_System_Ioctl"' + ) + Asks = 'Does a file handle name a NUMA node, and is it the volume''s or the file''s?' + }, + @{ + Name = 'thread-stack-numa' + File = 'thread-stack-numa-spike.rs' + Features = @( + '"Win32_Foundation"', '"Win32_Security"', '"Win32_System_Threading"', + '"Win32_System_SystemInformation"', '"Win32_System_ProcessStatus"' + ) + Asks = 'Does creation-time affinity govern where a thread''s stack lives?' + } +) + +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null +$buildFailures = 0 +$sections = New-Object System.Collections.Generic.List[string] + +foreach ($spike in $spikes) { + $source = Join-Path $spikeDir $spike.File + if (-not (Test-Path $source)) { + Write-Host "::warning::spike source missing: $source" + $buildFailures++ + continue + } + + $work = Join-Path ([System.IO.Path]::GetTempPath()) ("spike-" + $spike.Name + "-" + [guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Force -Path (Join-Path $work 'src') | Out-Null + + $features = $spike.Features -join ', ' + $manifest = @" +[package] +name = "spike_$($spike.Name -replace '-','_')" +version = "0.0.0" +edition = "2021" + +[dependencies] +windows-sys = { version = "0.61.2", default-features = false, features = [$features] } + +[workspace] +"@ + Set-Content -Path (Join-Path $work 'Cargo.toml') -Value $manifest -Encoding utf8 + Copy-Item $source (Join-Path $work 'src\main.rs') -Force + + Write-Host "=== building $($spike.Name) ===" + Push-Location $work + try { + $build = & cargo build --quiet 2>&1 + $buildExit = $LASTEXITCODE + if ($buildExit -ne 0) { + # A build failure is a defect in the instrument, and is the one + # thing here worth failing over. + Write-Host "::error::spike $($spike.Name) failed to build" + $build | ForEach-Object { Write-Host $_ } + $buildFailures++ + $sections.Add("### $($spike.Name)`n`n**FAILED TO BUILD** -- the instrument is broken, not the machine.`n") + continue + } + + Write-Host "=== running $($spike.Name) ===" + $output = & cargo run --quiet 2>&1 | Out-String + Write-Host $output + } + finally { + Pop-Location + Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue + } + + $transcript = Join-Path $OutputDirectory "$($spike.Name).txt" + Set-Content -Path $transcript -Value $output -Encoding utf8 + + $vacuous = $output -match 'VACUOUS' + $verdict = if ($vacuous) { + 'vacuous on this runner (single NUMA node) -- expected, and the spike said so itself' + } + else { + '**NOT vacuous -- this runner has more than one NUMA node. Read the output.**' + } + + $sections.Add(@" +### $($spike.Name) + +$($spike.Asks) + +$verdict + +`````` +$output +`````` +"@) +} + +if ($Summary) { + $header = @" +## NUMA spike results + +Observational. These never fail the build; a red step here means a spike failed +to **build**, which is a defect in the instrument rather than a finding about +the machine. + +"@ + Add-Content -Path $Summary -Value ($header + ($sections -join "`n")) +} + +if ($buildFailures -gt 0) { + Write-Host "::error::$buildFailures spike(s) failed to build" + exit 1 +} +Write-Host "all spikes built and ran" +exit 0 From d650e806b06d3a65a0ac59c6be353f53c3e1e341 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 16:54:20 -0400 Subject: [PATCH 010/361] chore: record the probes crate's new windows-topology-sys dependency in Cargo.lock Missed from eb0ffd8. CI builds with --locked, so an out-of-date lock would have failed every job rather than just this one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index be688efd..f6892ecc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,7 @@ version = "0.0.0" dependencies = [ "windows-sys", "windows-threadpool-sys", + "windows-topology-sys", ] [[package]] From f95d08a4e069e4649df1390f3ec955e4043d6928 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 17:03:28 -0400 Subject: [PATCH 011/361] docs: distinguish the client-facing queue from the cross-domain one The blocked/unblocked table said 'the cross-domain queue' is absent at N=1, which is true but sits beside the claim that the first deliverable depends on none of the blocked items. Read as 'all queues', that wrongly implies no queue work is needed for N=1. The client-facing SQ/CQ is needed at every size, because a foreign client thread must still reach the single domain, and it is on the N=1 critical path. Only the domain-to-domain queue is genuinely absent below two domains. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...N-2026-08-30-numa-sharded-io-execution-domains.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index d85265f2..4e060095 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -296,8 +296,16 @@ on a single-node, single-LLC machine one domain **is** the optimal partition. **What is genuinely additive above one domain**, and it is additive rather than a second mode: -- the cross-domain queue and its doorbell -- with one domain there is no peer to - message; +- the **cross-domain** queue and its doorbell -- with one domain there is no peer + to message. **This is not the client-facing queue**, and the distinction + matters enough to state plainly, because reading "the queue" as "all queues" + would wrongly suggest no queue work is needed for the first deliverable. + There are two roles: + - **client to domain (the SQ), and domain to client (the CQ)** -- needed at + **every** size including N=1, because a foreign client thread must still + reach the single domain. This is the two-layer ring, and it is on the N=1 + critical path. + - **domain to domain** -- genuinely absent below two domains. - the routing policy -- with one domain there is no choice to make, so the volume-to-node key has nothing to select. From 8f5636313eec04c02e48fc0f5ae3fc6f3f84db96 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 17:18:18 -0400 Subject: [PATCH 012/361] feat(probes): measure what a doorbell costs, and answer C-1 C-1 asked whether the two-layer ring's doorbell is expensive enough to justify an eventcount. Measured on the ARM64 development machine: atomic_fetch_add 7.2 ns set_event_already_signalled 81.2 ns set_reset_event 164.9 ns wait_zero_signalled 94.8 ns submit_io_ring_empty 79.2 ns park_and_wake round trip 2196.4 ns Answer: the skip-when-busy rule is a refinement, not a prerequisite. Batching alone settles it. One doorbell per drained batch costs 164.9 ns at a batch of one, 20.6 at eight, and 5.2 at thirty-two -- so at a batch of about 23 the doorbell already costs less per operation than the atomic push it accompanies. A first implementation can always-signal and stay honest, and the eventcount can wait for a measurement against real work to justify its lost-wakeup risk. That matters because publish-intent/re-check/park is the highest-risk protocol in the design, and this says we do not have to buy that risk up front. Two things this probe got wrong before it got them right, both recorded rather than quietly fixed: - **It deadlocked.** The first park-and-wake used one thread calling SetEvent in a loop against another calling WaitForSingleObject(INFINITE). An auto-reset event does not count signals, so two arriving before one wait collapse into one, the waiter's count never catches up, and it blocks for ever. It hung for over four hundred seconds before being killed. Now a two-event ping-pong forces strict alternation, and every wait is bounded, because a probe that can hang is a probe that can hang a build. It returns None on timeout rather than averaging a partial run. - **Its headline ratio was built on a denominator that is not a syscall.** The probe was written to divide the doorbell cost by an empty SubmitIoRing and report "the doorbell is N% of a syscall". That yielded 210%, which should have been the tell: 79 ns is far too cheap for a kernel transition, so an empty submit is almost certainly short-circuiting in user mode. The verdict logic keyed on that ratio has been removed rather than tuned, and both the module documentation and doorbell_share_of_submit now carry the warning -- the original text claimed "a small measured share is trustworthy", which is reasoning the measurement itself retracted. The probe now reports absolute costs and the batching arithmetic, and refuses to form the ratio, because the honest denominator is the cost of the real work a submission carries and this does not measure it. Wired into the platform-probes CI job, so the numbers accumulate across the runner fleet alongside the topology probe, and emits an x-probe-doorbell-cost JSON line for mining. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 7 + crates/windows-platform-probes/Cargo.toml | 4 + .../src/bin/doorbell_cost.rs | 118 ++++++++ .../src/doorbell_cost.rs | 263 ++++++++++++++++++ crates/windows-platform-probes/src/lib.rs | 1 + 5 files changed, 393 insertions(+) create mode 100644 crates/windows-platform-probes/src/bin/doorbell_cost.rs create mode 100644 crates/windows-platform-probes/src/doorbell_cost.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7ff6940..a13bdd45 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -179,6 +179,13 @@ jobs: # of logs mechanically rather than read by eye. - name: probe magnitudes (topology) run: cargo run -p windows-platform-probes --bin probe-topology --locked + # Decides how much machinery the two-layer ring's doorbell needs. Its + # park-and-wake handshake is bounded rather than INFINITE on purpose: the + # first version of it deadlocked, because an auto-reset event does not + # count signals and the waiter's count never caught up. A probe that can + # hang is a probe that can hang a build. + - name: probe magnitudes (doorbell cost) + run: cargo run -p windows-platform-probes --bin probe-doorbell-cost --locked # The NUMA questions the 2026-08-30 design session could not answer, run # against whatever machine the runner fleet supplies. diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index ad473e72..b905370b 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -47,6 +47,10 @@ path = "src/bin/ioring.rs" name = "probe-pool-growth" path = "src/bin/pool_growth.rs" +[[bin]] +name = "probe-doorbell-cost" +path = "src/bin/doorbell_cost.rs" + [[bin]] name = "probe-topology" path = "src/bin/topology.rs" diff --git a/crates/windows-platform-probes/src/bin/doorbell_cost.rs b/crates/windows-platform-probes/src/bin/doorbell_cost.rs new file mode 100644 index 00000000..e5a2f110 --- /dev/null +++ b/crates/windows-platform-probes/src/bin/doorbell_cost.rs @@ -0,0 +1,118 @@ +// Copyright (c) Mike Grier. + +//! Prints how expensive a doorbell is relative to the syscall it would guard. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! This decides whether the two-layer ring design needs an eventcount at all. +//! If a doorbell is a meaningful fraction of `SubmitIoRing`, the skip-when-busy +//! rules are load-bearing. If it is noise, a simple always-signal queue is +//! adequate and the more delicate protocol -- publish intent, re-check, park -- +//! can wait for evidence that it is worth its lost-wakeup risk. + +use windows_platform_probes::doorbell_cost::{measure, measure_park_and_wake}; + +fn main() { + println!("== what does a doorbell cost, against the syscall it guards? ==\n"); + + let observation = measure(); + + println!("{:<30} {:>12}", "operation", "ns/op"); + for timing in &observation.timings { + println!("{:<30} {:>12.1}", timing.label, timing.nanos_per_op); + } + + let park = measure_park_and_wake(20_000); + match park { + Some(ns) => println!("{:<30} {:>12.1}", "park_and_wake round trip", ns), + None => println!("{:<30} {:>12}", "park_and_wake round trip", "TIMED OUT"), + } + + println!("\ninterpretation:"); + + if let Some(atomic) = observation.get("atomic_fetch_add") + && let Some(doorbell) = observation.get("set_reset_event") + && atomic > 0.0 + { + println!( + " a doorbell cycle costs {:.0}x an uncontended atomic ({:.0} ns vs {:.1} ns).", + doorbell / atomic, + doorbell, + atomic + ); + if let Some(park) = park { + println!( + " an actual park-and-wake round trip costs {:.0}x that again ({:.0} ns),", + park / doorbell, + park + ); + println!(" which is what is paid when the consumer genuinely sleeps."); + } + } + + // Deliberately NOT expressed as a share of the empty submit. See below. + if let Some(submit) = observation.submit_nanos { + println!("\n CAUTION: an empty SubmitIoRing measured {submit:.0} ns, which is far too"); + println!(" cheap for a kernel transition -- it is almost certainly short-"); + println!(" circuiting in user mode when there is nothing queued. It is"); + println!(" therefore NOT a fair denominator, and any 'doorbell is N% of a"); + println!(" syscall' figure derived from it would be a confident wrong answer."); + println!(" The honest denominator is the cost of the real work a submission"); + println!(" carries, which this probe does not measure."); + } + + // What can be said without a denominator: how much batching it takes for + // the doorbell to disappear, which is the lever the design actually has. + if let Some(doorbell) = observation.get("set_reset_event") + && let Some(atomic) = observation.get("atomic_fetch_add") + && atomic > 0.0 + { + println!("\n batching is the lever, and it is a strong one. One doorbell per"); + println!(" drained batch costs, per operation:"); + for batch in [1_u32, 8, 32, 128] { + println!( + " batch of {batch:>4}: {:>7.1} ns/op ({:.1}x an atomic)", + doorbell / f64::from(batch), + doorbell / f64::from(batch) / atomic + ); + } + let break_even = (doorbell / atomic).ceil() as u32; + println!(" so at a batch of about {break_even}, the doorbell costs less per"); + println!(" operation than the atomic push it accompanies."); + } + + println!("\n => The skip-when-busy rule is a refinement, not a prerequisite."); + println!(" Batching alone drives the doorbell below the cost of the push,"); + println!(" so a first implementation can always-signal and stay honest."); + println!(" Adopt the eventcount when a measurement against real work"); + println!(" justifies its lost-wakeup risk -- not before."); + + let atomic = observation.get("atomic_fetch_add").unwrap_or(f64::NAN); + let already = observation + .get("set_event_already_signalled") + .unwrap_or(f64::NAN); + let cycle = observation.get("set_reset_event").unwrap_or(f64::NAN); + let wait0 = observation.get("wait_zero_signalled").unwrap_or(f64::NAN); + println!( + concat!( + r#"{{"reason":"x-probe-doorbell-cost","arch":"{}","atomic_ns":{:.1},"#, + r#""set_event_already_signalled_ns":{:.1},"set_reset_event_ns":{:.1},"#, + r#""wait_zero_signalled_ns":{:.1},"park_and_wake_round_trip_ns":{},"#, + r#""submit_io_ring_empty_ns":{},"doorbell_share_of_submit":{}}}"# + ), + std::env::consts::ARCH, + atomic, + already, + cycle, + wait0, + park.map_or("null".to_string(), |n| format!("{n:.1}")), + observation + .submit_nanos + .map_or("null".to_string(), |n| format!("{n:.1}")), + observation + .doorbell_share_of_submit() + .map_or("null".to_string(), |s| format!("{s:.4}")), + ); +} diff --git a/crates/windows-platform-probes/src/doorbell_cost.rs b/crates/windows-platform-probes/src/doorbell_cost.rs new file mode 100644 index 00000000..e4c5e3e6 --- /dev/null +++ b/crates/windows-platform-probes/src/doorbell_cost.rs @@ -0,0 +1,263 @@ +// Copyright (c) Mike Grier. + +//! How expensive is a doorbell, relative to the syscall it would guard? +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! # The decision this exists to inform +//! +//! The two-layer ring design has a client thread push a descriptor onto a +//! bounded MPSC queue and then, sometimes, signal an event so the domain thread +//! wakes. The design assumes that signal is expensive enough to be worth +//! avoiding, and proposes an eventcount -- publish intent to park, re-check the +//! queue, then wait -- so a producer rings the doorbell only on the +//! empty-to-non-empty edge and only when a consumer is actually parked. +//! +//! That protocol is the highest-risk part of the whole design, because +//! publish-recheck-park is exactly where lost wakeups live. Building it because +//! the cost was *assumed* would be taking on that risk without evidence. So: +//! +//! - if `SetEvent` is a meaningful fraction of `SubmitIoRing`, the skip rules +//! are load-bearing and belong in the design from the start; +//! - if it is noise, a simple always-signal queue is adequate and the +//! optimization can wait for a measurement that justifies it. +//! +//! # What is timed +//! +//! Each is a tight loop over a warm path, reported as nanoseconds per +//! operation. Absolute values are host-specific and uninteresting; the +//! **ratios** are the finding. +//! +//! - `atomic_fetch_add` -- the uncontended atomic that a queue push costs, +//! as a floor for "the cheapest useful thing". +//! - `set_event_already_signalled` -- `SetEvent` on an event that is already +//! set, which is the redundant-signal case the skip +//! rule removes. +//! - `set_reset_event` -- `SetEvent` then `ResetEvent`, the honest cost of +//! one doorbell cycle with nobody waiting. +//! - `wait_zero_signalled` -- `WaitForSingleObject(handle, 0)` on a signalled +//! event: the consumer's cost of observing it. +//! - `submit_io_ring_empty` -- `SubmitIoRing` with nothing queued, which is +//! the syscall the doorbell would be amortised +//! against. Absent when `IoRing` is unavailable. +//! +//! # The empty submit is not a fair denominator, and the first run proved it +//! +//! This probe was written expecting to divide the doorbell cost by +//! `submit_io_ring_empty` and read off "the doorbell is N% of a syscall". **Do +//! not do that.** Measured on the development machine, an empty `SubmitIoRing` +//! came in at ~79 ns -- far too cheap for a kernel transition, so it is almost +//! certainly short-circuiting in user mode when there is nothing queued. The +//! resulting "doorbell is 210% of a syscall" would have been a confident wrong +//! answer built on a denominator that never entered the kernel. +//! +//! The honest denominator is the cost of the real work a submission carries, +//! which this probe deliberately does not measure -- so it reports the absolute +//! costs and the *batching* arithmetic instead, and leaves the ratio alone. +//! [`Observation::doorbell_share_of_submit`] is retained only because the raw +//! fact is worth recording; its own documentation repeats this warning. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Instant; + +use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0}; +use windows_sys::Win32::System::Threading::{ + CreateEventW, ResetEvent, SetEvent, WaitForSingleObject, +}; + +use crate::ioring; + +/// Nanoseconds per operation for one timed loop. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Timing { + /// What was timed. + pub label: &'static str, + /// Iterations executed. + pub iterations: u32, + /// Nanoseconds per iteration. + pub nanos_per_op: f64, +} + +/// Every timing, plus the ratios that actually decide the design question. +#[derive(Debug, Clone)] +pub struct Observation { + /// Each timed loop, in the order run. + pub timings: Vec, + /// `None` when `IoRing` is unavailable on this host. + pub submit_nanos: Option, +} + +impl Observation { + /// Look a timing up by label. + #[must_use] + pub fn get(&self, label: &str) -> Option { + self.timings + .iter() + .find(|t| t.label == label) + .map(|t| t.nanos_per_op) + } + + /// One doorbell cycle as a fraction of one **empty** `SubmitIoRing`. + /// + /// **This is not the number the design turns on, and it should not be read + /// as one.** An empty submit does not appear to enter the kernel (see the + /// module documentation), so this ratio has a denominator that is not a + /// syscall. It is exposed because the raw fact is worth recording across + /// hosts -- a machine where the empty submit is *expensive* would itself be + /// a finding -- not because dividing by it answers anything. + #[must_use] + pub fn doorbell_share_of_submit(&self) -> Option { + let doorbell = self.get("set_reset_event")?; + let submit = self.submit_nanos?; + (submit > 0.0).then_some(doorbell / submit) + } +} + +fn time_loop(label: &'static str, iterations: u32, mut body: impl FnMut()) -> Timing { + // Warm the path first: the first call through a syscall stub pays for + // resolution and page faults that a steady-state cost should not include. + for _ in 0..1024 { + body(); + } + let start = Instant::now(); + for _ in 0..iterations { + body(); + } + let elapsed = start.elapsed(); + Timing { + label, + iterations, + nanos_per_op: elapsed.as_nanos() as f64 / f64::from(iterations), + } +} + +/// Run every timing. +/// +/// # Panics +/// +/// Panics if `CreateEventW` fails, which would mean the host cannot create a +/// manual-reset event and nothing here is measurable. +#[must_use] +pub fn measure() -> Observation { + const ITERATIONS: u32 = 200_000; + + // SAFETY: a manual-reset, initially-unsignalled, unnamed event. + let event: HANDLE = unsafe { CreateEventW(std::ptr::null(), 1, 0, std::ptr::null()) }; + assert!(!event.is_null(), "CreateEventW failed"); + + let counter = AtomicU64::new(0); + let mut timings = Vec::new(); + + timings.push(time_loop("atomic_fetch_add", ITERATIONS, || { + counter.fetch_add(1, Ordering::Relaxed); + })); + + // Leave it signalled, so every call in the next loop is redundant. + unsafe { SetEvent(event) }; + timings.push(time_loop("set_event_already_signalled", ITERATIONS, || { + unsafe { SetEvent(event) }; + })); + + unsafe { ResetEvent(event) }; + timings.push(time_loop("set_reset_event", ITERATIONS, || unsafe { + SetEvent(event); + ResetEvent(event); + })); + + unsafe { SetEvent(event) }; + timings.push(time_loop("wait_zero_signalled", ITERATIONS, || { + unsafe { WaitForSingleObject(event, 0) }; + })); + unsafe { + ResetEvent(event); + CloseHandle(event); + } + + // The syscall the doorbell would be amortised against. Far fewer + // iterations: this one is a real kernel transition. `submit_and_wait(0)` + // asks for no completions, so it returns without blocking and measures the + // transition rather than any I/O. + let submit_nanos = ioring::Ring::new().map(|ring| { + const SUBMIT_ITERATIONS: u32 = 20_000; + let timing = time_loop("submit_io_ring_empty", SUBMIT_ITERATIONS, || { + let _ = ring.submit_and_wait(0); + }); + timings.push(timing); + timing.nanos_per_op + }); + + Observation { + timings, + submit_nanos, + } +} + +/// Keeps the doorbell's own wake path honest: a consumer that actually parks +/// and is woken measures something the zero-timeout poll above does not. +/// +/// Reported separately because it is a two-thread measurement and therefore +/// noisier than the single-threaded loops. The number is a full **round trip** +/// -- wake the peer, park, be woken -- not a single transition, so it is an +/// upper bound on what one wakeup costs rather than the cost itself. +/// +/// # Why the handshake alternates strictly +/// +/// The obvious version -- one thread calling `SetEvent` in a loop while the +/// other calls `WaitForSingleObject` -- **deadlocks**, and did when this probe +/// was first written. An auto-reset event does not count signals: two arriving +/// before one wait collapse into one, the waiter's count never catches up, and +/// it blocks on `INFINITE` for ever. Two events used as ping and pong force +/// strict alternation, so no signal can be lost. +/// +/// Every wait is nevertheless bounded. A probe that can hang is a probe that +/// can hang a build, and the deadlock above is exactly how that happens; a +/// timeout turns it into a reported anomaly instead. +/// +/// Returns `None` if the handshake ever timed out, because a partial run's +/// average would be meaningless. +#[must_use] +pub fn measure_park_and_wake(rounds: u32) -> Option { + const WAIT_TIMEOUT_MS: u32 = 5_000; + + // SAFETY: two auto-reset, initially-unsignalled, unnamed events. + let ping: HANDLE = unsafe { CreateEventW(std::ptr::null(), 0, 0, std::ptr::null()) }; + let pong: HANDLE = unsafe { CreateEventW(std::ptr::null(), 0, 0, std::ptr::null()) }; + assert!(!ping.is_null() && !pong.is_null(), "CreateEventW failed"); + + let (ping_addr, pong_addr) = (ping as usize, pong as usize); + let peer = std::thread::spawn(move || { + let (ping, pong) = (ping_addr as HANDLE, pong_addr as HANDLE); + for _ in 0..rounds { + // SAFETY: both handles outlive this thread, which is joined below. + let waited = unsafe { WaitForSingleObject(ping, WAIT_TIMEOUT_MS) }; + if waited != WAIT_OBJECT_0 { + return false; + } + unsafe { SetEvent(pong) }; + } + true + }); + + let mut ok = true; + let start = Instant::now(); + for _ in 0..rounds { + // SAFETY: both handles are live for the whole loop. + unsafe { SetEvent(ping) }; + if unsafe { WaitForSingleObject(pong, WAIT_TIMEOUT_MS) } != WAIT_OBJECT_0 { + ok = false; + break; + } + } + let elapsed = start.elapsed(); + + let peer_ok = peer.join().unwrap_or(false); + // SAFETY: the peer has been joined, so nothing else holds these. + unsafe { + CloseHandle(ping); + CloseHandle(pong); + } + + (ok && peer_ok).then(|| elapsed.as_nanos() as f64 / f64::from(rounds)) +} diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index 9f2cac64..9b2da045 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -112,6 +112,7 @@ pub mod cancel_io; pub mod completion_port; pub mod device_map; +pub mod doorbell_cost; pub mod error_mode; pub mod handle_state; pub mod ioring; From c5e01b890232c23746bbc31cc82d899d73a4b935 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 17:33:26 -0400 Subject: [PATCH 013/361] docs: record the C-1 measurement and amend the requirements it contradicts Batching, not the skip rule, is the lever: one doorbell per drained batch costs 164.9 ns at a batch of one and 5.2 at thirty-two, so at about 23 it already costs less per operation than the atomic push it accompanies. A first implementation may always-signal. R3 mandated an eventcount and R4 mandated the skip rules. Both are amended rather than left standing beside a measurement that contradicts them, and the amendment separates the free skip (queue already non-empty, needs no knowledge of the consumer) from the one C-1 says to defer (consumer publishes whether it is parked). Publish-recheck-park is the highest-risk protocol here and nothing yet shows it is worth its lost-wakeup risk. Also records the two mistakes the probe made before it was right -- a deadlock from an auto-reset event not counting signals, and a headline ratio whose denominator was an empty SubmitIoRing that never enters the kernel -- and names what is still unmeasured: the doorbell against a submission carrying real I/O, which is what would justify adopting the eventcount. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...08-30-numa-sharded-io-execution-domains.md | 81 ++++++++++++++++--- 1 file changed, 70 insertions(+), 11 deletions(-) diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index 4e060095..42f93418 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -507,9 +507,54 @@ reaching the ring, where a run-to-completion client would have none. reduces syscalls rather than adding them. - **It should be pay-for-what-you-use**: a client whose continuation runs on the domain thread submits directly, with no queue and no doorbell. -- **Measurable now**, on this hardware, with no infrastructure: time `SetEvent`, - an uncontended atomic push, and a `SubmitIoRing` round trip. If `SetEvent` is - a few percent of the syscall, the hop is noise. +### C-1 measured: batching settles it, and the eventcount can wait + +Measured on the ARM64 development machine by +`probe-doorbell-cost`, now part of the platform-probes CI job so the numbers +accumulate across the runner fleet. + +| operation | ns/op | +|---|---| +| `atomic_fetch_add` | 7.2 | +| `set_event_already_signalled` | 81.2 | +| `set_reset_event` (one doorbell cycle) | 164.9 | +| `wait_zero_signalled` | 94.8 | +| `submit_io_ring_empty` | 79.2 | +| park and wake, round trip | 2196.4 | + +**The finding is that batching, not the skip rule, is the lever.** One doorbell +per drained batch costs 164.9 ns at a batch of one, 20.6 at eight, and 5.2 at +thirty-two -- so at a batch of about **23 the doorbell already costs less per +operation than the atomic push it accompanies**. A first implementation may +always-signal and remain honest. + +So **the eventcount is deferred, not adopted.** Publish-recheck-park is the +highest-risk protocol in this design, and nothing yet shows it is worth its +lost-wakeup risk. R3 and R4 are amended accordingly. + +**Two mistakes in the probe are recorded because they nearly produced findings:** + +- **It deadlocked.** The first park-and-wake had one thread calling `SetEvent` + in a loop against another in `WaitForSingleObject(INFINITE)`. An auto-reset + event does not count signals, so two arriving before one wait collapse into + one, the waiter's count never catches up, and it blocks for ever -- it hung + for over four hundred seconds before being killed. A probe that can hang is a + probe that can hang a build. It now uses a two-event ping-pong for strict + alternation, bounds every wait, and returns nothing at all on timeout rather + than averaging a partial run. +- **Its headline ratio had a denominator that is not a syscall.** The probe was + written to divide the doorbell cost by an empty `SubmitIoRing` and report + "the doorbell is N% of a syscall". It reported **210%**, which should have + been read as a broken denominator rather than a result: 79 ns is far too cheap + for a kernel transition, so an empty submit is almost certainly + short-circuiting in user mode. The verdict logic was deleted rather than + tuned. The honest denominator is the cost of the real work a submission + carries, which this probe does not measure -- so it reports absolute costs and + the batching arithmetic and forms no ratio. + +**Still unmeasured, and worth naming:** the doorbell's cost against a submission +carrying real I/O. That is the number that would justify adopting the +eventcount, and it needs a workload rather than a microbenchmark. ### C-1a Why the doorbell must be a HANDLE, and cannot be `WaitOnAddress` @@ -733,17 +778,31 @@ backpressure** -- an unbounded queue has none, which is why `SegQueue` was the wrong model. **R3 Lock-free producers.** No mutex on the producer path. A producer-side lock -serializes precisely what multi-producer exists to parallelize. Park and notify -go through an **eventcount**: the consumer publishes intent to park, re-checks -the queue, and only then waits. That re-check closes the lost-wakeup gap without -a lock. +serializes precisely what multi-producer exists to parallelize. + +**Park and notify may start as an unconditional signal.** An earlier form of +this requirement mandated an **eventcount** -- consumer publishes intent to +park, re-checks the queue, then waits -- as the way to close the lost-wakeup gap +without a lock. **C-1 measured that and the mandate does not survive**: batching +alone drives the doorbell below the cost of the atomic push it accompanies, so a +first implementation may always-signal. The eventcount stays in the design as a +*later* step, adopted against a measurement of real work rather than up front, +because publish-recheck-park is the highest-risk protocol here and its +lost-wakeup risk should be bought only once something has shown it is worth +paying for. **R4 Doorbell.** A queue-owned **manual-reset event**, created **lazily** so a polling-only consumer allocates no kernel object. Level semantics: signalled exactly when the consumer has something to observe. **The reset is atomic with -the emptiness observation; the signal may be outside any lock** (see C-1b). The -signal is *skipped* when the queue was already non-empty, or when the consumer -is not parked. Handed out as a borrowed handle plus an owned duplicate. +the emptiness observation; the signal may be outside any lock** (see C-1b). +Handed out as a borrowed handle plus an owned duplicate. + +Skipping the signal when the queue was already non-empty, or when the consumer +is not parked, is an **optimization rather than a requirement** -- see R3 and +C-1. The skip that costs nothing and can be taken immediately is the +already-non-empty one, which needs no knowledge of the consumer's state; the +one that needs the consumer to publish whether it is parked is the part C-1 says +to defer. **R5 Wakeup safety.** No lost wakeups. Spurious wakeups are permitted, and the consumer must tolerate them. Drain to empty on every pass. @@ -953,7 +1012,7 @@ and must not be allowed to blur into them. | the MPSC, eventcount, and doorbell (R1-R10 is pure concurrency) | whether the FSCTL names a *meaningful* volume node (F-1) | | the two-layer ring and the client-facing API shape | Q6, whether a Storage Space reports honestly or reports a fiction | | one-shot registration semantics (already established) | Q7, whether creation-time affinity yields a node-local stack | -| the C-1 doorbell measurement (`SetEvent` against `SubmitIoRing`) | the *magnitude* of the buffer-placement benefit | +| ~~the C-1 doorbell measurement~~ -- **done**, see "C-1 measured" above | the *magnitude* of the buffer-placement benefit | | the composed layer's type-level traversal | domain-count tuning above one | | the durability crate, whose mechanism was already measured as D-23/D-24 | | | whether `CreateRemoteThreadEx` with an attribute list works at all | | From a32cd3274df48ef3c6a5a7fa126b433143f762a6 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 17:51:33 -0400 Subject: [PATCH 014/361] feat(probes): measure request construction, and scope both microbenchmarks honestly The engineer identified that windows-namespace-request-sys already solves the R7 contradiction. It does, and better than the workaround proposed earlier in the session: OpenFile is Send (verified by compile-time assertion), owns its PreparedPath, and carries a CapturedHandle that is already a *duplicate*, so nothing borrowed crosses the hop. The queue carries an owned request by value and the "write the path into a buffer slot" scheme is unnecessary -- that crate was built for exactly this. R7 is amended rather than satisfied. "POD descriptors, no allocation on push" cannot hold for a payload owning a heap string, so it splits: the slot stays fixed-size and POD, the payload is an owned value moved in. The property that mattered survives -- no allocation inside the push, no lifetime hazard. Measured on ARM64: prepare_short_path 534.7 ns prepare_long_path 1593.0 ns build_open_request 452.7 ns clone_prepared_units 95.3 ns capture_handle 282.5 ns Two costs that a memory-focused reading of "what does an SQE hold" would miss: - prepare is a Win32 call, not an allocation. It invokes GetFullPathNameW, because the namespace session settled that the path resolves at submission -- the process CWD is mutable by any thread, so resolving later would be racy. No allocator change removes it. Cloning already-prepared units at 95.3 ns bounds what an inline-storage or recycling scheme could recover, and only for a caller that can reuse a resolved path. - A handle must be duplicated, at 282.5 ns, which the engineer raised. That is a kernel transition, not a memory copy. Corrects an over-reading this probe made in its first form, and sweeps the same error out of the C-1 record beside it. Both measurements are per-operation overheads of single uncontended operations; neither establishes anything about queue efficiency. Two distinctions the numbers must not be stretched across: - Per-operation overhead is not throughput. Contention, cache traffic, batching amortization and behaviour at capacity decide whether a queue is good, and a single push or SetEvent shows none of them. - One operation type is not the operation mix. A namespace open is the heaviest payload the queue carries; a registered-buffer read is the lightest and is the hot path, where the descriptor is a slot index and an offset and the queue's mechanics are the whole per-operation cost. So the probe no longer claims "the queue's mechanics are not where the time goes". What it supports is narrower and still useful: for an open-heavy workload, doorbell tuning would be optimizing the small half of the cost. That is a finding about operation mix. Wired into the platform-probes CI job with an x-probe-request-cost JSON line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 4 + Cargo.lock | 2 + crates/windows-platform-probes/Cargo.toml | 8 + .../src/bin/request_cost.rs | 136 +++++++++++++ crates/windows-platform-probes/src/lib.rs | 1 + .../src/request_cost.rs | 178 ++++++++++++++++++ ...08-30-numa-sharded-io-execution-domains.md | 83 +++++++- 7 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 crates/windows-platform-probes/src/bin/request_cost.rs create mode 100644 crates/windows-platform-probes/src/request_cost.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a13bdd45..a7190335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -186,6 +186,10 @@ jobs: # hang is a probe that can hang a build. - name: probe magnitudes (doorbell cost) run: cargo run -p windows-platform-probes --bin probe-doorbell-cost --locked + # Read with the doorbell probe above: together they say whether the + # queue's mechanics or the request's own cost deserves the attention. + - name: probe magnitudes (request cost) + run: cargo run -p windows-platform-probes --bin probe-request-cost --locked # The NUMA questions the 2026-08-30 design session could not answer, run # against whatever machine the runner fleet supplies. diff --git a/Cargo.lock b/Cargo.lock index f6892ecc..7df419b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,9 +228,11 @@ dependencies = [ name = "windows-platform-probes" version = "0.0.0" dependencies = [ + "windows-namespace-request-sys", "windows-sys", "windows-threadpool-sys", "windows-topology-sys", + "wtf-string", ] [[package]] diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index b905370b..ca687452 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -51,6 +51,10 @@ path = "src/bin/pool_growth.rs" name = "probe-doorbell-cost" path = "src/bin/doorbell_cost.rs" +[[bin]] +name = "probe-request-cost" +path = "src/bin/request_cost.rs" + [[bin]] name = "probe-topology" path = "src/bin/topology.rs" @@ -64,6 +68,10 @@ windows-threadpool-sys = { version = "0.1.3", path = "../windows-threadpool-sys" # a second parse written here, which would only measure itself. The raw Win32 # counters it cross-checks against are read independently through windows-sys. windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys" } +# The request-cost probe measures the real request types the design would put on +# a queue, not a stand-in, for the same reason. +windows-namespace-request-sys = { version = "0.2.0", path = "../windows-namespace-request-sys" } +wtf-string = { version = "0.1.0", path = "../wtf-string" } [dependencies.windows-sys] version = "0.61.2" diff --git a/crates/windows-platform-probes/src/bin/request_cost.rs b/crates/windows-platform-probes/src/bin/request_cost.rs new file mode 100644 index 00000000..f70a9574 --- /dev/null +++ b/crates/windows-platform-probes/src/bin/request_cost.rs @@ -0,0 +1,136 @@ +// Copyright (c) Mike Grier. + +//! Prints what a namespace request costs to build, against the queue that would +//! carry it. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! Read alongside `probe-doorbell-cost`: together they say whether the queue's +//! mechanics or the request's allocation model deserves the attention. + +use windows_platform_probes::request_cost::measure; + +/// Measured by `probe-doorbell-cost` on the same machine. Restated here only to +/// render a ratio; the authoritative number is whatever that probe prints on +/// the host this runs on. +const DOORBELL_NS_REFERENCE: f64 = 164.9; +const ATOMIC_NS_REFERENCE: f64 = 7.2; + +fn main() { + println!("== what does a namespace request cost to build? ==\n"); + + let observation = measure(); + + println!( + "{:<26} {:>10} {:>14} {:>16}", + "operation", "ns/op", "x an atomic", "x a doorbell" + ); + for timing in &observation.timings { + println!( + "{:<26} {:>10.1} {:>14.1} {:>16.2}", + timing.label, + timing.nanos_per_op, + timing.nanos_per_op / ATOMIC_NS_REFERENCE, + timing.nanos_per_op / DOORBELL_NS_REFERENCE, + ); + } + println!( + "\n(ratios use the reference doorbell {DOORBELL_NS_REFERENCE:.1} ns and atomic \ + {ATOMIC_NS_REFERENCE:.1} ns measured\n by probe-doorbell-cost on the development \ + machine; re-read that probe on this host\n before trusting them)" + ); + + println!("\ninterpretation:"); + + let build = observation.get("build_open_request"); + let capture = observation.get("capture_handle"); + + if let Some(build) = build { + println!( + " building a pathed request costs {build:.0} ns, which is {:.1}x one", + build / DOORBELL_NS_REFERENCE + ); + println!(" doorbell."); + println!(); + println!(" SCOPE, because this is easy to over-read: that is a statement about"); + println!(" ONE OPERATION TYPE, not about the queue. A namespace open is the"); + println!(" heaviest payload the queue carries -- it resolves a path through"); + println!(" Win32 and may duplicate a handle -- and it ends in a CreateFileW"); + println!(" costing microseconds regardless. A registered-buffer read, which is"); + println!(" the hot path, carries no path and no handle: its descriptor is a slot"); + println!(" index and an offset, and there the queue's own mechanics are the"); + println!(" whole cost."); + println!(); + println!(" Nor is per-operation overhead the same thing as queue efficiency."); + println!(" Throughput under contention, cache behaviour, batching amortization"); + println!(" and backpressure decide that, and a single uncontended construction"); + println!(" time measures none of them."); + println!(); + println!(" What it does support: for an open-heavy workload, doorbell tuning"); + println!(" would be optimizing the small half. That is a finding about"); + println!(" OPERATION MIX, and it says nothing about the read path."); + } + + if let Some(capture) = capture { + println!("\n duplicating a handle costs {capture:.0} ns -- a kernel transition, not"); + println!(" a memory copy, and easy to under-count when thinking about what an"); + println!(" SQE holds."); + if let Some(build) = build { + if capture > build { + println!( + " It is {:.1}x the cost of building the pathed request itself, so a", + capture / build + ); + println!(" request carrying a handle is dominated by the duplication, and"); + println!(" any allocation tuning on the path would be optimizing the wrong"); + println!(" half."); + } else { + println!( + " It is {:.2}x the pathed request, so the two are comparable and", + capture / build + ); + println!(" neither dominates."); + } + } + } + + // The split that decides whether an allocator change can help at all. + if let Some(build) = build + && let Some(clone) = observation.get("clone_prepared_units") + && build > clone + { + println!("\n WHERE THE TIME ACTUALLY GOES, and it is not the allocator:"); + println!(" `prepare` calls GetFullPathNameW to resolve the path against the"); + println!(" process working directory -- a Win32 call, because the CWD is mutable"); + println!(" by any thread and resolving later would be racy. So most of the cost"); + println!(" above is a syscall that no allocation scheme can remove."); + println!(" Cloning already-prepared units is {clone:.0} ns, which bounds what an"); + println!( + " inline-storage or recycling scheme could recover at {:.0} ns per request", + build - clone + ); + println!(" AT MOST -- and only for a caller that can reuse a resolved path."); + println!(" A caller with a fresh path each time pays the resolution regardless."); + } + + let get = |label: &str| { + observation + .get(label) + .map_or("null".to_string(), |n| format!("{n:.1}")) + }; + println!( + concat!( + r#"{{"reason":"x-probe-request-cost","arch":"{}","prepare_short_ns":{},"#, + r#""prepare_long_ns":{},"build_open_request_ns":{},"#, + r#""clone_prepared_units_ns":{},"capture_handle_ns":{}}}"# + ), + std::env::consts::ARCH, + get("prepare_short_path"), + get("prepare_long_path"), + get("build_open_request"), + get("clone_prepared_units"), + get("capture_handle"), + ); +} diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index 9b2da045..f62c5005 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -117,6 +117,7 @@ pub mod error_mode; pub mod handle_state; pub mod ioring; pub mod pool_growth; +pub mod request_cost; pub mod topology; pub mod worker_context; diff --git a/crates/windows-platform-probes/src/request_cost.rs b/crates/windows-platform-probes/src/request_cost.rs new file mode 100644 index 00000000..c6f1b1cf --- /dev/null +++ b/crates/windows-platform-probes/src/request_cost.rs @@ -0,0 +1,178 @@ +// Copyright (c) Mike Grier. + +//! What does it cost to build a namespace request, against the queue that would +//! carry it? +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! # The decision this exists to inform +//! +//! The two-layer ring's submission queue was specified to carry **POD +//! descriptors with no allocation on push**. A deferred `CreateFileW` carries a +//! path, and a path is neither fixed-size nor POD, so that requirement and the +//! namespace plane's needs cannot both hold as written. +//! +//! `windows-namespace-request-sys` already solves the hard part: an `OpenFile` +//! is an *owned, `Send`* parameter set, built on one thread and performed +//! faithfully on another. So the queue can carry a request by value and the +//! lifetime hazard disappears. What remains is a cost question about **this +//! operation type**: how does building one compare with the doorbell that would +//! carry it (~165 ns, per `probe-doorbell-cost`)? +//! +//! # What this does not measure, stated because the number invites over-reading +//! +//! **This says nothing about whether the queue is efficient.** It measures the +//! construction cost of the queue's *heaviest* payload. Two distinctions the +//! result must not be stretched across: +//! +//! - **Operation type.** A namespace open resolves a path through Win32 and +//! may duplicate a handle. A registered-buffer read -- the hot path -- does +//! neither: its descriptor is a slot index and an offset, and there the +//! queue's own mechanics are the whole per-operation cost. +//! - **Overhead against efficiency.** Throughput under contention, the ring's +//! cache behaviour, batching amortization, and backpressure under load are +//! what make a queue good or bad. A single uncontended construction time +//! measures none of them. +//! +//! The conclusion it *does* support is about **operation mix**: for an +//! open-heavy workload, effort spent shaving the doorbell would be spent on the +//! small half of the cost. +//! +//! # Handle duplication is the part that is easy to under-count +//! +//! A request that carries a handle -- a template handle for an open, or the +//! subject of a query -- must **duplicate** it, because the submitting thread +//! may close its own copy the moment it returns. `CapturedHandle::capture` does +//! that with `DuplicateHandle`, which is a kernel transition, not a memory +//! copy. So "what does a request cost" is not only an allocation question, and +//! measuring only the path would understate it. +//! +//! # Preparing a path is a Win32 call, not an allocation +//! +//! This probe was written expecting `prepare` to be an allocation and a copy. +//! It is not: it calls **`GetFullPathNameW`** to resolve the path against the +//! process working directory, because [the namespace session] settled that the +//! path is resolved at submission -- the process CWD is mutable by any thread, +//! so even perfect remoting would be racy. +//! +//! That means the measured cost is a *syscall* cost and cannot be tuned away by +//! an allocator. An inline-storage or recycling scheme would only recover the +//! allocation part, which `clone_prepared_units` bounds from below. Knowing +//! which half is which is the point of measuring both. +//! +//! [the namespace session]: ../../../design-sessions/DESIGN-SESSION-2026-08-27-pseudo-async-namespace-operations.md +//! +//! Each timing is reported per operation. Absolute values are host-specific; +//! the **ratios against the doorbell and the atomic** are the finding. + +use std::time::Instant; + +use wtf_string::Wtf16String; + +use windows_namespace_request_sys::{CapturedHandle, OpenFile, prepare}; + +/// Nanoseconds per operation for one timed loop. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Timing { + /// What was timed. + pub label: &'static str, + /// Iterations executed. + pub iterations: u32, + /// Nanoseconds per iteration. + pub nanos_per_op: f64, +} + +/// Every timing taken by [`measure`]. +#[derive(Debug, Clone)] +pub struct Observation { + /// Each timed loop, in the order run. + pub timings: Vec, +} + +impl Observation { + /// Look a timing up by label. + #[must_use] + pub fn get(&self, label: &str) -> Option { + self.timings + .iter() + .find(|t| t.label == label) + .map(|t| t.nanos_per_op) + } +} + +fn time_loop(label: &'static str, iterations: u32, mut body: impl FnMut() -> T) -> Timing { + // Warm the path: the first pass pays for lazily resolved syscall stubs and + // for the allocator's first touch of a fresh size class. + for _ in 0..256 { + std::hint::black_box(body()); + } + let start = Instant::now(); + for _ in 0..iterations { + std::hint::black_box(body()); + } + let elapsed = start.elapsed(); + Timing { + label, + iterations, + nanos_per_op: elapsed.as_nanos() as f64 / f64::from(iterations), + } +} + +/// Time request construction, path preparation, and handle duplication. +/// +/// # Panics +/// +/// Panics if the fixed test paths fail to prepare, which would mean +/// `prepare` rejects an ordinary absolute path and nothing here is meaningful. +#[must_use] +pub fn measure() -> Observation { + const ITERATIONS: u32 = 100_000; + const HANDLE_ITERATIONS: u32 = 50_000; + + let short = Wtf16String::from(r"C:\Windows\System32\kernel32.dll"); + let long_text = format!(r"C:\{}\file.txt", vec!["directory"; 24].join("\\")); + let long = Wtf16String::from(long_text.as_str()); + + let mut timings = Vec::new(); + + // The allocation and normalization a path costs, at two lengths, because + // the common case and the worst case allocate differently. + timings.push(time_loop("prepare_short_path", ITERATIONS, || { + prepare(&short).expect("an absolute path prepares") + })); + timings.push(time_loop("prepare_long_path", ITERATIONS, || { + prepare(&long).expect("an absolute path prepares") + })); + + // A whole request, which is a prepared path plus the builder chain. This is + // what the queue would actually carry. + timings.push(time_loop("build_open_request", ITERATIONS, || { + let path = prepare(&short).expect("an absolute path prepares"); + OpenFile::new(path) + .with_desired_access(0x8000_0000) + .with_share_mode(1) + .with_creation_disposition(3) + })); + + // Cloning the prepared path alone, which is what a request-recycling scheme + // would avoid paying. + let prepared_units = prepare(&short) + .expect("an absolute path prepares") + .into_wtf16(); + timings.push(time_loop("clone_prepared_units", ITERATIONS, || { + prepared_units.clone() + })); + + // The kernel transition a captured handle costs. Measured against a handle + // this process already owns, so nothing here depends on the filesystem. + let file = + std::fs::File::open(r"C:\Windows\System32\kernel32.dll").expect("kernel32.dll is readable"); + let borrowed = std::os::windows::io::AsHandle::as_handle(&file); + timings.push(time_loop("capture_handle", HANDLE_ITERATIONS, || { + CapturedHandle::capture(borrowed).expect("duplicating an owned handle") + })); + + Observation { timings } +} diff --git a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md index 42f93418..28b4f9d5 100644 --- a/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md +++ b/design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md @@ -556,6 +556,72 @@ lost-wakeup risk. R3 and R4 are amended accordingly. carrying real I/O. That is the number that would justify adopting the eventcount, and it needs a workload rather than a microbenchmark. +### What these microbenchmarks do not establish + +Both C-1 and the request-cost measurement below are **per-operation overheads +of single, uncontended operations**. That is not the same thing as queue +efficiency, and the distinction is worth stating because both numbers invite the +same over-reading: + +- **Per-operation overhead is not throughput.** What makes a queue good or bad + is its behaviour under contention, the cache traffic of its ring, how well + batching amortises, and how it behaves at capacity. None of that is visible in + a single push or a single `SetEvent`. +- **One operation type is not the operation mix.** A namespace open is the + heaviest payload the queue carries; a registered-buffer read is the lightest, + and it is the hot path. A conclusion drawn from either says nothing about the + other. + +So neither measurement licenses a claim of the form "the queue's mechanics do +not matter". What C-1 supports is narrow and still useful: **batching alone +makes the doorbell cheap enough that the eventcount need not be bought up +front.** + +### The request's cost, and what it says about operation mix + +Measured on the same machine by `probe-request-cost`: + +| operation | ns/op | x a doorbell | +|---|---|---| +| `prepare_short_path` | 534.7 | 3.2 | +| `prepare_long_path` | 1593.0 | 9.7 | +| `build_open_request` | 452.7 | 2.7 | +| `clone_prepared_units` | 95.3 | 0.6 | +| `capture_handle` | 282.5 | 1.7 | + +**The queue can carry an owned request, and the R7 contradiction dissolves.** +`OpenFile` is `Send` (verified by compile-time assertion), owns its +`PreparedPath`, and carries a `CapturedHandle` that is already a *duplicate* -- +so nothing borrowed crosses the hop and no lifetime outlives the submitting +thread. The design does not need the "write the path into a buffer slot" scheme +proposed earlier in this session; the namespace crate was built for exactly this +and already solves it. + +**R7 needs amending rather than satisfying.** "POD descriptors, no allocation on +push" cannot hold for a payload that owns a heap string. Split it: the queue's +*slot* stays fixed-size and POD (tag, correlation id, index), while the +*payload* is an owned request the client allocated before pushing. The property +that mattered survives -- no allocation inside the push, and no lifetime hazard. + +**Two costs that are easy to under-count:** + +- **`prepare` is a Win32 call, not an allocation.** It invokes + `GetFullPathNameW`, because the namespace session settled that the path is + resolved at submission -- the process CWD is mutable by any thread, so + resolving later would be racy. No allocator change removes that. Cloning + already-prepared units costs 95.3 ns, which *bounds* what an inline-storage or + recycling scheme could recover, and only for a caller that can reuse a + resolved path. +- **A handle must be duplicated**, at 282.5 ns -- a kernel transition, not a + memory copy. Raised by the engineer, and it is the part a "what does an SQE + hold" analysis focused on memory would miss entirely. + +**Scope:** this is a statement about **operation mix**, not about the queue. For +an open-heavy workload, doorbell tuning would be optimizing the small half of +the cost. It says nothing about the registered-buffer read path, where the +descriptor is a slot index and an offset and the queue's mechanics are the whole +per-operation cost. + ### C-1a Why the doorbell must be a HANDLE, and cannot be `WaitOnAddress` `WaitOnAddress` is plausibly cheaper in isolation. It is still unusable here, @@ -811,9 +877,20 @@ consumer must tolerate them. Drain to empty on every pass. and **sized by the topology** -- generous when a domain owns a core exclusively, zero when it shares one with the rest of a laptop. -**R7 Payload.** POD descriptors only, never bytes: operation, target, buffer -slot index, offset, user tag. **No allocation on push.** Carrying bytes would -mean copying out of the registered pool, defeating the reason to register. +**R7 Payload.** Two parts, because an earlier single-sentence form of this +requirement ("POD descriptors only, no allocation on push") could not hold for a +namespace open, whose payload owns a heap-allocated path: + +- **The slot is fixed-size and POD**: operation, correlation id, buffer slot + index, offset, user tag. +- **The payload may be an owned value moved in** -- an `OpenFile` from + [windows-namespace-request-sys](../crates/windows-namespace-request-sys/README.md), + which is `Send`, owns its `PreparedPath`, and holds a *duplicated* handle. + +**No allocation inside the push**, and no borrowed lifetime crossing the hop: +the client allocates before pushing, on its own thread, where blocking and +failing are both acceptable. Never bytes -- carrying bytes would mean copying +out of the registered pool, defeating the reason to register. **R8 Shutdown.** The consumer learns when all producers are gone; producers learn when the consumer is gone and fail with a typed error. Descriptors in From bb4b24858aa74aff19646fe12ff33af565fbec96 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 19:06:06 -0400 Subject: [PATCH 015/361] docs: form the io-domains checklist (M30-M32 specified, M33+ parked) Transcribes the session's converged decisions into work items, per the rule that design notes are not a work queue: a decision recorded only in a session record is orphaned because nothing causes it to be picked up. M30-M32 are specified and depend on no NUMA hardware: the queue crate (SPSC, bounded-array MPSC, the lazily created manual-reset doorbell whose reset is atomic with the emptiness observation), then the three contract decisions the runtime cannot be written without. M33+ is parked rather than pending, because ordering, correlation and backpressure are open in the session record and writing runtime code around them would promote an open question to a settled one by implication. M-inf holds three items each gated on a specific measurement rather than on taste: the extra MPSC shapes on M31.5's contention benchmark, the eventcount on a doorbell measurement against real I/O, and the PreparedPath allocation change on the 95-of-453-ns ceiling already measured. Numbered M30+ after checking for collisions: CHECKLIST-thread-ambient.md runs to M29, not M27 as its own header prose claims, so the first draft's M28-M29 collided with it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 173 ++++++++++++++++++++++++++++++++++++++++ PLANS.md | 1 + 2 files changed, 174 insertions(+) create mode 100644 CHECKLIST-io-domains.md diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md new file mode 100644 index 00000000..5539804d --- /dev/null +++ b/CHECKLIST-io-domains.md @@ -0,0 +1,173 @@ +# Checklist: NUMA-sharded I/O execution domains + +Feature-scoped checklist for the `mikegrier/deferred-namespace-ops` branch. It covers a new queue crate, +a domain runtime, a durability layer, and extensions to three existing crates, so it lives at the +workspace root -- their lowest common source-component -- rather than inside any one of them. Per the +naming convention for feature files, it is deleted outright once every item is complete, with the content +moved to [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md). + +Authoritative decisions are in [DESIGN-NOTES.md](DESIGN-NOTES.md); the session that produced them is +[DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md), +which is **still open**. Milestone numbers continue the workspace sequence: [CHECKLIST.md](CHECKLIST.md) +holds M19-M21 and [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) held M22-M27. + +## What is ready, and what deliberately is not + +**M30-M32 are specified.** The queue crate's requirements (R1-R10, as amended by the C-1 and +request-cost measurements) are settled, its shapes are chosen, and its boundary is decided. None of it +depends on NUMA hardware, and all of it is testable with no ring, no pool, and no I/O. + +**M33+ is parked, not pending.** The domain runtime cannot be written until M32 settles three contract +questions -- ordering, correlation, and backpressure -- that would change its shape. Those are decision +items in M32 rather than assumptions baked into M33+, because the session recorded them as open and +promoting an open question to a settled one by writing code around it is exactly the failure this +sequencing avoids. + +**The N=1 path is the whole of the first deliverable.** A single execution domain needs no routing +policy, no cross-domain queue, and no placement choice, so nothing below is blocked on the multi-node +hardware the session could not obtain. What N>1 adds is additive, not a second mode. + +## M30 -- The queue crate: name, skeleton, and the SPSC shape + +- [ ] **M30.1** -- Decide the crate's name and record why, **before** anything depends on it, because + renaming a crate that has dependents is churn this repository avoids. Two things to settle together: + whether the `-sys` suffix applies (every existing `windows-*-sys` crate is thin-over-Win32, and this is + a data structure with an opinion, so it probably does not), and the name of the domain runtime crate + that will sit above it, since the pair should read as a pair. Candidates raised: `windows-io-queue`, + `windows-signalled-queue`, `windows-queue`. Record the decision in [DESIGN-NOTES.md](DESIGN-NOTES.md) + so the reasoning survives the choice. + +- [ ] **M30.2** -- Create the crate with `publish = true` (the engineer's decision: this is general-purpose + and worth publishing, unlike `windows-guard-alloc`), and write its `DESIGN-NOTES.md` with the decisions + the session already reached: the shape menu and which shapes ship now, the + concrete-types-plus-optional-trait rule, the overflow policy, and the doorbell invariant. This is the + Tier-1 transcription of Tier-3 session content -- design notes are not a work queue, so a decision that + lives only in the session record is orphaned. + +- [ ] **M30.3** -- The SPSC bounded ring, with no doorbell and no Win32 at all: a pure data structure with + acquire/release head and tail and no CAS on either side. It is the CQ direction (R1), and it is first + because everything harder is a variation on it. Tests are ordinary fast unit tests -- capacity edges, + wraparound, full and empty, and that a `pop` never observes a partially written `T`. + +- [ ] **M30.4** -- The doorbell, as its own reviewable unit: a queue-owned **manual-reset** event created + **lazily**, so a polling-only consumer allocates no kernel object. Level semantics -- signalled exactly + when the consumer has something to observe. **The reset must be atomic with the observation that there + is nothing to take; the signal need not be** (C-1b measured why: a late signal is a spurious wakeup, a + stale reset is a lost one). Hand it out as a borrowed handle plus an owned duplicate, per the + file-watcher's precedent. + +- [ ] **M30.5** -- Join the two, and **sabotage-verify the lost-wakeup guard**: a test that reverses the + reset and the emptiness check must deadlock, and must stop deadlocking when the order is restored. A + wakeup invariant asserted only by a passing test is a test of nothing -- this is the same discipline + the ioring crate's `wait_then_drain` and the M17.4 calibration established. + +## M31 -- The MPSC shape and the queue's contract + +- [ ] **M31.1** -- The bounded array MPSC: Vyukov's sequence protocol, where a producer CASes the tail + forward, writes, then publishes by storing the slot's sequence. Lock-free rather than wait-free, bounded + by construction so backpressure is free, and no allocation anywhere. Pad the head and tail onto separate + cache lines and say so in a comment, because the padding is load-bearing and looks like waste. + +- [ ] **M31.2** -- Overflow policy, which is more than "return `Err`". Ship fail-fast plus a `reserve` + that guarantees a slot for a message that must not be lost, following + [queue.rs](crates/windows-file-watcher/src/queue.rs), which already carries three policies including a + **coalesced loss latch** the consumer is guaranteed to observe. **Never offer overwrite-oldest**: for + telemetry that is a lost sample, but for an I/O submission it is a lost operation, and the two must not + share a policy knob. + +- [ ] **M31.3** -- Shutdown in both directions: the consumer learns when every producer is gone, and a + producer learns when the consumer is gone and fails with a typed error. Descriptors in flight at + teardown are **accounted, not dropped** -- some own handles, and their disposal must be allowed to + block, which is the hazard the namespace session flagged for undrained completions. + +- [ ] **M31.4** -- Observability (R9): depth, high-water, and **a count of doorbells actually rung**. That + last one is what makes the skip rule measurable rather than assumed, and sabotage-verifiable -- disabling + the skip must move the number. + +- [ ] **M31.5** -- The contention benchmark that decides whether the deferred shapes are needed: N producer + threads pushing, throughput against N. **This is the item that either justifies or kills the linked and + sharded MPSC shapes**, and it is deliberately a measurement rather than a judgement, for the same reason + C-1 was. If the tail CAS does not contend at realistic producer counts, the array queue is the only MPSC + this crate ever needs. + + Record the result either way -- a measurement that says "the simple thing is fine" is worth as much as + one that does not, and is the cheaper outcome to lose track of. + +## M32 -- Contracts the runtime cannot be written without + +These are decision items, not implementation. Each is open in the session record, and each would change +the runtime's shape, so all three land before M33+ begins. + +- [ ] **M32.1** -- **The ordering guarantee.** Open since the 2026-08-27 namespace session, which + observed that `DeleteFile(X)` then `CreateFile(X)` on a pool does not execute in order and said the + contract "must state this explicitly rather than let it fall out of the implementation". A + single-consumer SQ gives per-domain FIFO *for free* -- the question is whether it is **promised**. + Promising it constrains every future implementation; withholding it makes composition harder for a + client that has an ordering requirement and no other way to express one. Decide, and state the + guarantee in the queue's own documentation rather than leaving it as an artifact. + +- [ ] **M32.2** -- **Correlation.** Who mints the tag that joins a submission to its completion, and how + it survives the two-layer translation into the ring's own `user_data`. Constraints already established: + `IoRing` mints `user_data` starting at **0** on a fresh ring, and `Token::claim_if` requires both + `user_data` **and** `RingId` to match. The client-facing tag is therefore not the ring's tag, and the + mapping between them is state the domain owns. + +- [ ] **M32.3** -- **Backpressure behaviour.** R2 says a full queue fails, and that failure is the + backpressure. But a client with nowhere to go either spins or drops, so decide whether a blocking submit + exists -- and if it does, **what it blocks on**, because a blocking submit that cannot be composed into + a `WaitForMultipleObjects` reintroduces exactly the wait-composition problem that ruled out crossbeam. + +- [ ] **M32.4** -- Transcribe the session's converged decisions from Tier 3 into Tier 1, and record the + ones this checklist rests on in [DESIGN-NOTES.md](DESIGN-NOTES.md): the uniform tunable architecture, + report-don't-route, the domain runtime not being a thread pool, the rejection of round-robin, and the + two-layer ring. **A decision recorded only in a session record steers nothing**, and this checklist is + the mechanism that makes them binding. + +> **-> CROSS-COMPONENT HANDOFF:** M33+ below spans `crates/windows-thread-ambient-sys`, +> `crates/windows-namespace-request-sys`, and `crates/windows-ioring-sys`. Each has its own +> `CHECKLIST.md`; the items are held here until M32 settles, then move to the component that owns them. + +## M33+ -- The domain runtime (gated on M32) + +Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` convention. + +- [ ] **M33+.1** -- The domain: one pinned thread, its `IoRing`, its node-local registered pool, its shard. + N=1 first and complete on its own; N>1 adds routing and a cross-domain queue without disturbing it. + +- [ ] **M33+.2** -- The thread builder, into + [windows-thread-ambient-sys](crates/windows-thread-ambient-sys/README.md): construct a thread with + `PROC_THREAD_ATTRIBUTE_GROUP_AFFINITY` set **at creation**, because a stack is allocated then and + binding afterwards cannot move it. Plus `bind_current_thread` with a restore guard for threads the + client did not create. **Its principal justification is unverified** -- see + [thread-stack-numa-spike.rs](crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs), + which is written and smoke-tested but needs multi-node hardware. If it comes back showing creation-time + affinity does *not* govern stack placement, this item shrinks to the binder alone. + +- [ ] **M33+.3** -- Extend [windows-namespace-request-sys](crates/windows-namespace-request-sys/README.md) + so an `Outcome` can carry the volume-node hint and its provenance alongside the handle, which is the + "report, don't route" primitive. + +- [ ] **M33+.4** -- The `threadpool` feature: a client-side helper for multiplexing more CQ doorbells than + `WaitForMultipleObjects` accepts, using `ThreadpoolWait` (kernel-side wait completion packets, so wide + waits cost the dispatch hop rather than a thread per 64). **Default-off and at the edge** -- a domain + waits on three handles and never approaches the limit, so the dependency belongs to whoever multiplexes. + +- [ ] **M33+.5** -- The durability layer as its own crate: **composition with shared vocabulary, not + derivation.** It contains a domain and submits through it; it re-exports `Op` and `Completion` where the + concept is genuinely the same, and defines `Epoch` and its own commit types where it adds meaning. + Carry one constraint from the start: the flush barrier stops at the ring's edge, so **an epoch is + per-domain** and a client spanning two domains needs two flushes and an explicit join. + +## M-inf -- Ungated + +- [ ] **M-inf.1** -- The linked and sharded MPSC shapes, if and only if M31.5 shows the array queue's tail + CAS contends at realistic producer counts. + +- [ ] **M-inf.2** -- The eventcount, if and only if a measurement against real I/O shows the doorbell + costs enough to be worth its lost-wakeup risk. C-1 showed batching alone drives it below the atomic push + it accompanies, so nothing currently justifies it. + +- [ ] **M-inf.3** -- An allocation-model change to `PreparedPath` (inline storage or request recycling). + Bounded before anyone builds it: `prepare` is dominated by `GetFullPathNameW`, a Win32 call no allocator + removes, and cloning already-prepared units is 95 ns of a 453 ns request. That 95 ns is the ceiling on + the win, and only for a caller that can reuse a resolved path. diff --git a/PLANS.md b/PLANS.md index edd2e01b..5d103928 100644 --- a/PLANS.md +++ b/PLANS.md @@ -16,6 +16,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| +| [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | not started | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset is atomic with the emptiness observation), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | not started | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | From a7dd979046e10fac74da879fbfd3f49d5531d157 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 19:15:51 -0400 Subject: [PATCH 016/361] docs(io-domains): name the queue crate windows-waitable-queues Completed item: M30.1: Decide the crate's name and record why, before anything depends on it. The engineer proposed the plural, and it is right for a reason stronger than taste: windows-platform-probes is already plural, so this workspace distinguishes singular-for-one-facility from plural-for-a-collection-of-peers. SPSC and MPSC are siblings and neither is canonical, so this is the second kind. No -sys suffix, because every windows-*-sys crate here is thin-over-Win32 and this one chooses a slot protocol, an overflow policy and a signalling discipline that Win32 has no equivalent of. The suffix is the only signal a reader has for how much a crate decides on their behalf. windows-io-queue, which I floated one turn earlier, is rejected in the record rather than dropped: the queues have nothing to do with I/O and the domain runtime is merely their first consumer. What unifies the family is waitability -- the property crossbeam structurally cannot offer, since its Select accepts only channel operations and so can neither be seen by WaitForMultipleObjects nor see an IoRing completion event. 'Waitable' is not coined for this; windows-threadpool-sys already owns it via WaitableHandle. Records one accepted consequence: the plural forbids a bare Queue type, because a crate named queues that exported one would claim a primacy the name denies. And narrows the item as written -- the runtime crate's name is deliberately not fixed, since the churn argument applies to a crate with dependents and the runtime does not exist until M33+. The pairing rule is recorded instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 18 +++++++++++++++++- DESIGN-NOTES.md | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 5539804d..3abdbafe 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -29,13 +29,29 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m ## M30 -- The queue crate: name, skeleton, and the SPSC shape -- [ ] **M30.1** -- Decide the crate's name and record why, **before** anything depends on it, because +- [x] **M30.1** -- Decide the crate's name and record why, **before** anything depends on it, because renaming a crate that has dependents is churn this repository avoids. Two things to settle together: whether the `-sys` suffix applies (every existing `windows-*-sys` crate is thin-over-Win32, and this is a data structure with an opinion, so it probably does not), and the name of the domain runtime crate that will sit above it, since the pair should read as a pair. Candidates raised: `windows-io-queue`, `windows-signalled-queue`, `windows-queue`. Record the decision in [DESIGN-NOTES.md](DESIGN-NOTES.md) so the reasoning survives the choice. + **Decided: `windows-waitable-queues`**, no `-sys` suffix, recorded in + [DESIGN-NOTES.md](DESIGN-NOTES.md#the-waitable-queues-crate-is-named-plural-and-carries-no-sys-suffix). + The engineer proposed the plural and it is right for a reason stronger than taste: + [windows-platform-probes](crates/windows-platform-probes/README.md) is already plural, so the workspace + distinguishes singular-for-one-facility from plural-for-a-collection-of-peers, and this is the second + kind. **`windows-io-queue`, floated during the same discussion, was rejected** -- the queues have + nothing to do with I/O, and naming a general facility after its first consumer is the mistake + `windows-topology-sys` avoided. What unifies them is waitability, a word this workspace already owns + through `WaitableHandle`. + **One consequence accepted deliberately:** the plural forbids a bare `Queue` type, since a crate named + "queues" exporting one would claim a primacy the name denies. Every type is specifically named and a + consumer must say which it wants. + **The runtime crate's name is deliberately NOT fixed here**, which narrows this item as written. The + churn argument applies to a crate with dependents, and M30.2 creates the queue crate immediately while + the runtime does not exist until M33+. The rule is recorded instead -- the pair should read as a pair, + and the runtime's name may carry `io` because that crate genuinely is about I/O. - [ ] **M30.2** -- Create the crate with `publish = true` (the engineer's decision: this is general-purpose and worth publishing, unlike `windows-guard-alloc`), and write its `DESIGN-NOTES.md` with the decisions diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index 01cdd64b..af3eb6ae 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -85,6 +85,48 @@ within a consumer's reach, and if not, what would put it there". weighing future proposals, not a change to existing code, so the absence of a checklist item for it is intentional rather than an oversight. +## `windows-waitable-queues` is plural, and carries no `-sys` suffix + +Two naming decisions for the queue crate that +[CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30 introduces, recorded before anything +depends on the name, because renaming a crate that has dependents is churn. + +**No `-sys` suffix.** Every `windows-*-sys` crate in this workspace is thin-over-Win32: it makes +an existing API memory-safe without adding policy. This crate is a data structure with an +opinion -- it chooses a slot protocol, an overflow policy, and a signalling discipline that Win32 +has no equivalent of. Calling it `-sys` would misdescribe the layer, and the suffix is the only +signal a reader has for how much the crate decides on their behalf. + +**Plural, because it is a collection of peers rather than one facility.** The workspace already +draws this distinction: `windows-topology-sys` is singular because it provides *the* topology, +while [windows-platform-probes](crates/windows-platform-probes/README.md) is plural because it +provides many probes with none of them canonical. The queue crate is the second kind. SPSC and +MPSC are siblings, and the shapes deferred to `M-inf` -- intrusive-linked and sharded -- are +siblings too. No single queue is the queue. + +**The consequence is accepted deliberately: there is no bare `Queue` type.** A crate named +"queues" that exported one would be claiming a primacy the name denies, so every type is +specifically named (`SpscRing`, `MpscRing`) and a consumer must say which it wants. That stops a +default from accreting by accident, which is the failure the plural is chosen to prevent. + +**What unifies the family is waitability, not I/O.** An earlier candidate, `windows-io-queue`, +was rejected on this point: the queues themselves have nothing to do with I/O, and the domain +runtime is merely their first consumer. Naming a general facility after its first client is the +mistake `windows-topology-sys` avoided when it declined to become a partitioning policy. What +every queue here shares is that it can be waited on **alongside other kernel objects**, which is +precisely what `crossbeam-channel` structurally cannot offer: its `Select` accepts only channel +operations, so `WaitForMultipleObjects` cannot see a crossbeam channel and `Select` cannot see an +`IoRing` completion event. That gap is the reason to build rather than depend, so it belongs in +the name. + +"Waitable" is chosen over a coined word because this workspace already owns it with a precise +meaning -- `windows-threadpool-sys` exposes `WaitableHandle`, governed by the two decisions +[below](#a-safe-wait-constructor-takes-proven-wait-provenance-not-any-handle) on wait provenance +and close routines. The new crate inherits an established concept rather than inventing one. + +**"Ring" was considered and is wrong for the family.** It is accurate for the array shapes and +false for the intrusive-linked one, which is genuinely not a ring. `queues` covers both. + ## Windows SDK model and constraints This crate targets the object-based thread pool API (introduced in Windows Vista) rather than the legacy From 755b157715d336ab246dc768f9c4711c4ca7e3f3 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 19:18:30 -0400 Subject: [PATCH 017/361] docs(io-domains): anticipate the WaitableQueue trait, and bind its constraint The engineer's point: the shapes might aspirationally all implement a WaitableQueue trait. That is not in tension with the plural naming decision, and the previous wording could have been read as forbidding it -- a type named Queue among peers claims primacy, while a trait names the contract those peers share and claims none. The substantive part is procedural: anticipating a trait constrains the concrete types now rather than adding to them later. If one shape ships pop(&mut self) -> Option and another ships try_pop(&self) -> Result, no trait unifies them afterwards without breaking one. That surfaces a trap worth settling before the first type exists. SPSC is conventionally a split Producer/Consumer pair while a shared MPMC queue is conventionally one Arc with &self on both ends, and no trait spans those structures. Making every shape split-handle resolves it and buys more than uniformity: cardinality becomes a compile-time guarantee carried by whether each handle is Clone, rather than a documented precondition. An SPSC producer that cannot be cloned cannot become a second producer -- the same discipline RingScope and get(&mut self) established elsewhere in this workspace. M30.3 now carries the constraint explicitly, since it writes the first signatures and a mistake there is not local. M30.2 gains the question it must answer first: whether WaitableQueue is one consumer-side trait or a producer/consumer pair, since waitability lives on the consumer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 15 +++++++++++++++ DESIGN-NOTES.md | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 3abdbafe..ce610a9e 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -60,11 +60,26 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m Tier-1 transcription of Tier-3 session content -- design notes are not a work queue, so a decision that lives only in the session record is orphaned. + **Settle one question here that M30.3 then depends on:** whether `WaitableQueue` is a single + consumer-side trait, or a producer trait and a consumer trait. Waitability lives on the consumer -- it + waits, while the producer merely rings -- so a single trait would be consumer-side and the producer's + contract would go unnamed. Decide rather than defer, because M30.3 writes the first signatures against + whichever answer this gives. + - [ ] **M30.3** -- The SPSC bounded ring, with no doorbell and no Win32 at all: a pure data structure with acquire/release head and tail and no CAS on either side. It is the CQ direction (R1), and it is first because everything harder is a variation on it. Tests are ordinary fast unit tests -- capacity edges, wraparound, full and empty, and that a `pop` never observes a partially written `T`. + **This item sets the shape every later queue must match**, so it is where the trait-compatibility + constraint binds: split producer and consumer handles, with cardinality carried by whether each is + `Clone` (see + [DESIGN-NOTES.md](DESIGN-NOTES.md#the-waitable-queues-crate-is-named-plural-and-carries-no-sys-suffix)). + Getting this wrong is not a local mistake -- if the first shape ships a signature the second cannot + match, the `WaitableQueue` trait becomes a breaking change to one of them rather than an addition. + Verify it the cheap way: write the trait's method signatures down as a comment before writing the + type, and confirm the type satisfies them. + - [ ] **M30.4** -- The doorbell, as its own reviewable unit: a queue-owned **manual-reset** event created **lazily**, so a polling-only consumer allocates no kernel object. Level semantics -- signalled exactly when the consumer has something to observe. **The reset must be atomic with the observation that there diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index af3eb6ae..7d768bdd 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -109,6 +109,42 @@ siblings too. No single queue is the queue. specifically named (`SpscRing`, `MpscRing`) and a consumer must say which it wants. That stops a default from accreting by accident, which is the failure the plural is chosen to prevent. +**A `WaitableQueue` *trait* is the opposite case, and is anticipated.** The rule above forbids a +bare `Queue` *type*, and the distinction matters: a type named `Queue` sitting among peers claims +to be the one that matters, while a trait names the contract those peers *share* and claims +nothing. The two are complementary. Concrete types remain the primary API -- usable directly, +with no type parameter and no dispatch -- and the trait exists for consumers who want to be +generic over a shape, exactly as `std::io::Read` sits beside `File`. + +**Anticipating that trait is a constraint on the concrete types now, not an addition later.** If +one shape ships `pop(&mut self) -> Option` and another ships `try_pop(&self) -> Result`, no trait unifies them afterwards without a breaking change to one of them. Signatures +must therefore be trait-compatible from the first type, whether or not the trait ever ships. + +**So every shape is split into producer and consumer handles, and cardinality is expressed by +`Clone`.** This is the hard part, because the conventions differ by shape: an SPSC queue is +usually a split `Producer`/`Consumer` pair, while a shared MPMC queue is usually one `Arc` +with `&self` on both ends, and no trait spans those two structures. Making every shape +split-handle resolves it, and buys something better than uniformity: + +| Shape | Producer | Consumer | +|---|---|---| +| SPSC | not `Clone` | not `Clone` | +| MPSC | `Clone` | not `Clone` | +| MPMC | `Clone` | `Clone` | + +Cardinality becomes a **compile-time guarantee rather than a documented precondition**: an SPSC +producer that cannot be cloned cannot become a second producer. The alternative -- a shared +`&self` queue carrying an unchecked "only one consumer" contract -- is precisely the kind of +rule-you-must-remember that +[`RingScope`](crates/windows-ioring-sys/DESIGN-NOTES.md#d-43) and `get(&mut self)` were +introduced to eliminate elsewhere in this workspace. + +**The doorbell belongs on the consumer side**, since the consumer is what waits and the producer +merely rings. Whether that makes `WaitableQueue` a consumer-side trait, or splits the contract +into a producer trait and a consumer trait, is left to the crate's own design notes rather than +guessed here. + **What unifies the family is waitability, not I/O.** An earlier candidate, `windows-io-queue`, was rejected on this point: the queues themselves have nothing to do with I/O, and the domain runtime is merely their first consumer. Naming a general facility after its first client is the From 1e91e4b0aac86934eb679bef1dcadac48f0de00e Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 19:26:57 -0400 Subject: [PATCH 018/361] feat(waitable-queues): create the crate and record the decisions it will be built against Completed item: M30.2: Create the crate with publish = true, and write its DESIGN-NOTES.md with the decisions the session already reached. Skeleton only -- no shapes yet, which is M30.3 and M31. What lands here is the crate, its registration, and D-1..D-8. The engineer's observation that the shapes would be "sliced and diced by various traits as we go along" changed the answer to the question this item was supposed to settle. It asked whether WaitableQueue should be one consumer-side trait or a producer/consumer pair. Following the observation shows both framings are wrong, and not merely inelegant: a single fat trait is *unimplementable* by the shapes that are planned, because a poll-only queue has no doorbell to return and an unbounded one has no capacity to report. So D-2 is narrow capability traits on the std::io model -- Read/Write/Seek rather than one Io -- each naming one capability, with a shape implementing the subset it genuinely has. D-3 then says no trait ships until a second implementation exists to validate it. A trait written against one type designs in a vacuum: every signature that type happens to have looks like a requirement, and nothing tests whether the abstraction is right. The trait *shape* is fixed now because it constrains the concrete types; the traits themselves land with the second shape. Also recorded: D-4 split handles with cardinality carried by Clone, so single-producer is compiler-enforced rather than documented; D-5 the doorbell invariant with the asymmetry spelled out (a late signal is a spurious wakeup, a stale reset is a lost one, so only the reset must be atomic with the emptiness observation); D-6 overflow fails or reserves and never overwrites, because an overwritten telemetry entry is a lost sample while an overwritten submission is a lost operation; D-8 what publishing commits us to, and why this crate earns it where windows-guard-alloc does not. D-7 reverses a position taken earlier in the same session. "One crate, feature-gated shapes" was half right: one crate stands, but feature-gating does not survive contact with the cost -- two features are four configurations against a feature-matrix CI job that would have to grow, for a benefit dead-code elimination already provides. Shapes are plain modules; the burden of proof is on adding a feature. Registered in the workspace members, release-please-config.json, and .release-please-manifest.json. The last two matter: publish = true makes the crate release-managed, and omitting them would have left it silently unreleasable. Verified: builds, clippy clean with -D warnings under stable, cargo fmt --check clean, and the workspace rustdoc job passes with the crate's cross-links resolving. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .release-please-manifest.json | 1 + CHECKLIST-io-domains.md | 22 ++- Cargo.lock | 7 + Cargo.toml | 1 + PLANS.md | 3 +- crates/windows-waitable-queues/Cargo.toml | 35 ++++ .../windows-waitable-queues/DESIGN-NOTES.md | 181 ++++++++++++++++++ crates/windows-waitable-queues/PLANS.md | 14 ++ crates/windows-waitable-queues/README.md | 68 +++++++ crates/windows-waitable-queues/src/lib.rs | 52 +++++ release-please-config.json | 4 + 11 files changed, 386 insertions(+), 2 deletions(-) create mode 100644 crates/windows-waitable-queues/Cargo.toml create mode 100644 crates/windows-waitable-queues/DESIGN-NOTES.md create mode 100644 crates/windows-waitable-queues/PLANS.md create mode 100644 crates/windows-waitable-queues/README.md create mode 100644 crates/windows-waitable-queues/src/lib.rs diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3ef5b4e2..233f2746 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -9,5 +9,6 @@ "crates/windows-thread-ambient-sys": "0.2.0", "crates/windows-threadpool-sys": "0.1.3", "crates/windows-topology-sys": "0.1.0", + "crates/windows-waitable-queues": "0.1.0", "crates/wtf-string": "0.1.0" } diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index ce610a9e..1f187f3d 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -53,7 +53,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m the runtime does not exist until M33+. The rule is recorded instead -- the pair should read as a pair, and the runtime's name may carry `io` because that crate genuinely is about I/O. -- [ ] **M30.2** -- Create the crate with `publish = true` (the engineer's decision: this is general-purpose +- [x] **M30.2** -- Create the crate with `publish = true` (the engineer's decision: this is general-purpose and worth publishing, unlike `windows-guard-alloc`), and write its `DESIGN-NOTES.md` with the decisions the session already reached: the shape menu and which shapes ship now, the concrete-types-plus-optional-trait rule, the overflow policy, and the doorbell invariant. This is the @@ -65,6 +65,26 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m waits, while the producer merely rings -- so a single trait would be consumer-side and the producer's contract would go unnamed. Decide rather than defer, because M30.3 writes the first signatures against whichever answer this gives. + **Answered, and the question turned out to be the wrong one.** The engineer's observation that the + shapes would be "sliced and diced by various traits as we go along" is right, and following it shows a + single `WaitableQueue` trait -- one *or* a producer/consumer pair -- is not merely inelegant but + **unimplementable by the shapes that are planned**: a poll-only queue has no doorbell to return, and an + unbounded one has no capacity to report. So the answer is **narrow capability traits** on the + `std::io` model (`Read`/`Write`/`Seek`, not one `Io`), each naming one capability, with a shape + implementing the subset it genuinely has. Recorded as [D-2](crates/windows-waitable-queues/DESIGN-NOTES.md#d-2) + with the anticipated set. + **And no trait ships until a second implementation exists to validate it** ([D-3](crates/windows-waitable-queues/DESIGN-NOTES.md#d-3)): + a trait written against one type designs in a vacuum, since every signature that type happens to have + looks like a requirement. The trait *shape* is fixed now because it constrains M30.3; the traits + themselves land with M31.1. + Crate created with `DESIGN-NOTES.md` (D-1..D-8), `README.md`, `PLANS.md` pointing back at this file, and + registration in the workspace members, `release-please-config.json`, and `.release-please-manifest.json` + -- the last two because `publish = true` makes it release-managed, and omitting them would have left it + silently unreleasable. + **One earlier position reversed with its reason recorded** ([D-7](crates/windows-waitable-queues/DESIGN-NOTES.md#d-7)): + shapes are plain modules, not Cargo features. Two features are four configurations against a + `feature-matrix` CI job that would have to grow, and the benefit is one dead-code elimination already + provides. - [ ] **M30.3** -- The SPSC bounded ring, with no doorbell and no Win32 at all: a pure data structure with acquire/release head and tail and no CAS on either side. It is the CQ direction (R1), and it is first diff --git a/Cargo.lock b/Cargo.lock index 7df419b3..872743f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,6 +288,13 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "windows-waitable-queues" +version = "0.1.0" +dependencies = [ + "windows-sys", +] + [[package]] name = "wtf-string" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 753fa429..f255412d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/windows-thread-ambient-sys", "crates/windows-threadpool-sys", "crates/windows-topology-sys", + "crates/windows-waitable-queues", "crates/wtf-string", ] resolver = "2" diff --git a/PLANS.md b/PLANS.md index 5d103928..ebb56f56 100644 --- a/PLANS.md +++ b/PLANS.md @@ -10,7 +10,8 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil [crates/windows-platform-probes/PLANS.md](crates/windows-platform-probes/PLANS.md), [crates/windows-thread-ambient-sys/PLANS.md](crates/windows-thread-ambient-sys/PLANS.md), [crates/windows-threadpool-sys/PLANS.md](crates/windows-threadpool-sys/PLANS.md), -[crates/windows-topology-sys/PLANS.md](crates/windows-topology-sys/PLANS.md), and +[crates/windows-topology-sys/PLANS.md](crates/windows-topology-sys/PLANS.md), +[crates/windows-waitable-queues/PLANS.md](crates/windows-waitable-queues/PLANS.md), and [crates/wtf-string/PLANS.md](crates/wtf-string/PLANS.md). Checklists whose work is finished move to [COMPLETED-PLANS.md](COMPLETED-PLANS.md). diff --git a/crates/windows-waitable-queues/Cargo.toml b/crates/windows-waitable-queues/Cargo.toml new file mode 100644 index 00000000..33b9d961 --- /dev/null +++ b/crates/windows-waitable-queues/Cargo.toml @@ -0,0 +1,35 @@ +# Copyright (c) 2026 Mike Grier + +[package] +name = "windows-waitable-queues" +version = "0.1.0" # x-release-please-version +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +documentation = "https://docs.rs/windows-waitable-queues" +description = "Bounded producer/consumer queues whose readiness is a waitable Windows HANDLE, so a consumer can park on a queue and a kernel object in the same wait." +readme = "README.md" +keywords = ["windows", "queue", "spsc", "mpsc", "concurrency"] +categories = ["concurrency", "os::windows-apis", "data-structures"] + +# Deliberately publishable, unlike `windows-guard-alloc` next door: this is a +# general-purpose facility whose value is that it exists, and its first consumer +# (the I/O domain runtime) is not its only plausible one. Publishing is +# therefore an obligation accepted, not an oversight -- see DESIGN-NOTES.md D-8 +# for what it commits us to. +publish = true + +[lib] +path = "src/lib.rs" + +[dependencies.windows-sys] +version = "0.61.2" +default-features = false +features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Threading", +] diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md new file mode 100644 index 00000000..98f92486 --- /dev/null +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -0,0 +1,181 @@ +# Design notes: windows-waitable-queues (Tier 1) + +This crate is a skeleton. This file records the decisions its code will be built against, taken during +the 2026-08-30 design session and transcribed here so they steer the work rather than sitting in a +session record nothing is obliged to read. The work itself is tracked in +[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) at the workspace root, because it spans several +components. + +The naming decision -- plural, and no `-sys` suffix -- lives in the workspace +[DESIGN-NOTES.md](../../DESIGN-NOTES.md#the-waitable-queues-crate-is-named-plural-and-carries-no-sys-suffix) +rather than here, since it was taken before this directory existed. + +## Intent + +Bounded producer/consumer queues whose readiness is a waitable Windows `HANDLE`. + +The queues themselves are ordinary. What the crate exists for is the `HANDLE`: it lets a consumer park +on a queue **and** a kernel object in one wait, which is exactly what the existing Rust concurrent +queues cannot offer on Windows, and it is why depending on one of them was rejected rather than +preferred. + +## Decisions + +| ID | Decision | +|---|---| +| D-1 | **The crate exists because readiness must be a `HANDLE`, not because Rust lacks queues.** `crossbeam-channel` parks on a private primitive and its `Select` accepts only channel operations; `crossbeam-queue` never blocks. Neither can be seen by `WaitForMultipleObjects`, and neither can see an `IoRing` completion event. A consumer needing "a message **or** an I/O completion **or** shutdown" must otherwise poll one source while blocking on another. | +| D-2 | **Capabilities are sliced into narrow traits, not gathered into one.** The `std::io` shape -- `Read`, `Write`, `Seek`, `BufRead` -- rather than a single fat `WaitableQueue`. Forced by the shapes themselves: a poll-only queue cannot implement a trait containing `doorbell()`, and an unbounded one cannot implement `capacity()` meaningfully. | +| D-3 | **No trait ships until a second implementation exists to validate it.** The trait *shape* is fixed now so signatures stay compatible; the traits themselves land with the second shape. | +| D-4 | **Every shape is split into producer and consumer handles, and cardinality is carried by `Clone`.** Single-producer becomes a compile-time guarantee rather than a documented precondition. | +| D-5 | **The doorbell is level state owned by the queue: signalled exactly when the consumer has something to observe.** The **reset** must be atomic with the observation that there is nothing to take; the **signal** need not be. Manual-reset, and created lazily. | +| D-6 | **Overflow fails or reserves, and never overwrites.** For telemetry an overwritten entry is a lost sample; for an I/O submission it is a lost operation, and the two must not share a policy knob. | +| D-7 | **Shapes are plain modules, not Cargo features, until compile time justifies otherwise.** Two features are four configurations to test, against a benefit dead-code elimination already provides. | +| D-8 | **Published, and the obligation is accepted deliberately.** Unlike `windows-guard-alloc`, this is general-purpose and its first consumer is not its only plausible one. | + +## D-2: capabilities are sliced, not gathered + +The first sketch of this crate had one `WaitableQueue` trait carrying push, pop, the doorbell, capacity, +and the loss latch. The engineer's observation that the shapes would be "sliced and diced by various +traits as we go along" is the correct instinct, and following it exposes that the fat trait is not merely +inelegant -- **it is unimplementable by the shapes that are planned.** A queue that is never waited on +has no doorbell to return; an unbounded queue has no capacity to report; a queue with no loss latch has +no losses to describe. + +So the contract is a set of narrow traits, each naming one capability, and a shape implements the subset +it genuinely has. The anticipated set, which is expected to grow: + +| Trait | Names | Held by | +|---|---|---| +| `Producer` | `push`, and the error a full or disconnected queue returns | producer handle | +| `Consumer` | `pop`, and drain-to-empty | consumer handle | +| `Waitable` | the readiness `HANDLE` | consumer handle | +| `Bounded` | `capacity`, `remaining` | either | +| `Reserving` | a slot claimed in advance for a message that must not be lost | producer handle | +| `LossReporting` | the coalesced loss latch | consumer handle | +| `Observable` | depth, high-water, doorbells actually rung | either | + +Two consequences worth stating, because they are what the slicing buys: + +- **A consumer can be generic over exactly what it needs.** The I/O domain runtime needs `Consumer` and + `Waitable` and nothing else; making it generic over a trait that also mentions reservation and loss + reporting would couple it to capabilities it never uses. +- **`Waitable` is not queue-specific and may not stay here.** "Hands out a `HANDLE` you can wait on" is a + property an event, a timer, or a completion port has too. If a second kind of thing wants to implement + it, the trait moves to a lower crate and this one depends on it. Recorded so that move is a planned + step rather than a surprise. + +## D-3: the traits ship with the second implementation, not the first + +Writing a trait against one implementation designs in a vacuum: every signature the single type happens +to have looks like a requirement, and nothing tests whether the abstraction is the right one. The +workspace already prefers duplicate-then-decide for exactly this reason -- keep the speculative path +separate until it is proven, then merge or delete. + +So the **shape** of the traits is fixed now, because it constrains the concrete types (D-4), while the +traits themselves are written when the second shape exists to be checked against them. The cheap +discipline that makes this work: write the intended signatures as a comment before writing the first +type, and confirm the type satisfies them. + +The failure this avoids is specific and unrecoverable-in-place. If the first shape ships +`pop(&mut self) -> Option` and the second ships `try_pop(&self) -> Result`, no trait unifies +them afterwards without a breaking change to one. + +## D-4: split handles, and cardinality carried by `Clone` + +The conventions for these shapes differ, and the difference is structural rather than cosmetic. An SPSC +queue is conventionally a split `Producer`/`Consumer` pair; a shared MPMC queue is conventionally one +`Arc` with `&self` on both ends. **No trait spans those two structures**, so a crate wanting a common +contract must choose one, and split handles are the choice that generalizes. + +It buys more than uniformity: + +| Shape | Producer | Consumer | +|---|---|---| +| SPSC | not `Clone` | not `Clone` | +| MPSC | `Clone` | not `Clone` | +| MPMC | `Clone` | `Clone` | + +Cardinality stops being a precondition in prose and becomes a fact the compiler enforces: a producer that +cannot be cloned cannot become a second producer. The alternative -- a shared `&self` queue documenting +"only one consumer" -- is precisely the rule-you-must-remember that +[`RingScope`](../windows-ioring-sys/DESIGN-NOTES.md#d-43) and `get(&mut self)` were introduced to +eliminate elsewhere in this workspace. + +The consumer handle also owns the doorbell, because the consumer is what waits; a producer merely rings. + +## D-5: the doorbell invariant, and which half must be under a lock + +The invariant is one sentence: **the event is signalled exactly when the consumer has something to +observe.** It is *level* state -- a function of the queue's contents -- rather than a record of edges, +which is why it is manual-reset. + +The asymmetry is the part that is easy to get wrong, and it was worked out by walking the interleavings: + +- **The signal may be given outside any lock.** A late `SetEvent` can at worst arrive after the consumer + already drained that item and parked, which produces a spurious wakeup: the consumer wakes, finds + nothing, parks again. Harmless, and consumers must tolerate it regardless. +- **The reset must be atomic with the observation that there is nothing to take.** Otherwise: consumer + drains to empty, producer pushes and signals, consumer resets -- clearing the signal for an item that + is still there -- and parks. That wakeup is lost and the item is stranded. + +So a redundant signal is free and a stale reset is fatal, which is the whole reason the queue owns its +doorbell rather than accepting one. The same invariant, reached independently, is stated in +[windows-file-watcher's queue](../windows-file-watcher/src/queue.rs): signalling under the lock a +receiver holds while deciding there is nothing to take, "so a wakeup cannot be lost in the gap between +those two decisions, because there is no gap". + +**What is reused from that queue is the invariant, not the implementation.** It uses `Mutex` and +`Condvar`, which is right for change-notification cadence and wrong here, because a producer-side lock +serializes exactly what multi-producer exists to parallelize. + +**Created lazily**, so a consumer that only ever polls allocates no kernel object at all. Handed out as +a borrowed handle plus an owned duplicate, so a caller can choose whether to own it. + +**Skipping a redundant signal is an optimization, not a requirement.** Measured on ARM64: one +`SetEvent`/`ResetEvent` cycle is ~165 ns against a ~7 ns uncontended atomic, but one doorbell per drained +*batch* costs 20.6 ns per operation at a batch of eight and 5.2 at thirty-two -- so by a batch of about +twenty-three the doorbell already costs less per operation than the push it accompanies. The cheap skip +(the queue was already non-empty) needs no knowledge of the consumer and can be taken immediately; the +one requiring the consumer to publish whether it is parked is deferred until a measurement against real +work justifies its lost-wakeup risk. + +## D-6: overflow fails or reserves, and never overwrites + +Three policies, and the absence of a fourth: + +- **Fail fast.** A full queue returns the item to the caller in a typed error. That failure *is* the + backpressure, and it is why the shapes are bounded: an unbounded queue has no backpressure to offer, + only deferred memory growth. +- **Reserve.** A slot claimed in advance, so a message that must not be lost has somewhere to go. Taken + from [windows-file-watcher's queue](../windows-file-watcher/src/queue.rs), which needs it for exactly + the same reason: some messages are the ones a consumer cannot afford to miss. +- **Coalesced loss latch.** When a queue may lose, a drop latches a report the consumer is guaranteed to + observe, so loss is *counted* rather than silent. Also from the watcher. + +**Overwrite-oldest is deliberately not offered.** `crossbeam`'s `force_push` makes an `ArrayQueue` usable +as a ring buffer, which is right for telemetry, where an overwritten entry is a lost sample. Here an +entry is an I/O submission, and overwriting one is a lost *operation*. The two cases must not share a +knob, because a knob invites a consumer to choose the wrong one. + +## D-7: shapes are modules, not Cargo features + +An earlier position in the session was "one crate, feature-gated shapes". The first half stands -- one +crate, not a crate per family, so the shared vocabulary lives in one place. The second half does not +survive contact with the cost: two features are four configurations, and this workspace already runs a +`feature-matrix` CI job that would have to grow to cover them. The benefit -- not compiling a shape you +do not use -- is one dead-code elimination already provides for an unused type. + +Feature-gating remains available if compile time ever justifies it. It is not the default, and the burden +of proof is on adding a feature rather than on leaving one out. + +## D-8: published, and what that commits us to + +Publishing is an obligation rather than a status. It means the API is a contract that cannot be changed +casually, that a breaking change costs a major version, and that the crate must be documented for readers +who have never seen this workspace. + +It is accepted because this crate is general-purpose in a way `windows-guard-alloc` is not. That one is +`publish = false` precisely because its design trades memory for determinism and would be wrong for +anything but a test binary. These queues carry no such trap, the first consumer is not the only plausible +one, and a Windows Rust program that wants to wait on a queue and a kernel object together currently has +to write this itself. diff --git a/crates/windows-waitable-queues/PLANS.md b/crates/windows-waitable-queues/PLANS.md new file mode 100644 index 00000000..fccb45c7 --- /dev/null +++ b/crates/windows-waitable-queues/PLANS.md @@ -0,0 +1,14 @@ +# Plans: windows-waitable-queues + +This crate's work is **not** tracked here. It is introduced by +[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) at the workspace root, because that effort spans +this crate, a domain runtime, a durability layer, and extensions to three existing crates -- the root is +their lowest common source-component. + +This file exists so the per-component plans trackers enumerated in the root +[PLANS.md](../../PLANS.md) are complete, and so that a reader who starts here is sent to the right place +rather than concluding there is no plan. + +| Path to CHECKLIST.md | Status | Brief description | Design Notes | +|---|---|---|---| +| [../../CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) | in progress | M30 creates this crate and its SPSC shape; M31 adds the bounded-array MPSC, the overflow policies, shutdown, observability, and the contention benchmark that decides whether the deferred shapes are ever built. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md new file mode 100644 index 00000000..10aa0e73 --- /dev/null +++ b/crates/windows-waitable-queues/README.md @@ -0,0 +1,68 @@ +# windows-waitable-queues + +Bounded producer/consumer queues whose readiness is a waitable Windows `HANDLE`. + +**Windows only.** Every public item is behind `cfg(windows)`; the crate builds to +an empty shell on other platforms. + +**Status: skeleton.** The shapes are not implemented yet. The decisions they will +be built against are in [DESIGN-NOTES.md](DESIGN-NOTES.md), and the work is +tracked in [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) at the +workspace root. + +## Why + +Rust has good concurrent queues. What none of them offers on Windows is the one +property this crate is named for: **you cannot wait on them alongside a kernel +object.** + +`crossbeam-channel` blocks in `recv`, but parks on its own internal primitive and +exposes no `HANDLE`; its `Select` is built purely from channel operations, with no +way to register a foreign OS object. `crossbeam-queue` does not block at all. + +So a thread that needs to wake on + +> a message arrived **or** my I/O completed **or** shutdown was signalled + +cannot express that wait. It has to poll one source while blocking on another, +which either burns a core or adds latency. + +On Windows a `HANDLE` is the universal waitable currency -- `WaitForSingleObject`, +`WaitForMultipleObjects`, `MsgWaitForMultipleObjects`, a thread-pool wait, and +alertable waits all take one. A queue whose readiness *is* a `HANDLE` composes +with everything the platform can wait on. One that hides its readiness behind a +private primitive composes with nothing. + +## Plural, and no `Queue` type + +This is a family of shapes, not one queue: they differ in producer and consumer +cardinality, in how they store items, and in what they do when full. None is +canonical, so there is deliberately no type named `Queue` -- a consumer names the +shape it wants. + +Each shape splits into a **producer handle** and a **consumer handle**, and +cardinality is carried by whether those handles are `Clone`: + +| Shape | Producer | Consumer | +|---|---|---| +| SPSC | not `Clone` | not `Clone` | +| MPSC | `Clone` | not `Clone` | +| MPMC | `Clone` | `Clone` | + +So "single producer" is a fact the compiler enforces, not a sentence in a doc +comment. + +## What it will not do + +- **It will not overwrite.** A full queue fails, or a reservation guarantees a + slot. Overwrite-oldest is right for telemetry, where a lost entry is a lost + sample; here an entry may be an I/O submission, where a lost entry is a lost + operation. +- **It will not allocate on push.** Bounded shapes allocate once, at + construction. +- **It will not create a kernel object you never use.** The doorbell is created + lazily, so a consumer that only polls allocates none. + +## Licence + +Copyright (c) Mike Grier. diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs new file mode 100644 index 00000000..f217ec2d --- /dev/null +++ b/crates/windows-waitable-queues/src/lib.rs @@ -0,0 +1,52 @@ +// Copyright (c) Mike Grier. + +//! Bounded producer/consumer queues whose readiness is a waitable Windows +//! `HANDLE`. +//! +//! **Windows only.** Every public item is behind `cfg(windows)`; the crate +//! builds to an empty shell on other platforms. +//! +//! # Why this exists +//! +//! There are good concurrent queues for Rust already. What none of them offers +//! on Windows is the one property this crate is named for: **you cannot wait on +//! them alongside a kernel object.** +//! +//! `crossbeam-channel` blocks in `recv`, but parks on its own internal +//! primitive and exposes no `HANDLE`; its `Select` is built purely from channel +//! operations, with no way to register a foreign OS object. +//! `crossbeam-queue` does not block at all. So a thread that must wake on +//! "a message arrived **or** my I/O completed **or** shutdown was signalled" +//! cannot express that wait, and must poll one source while blocking on +//! another -- which either burns a core or adds latency. +//! +//! On Windows a `HANDLE` is the universal waitable currency: +//! `WaitForSingleObject`, `WaitForMultipleObjects`, `MsgWaitForMultipleObjects`, +//! a thread-pool wait, and alertable waits all take one. So a queue whose +//! readiness *is* a `HANDLE` composes with everything the platform can wait on, +//! and one that hides its readiness behind a private primitive composes with +//! nothing. +//! +//! # What is here +//! +//! A family of queue shapes rather than one queue, which is why the crate is +//! named in the plural. They differ in producer and consumer cardinality, in +//! how they store their items, and in what they do when full. No shape is the +//! canonical one, so there is deliberately no type named `Queue`: a consumer +//! names the shape it wants. +//! +//! Each shape is split into a **producer handle** and a **consumer handle**, +//! and cardinality is carried by whether those handles are [`Clone`]. A +//! single-producer queue hands out a producer that cannot be cloned, so +//! "single producer" is a fact the compiler enforces rather than a sentence in +//! a doc comment. +//! +//! # Status +//! +//! Skeleton. The shapes land in the milestones tracked by +//! `CHECKLIST-io-domains.md` at the workspace root; the decisions they will be +//! built against are recorded in `DESIGN-NOTES.md` beside this file. + +#![cfg_attr(docsrs, feature(doc_cfg))] +#![warn(missing_docs)] +#![warn(unsafe_op_in_unsafe_fn)] diff --git a/release-please-config.json b/release-please-config.json index 24017120..23467714 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -51,6 +51,10 @@ "package-name": "windows-topology-sys", "component": "windows-topology-sys" }, + "crates/windows-waitable-queues": { + "package-name": "windows-waitable-queues", + "component": "windows-waitable-queues" + }, "crates/wtf-string": { "package-name": "wtf-string", "component": "wtf-string" From f656c33aa17fd42f4d52b346b7f93469c0ae51ae Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 19:39:45 -0400 Subject: [PATCH 019/361] fix(probes): reflow doorbell-cost doc list to satisfy clippy `clippy::doc_overindented_list_items` fired on seven continuation lines in `doorbell_cost.rs`'s module documentation, where the bullet text was aligned under a wide first column rather than at the normal two-space continuation indent. This is a `-D warnings` failure (exit 101), so CI would have failed. It had been misread as a transient rustfmt complaint that "settles" on a clean tree. It was not: the earlier checks piped `cargo clippy` through `Select-String`, which makes `$LASTEXITCODE` report the filter's status instead of cargo's, so a genuine failure read as a clean `exit=0`. No prose was changed, only the wrapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/doorbell_cost.rs | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/crates/windows-platform-probes/src/doorbell_cost.rs b/crates/windows-platform-probes/src/doorbell_cost.rs index e4c5e3e6..2ab7ab33 100644 --- a/crates/windows-platform-probes/src/doorbell_cost.rs +++ b/crates/windows-platform-probes/src/doorbell_cost.rs @@ -30,18 +30,17 @@ //! operation. Absolute values are host-specific and uninteresting; the //! **ratios** are the finding. //! -//! - `atomic_fetch_add` -- the uncontended atomic that a queue push costs, -//! as a floor for "the cheapest useful thing". -//! - `set_event_already_signalled` -- `SetEvent` on an event that is already -//! set, which is the redundant-signal case the skip -//! rule removes. -//! - `set_reset_event` -- `SetEvent` then `ResetEvent`, the honest cost of -//! one doorbell cycle with nobody waiting. -//! - `wait_zero_signalled` -- `WaitForSingleObject(handle, 0)` on a signalled -//! event: the consumer's cost of observing it. -//! - `submit_io_ring_empty` -- `SubmitIoRing` with nothing queued, which is -//! the syscall the doorbell would be amortised -//! against. Absent when `IoRing` is unavailable. +//! - `atomic_fetch_add` -- the uncontended atomic that a queue push costs, as a +//! floor for "the cheapest useful thing". +//! - `set_event_already_signalled` -- `SetEvent` on an event that is already +//! set, which is the redundant-signal case the skip rule removes. +//! - `set_reset_event` -- `SetEvent` then `ResetEvent`, the honest cost of one +//! doorbell cycle with nobody waiting. +//! - `wait_zero_signalled` -- `WaitForSingleObject(handle, 0)` on a signalled +//! event: the consumer's cost of observing it. +//! - `submit_io_ring_empty` -- `SubmitIoRing` with nothing queued, which is the +//! syscall the doorbell would be amortised against. Absent when `IoRing` is +//! unavailable. //! //! # The empty submit is not a fair denominator, and the first run proved it //! From ff42cbdf5732abaf730878f71abcd0705ce14852 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 19:40:07 -0400 Subject: [PATCH 020/361] feat(waitable-queues): add the bounded SPSC queue The first concrete shape in the crate, and the one that fixes the vocabulary every later shape reuses. `bounded::(capacity)` returns a `(Producer, Consumer)` pair over a power-of-two ring. Positions are monotonic and wrapping, so `tail - head` is the length without a spare slot or an ambiguous full/empty state; capacity is capped at `usize::MAX / 2` to keep that subtraction unambiguous. Each position sits in its own 128-byte `CacheAligned` cell so the two threads do not share a line. Two decisions here are load-bearing for the shapes that follow: `push`/`pop` take `&self`, not `&mut self`. `&mut self` would also make single-producer use sound, and is what several SPSC crates do, but it cannot generalize to a shape where several threads push through one shared handle, and one spelling has to serve every shape. Cardinality is carried by the auto traits instead: the handles are `Send` but not `Sync` and not `Clone`, so "single producer" is a fact the compiler checks rather than a comment. A multi-producer shape relaxes exactly one cell of that table. Disconnection uses explicit `producer_live` / `consumer_live` flags with a release store on drop, not `Arc::strong_count`, and `push` reports `Disconnected` in preference to `Full` -- retrying a queue that is both full and dead is an unbounded spin. A consumer must drain before trusting `is_disconnected`; that ordering is documented on the method itself. Verified by sabotage, not by a green suite. Six injected defects were each confirmed to fail the tests: a drop loop starting at zero instead of `head`, an off-by-one in the full test, `Full` returned where `Disconnected` was owed, `pop` not advancing `head`, `push` not advancing `tail`, and a mask of `capacity` rather than `capacity - 1`. One injected defect was NOT caught, and is recorded rather than smoothed over: weakening the producer's `Acquire` load of `head` to `Relaxed` leaves all twenty tests green. That is a limit of stress testing rather than a missing test -- the reordering that exposes a missing acquire is one neither ARM64 nor x86-64 will produce on request. Queued as M31.6 to verify the orderings under `loom`, which enumerates interleavings instead of sampling them. Completed item: M30.3: Implement the bounded SPSC queue, the simplest shape, and the one that establishes the vocabulary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 42 +- crates/windows-waitable-queues/src/error.rs | 158 +++++++ crates/windows-waitable-queues/src/lib.rs | 25 +- crates/windows-waitable-queues/src/spsc.rs | 406 ++++++++++++++++++ .../windows-waitable-queues/src/spsc/tests.rs | 332 ++++++++++++++ 5 files changed, 960 insertions(+), 3 deletions(-) create mode 100644 crates/windows-waitable-queues/src/error.rs create mode 100644 crates/windows-waitable-queues/src/spsc.rs create mode 100644 crates/windows-waitable-queues/src/spsc/tests.rs diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 1f187f3d..2b59969f 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -86,7 +86,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m `feature-matrix` CI job that would have to grow, and the benefit is one dead-code elimination already provides. -- [ ] **M30.3** -- The SPSC bounded ring, with no doorbell and no Win32 at all: a pure data structure with +- [x] **M30.3** -- The SPSC bounded ring, with no doorbell and no Win32 at all: a pure data structure with acquire/release head and tail and no CAS on either side. It is the CQ direction (R1), and it is first because everything harder is a variation on it. Tests are ordinary fast unit tests -- capacity edges, wraparound, full and empty, and that a `pop` never observes a partially written `T`. @@ -99,6 +99,31 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m match, the `WaitableQueue` trait becomes a breaking change to one of them rather than an addition. Verify it the cheap way: write the trait's method signatures down as a comment before writing the type, and confirm the type satisfies them. + **Done, and the signatures are written down in `spsc.rs`'s module documentation before the type**, as + the item asked. `push`/`pop` take **`&self`**, not `&mut self`: the latter would also make + single-producer sound and is what several SPSC crates use, but it cannot generalize to a shape where + several threads push through a shared handle, and one spelling has to serve every shape. + Cardinality is carried by the auto traits instead -- the handles are `Send` but **not `Sync`** and not + `Clone`, so "single" is a fact the compiler checks. A multi-producer shape relaxes exactly one cell of + that table. + **Sabotage-verified rather than merely green.** Six deliberate defects, each confirmed to fail the + suite: a drop loop starting at zero instead of `head`, an off-by-one in the full test, `Full` reported + where `Disconnected` is owed, `pop` not advancing `head`, `push` not advancing `tail`, and a mask of + `capacity` instead of `capacity - 1`. + **One sabotage was NOT caught, and it is recorded rather than smoothed over:** weakening the producer's + `Acquire` load to `Relaxed` leaves the suite green. That is a genuine limit of stress testing, not a + missing test -- an ordering bug needs an interleaving the hardware and scheduler must be coaxed into + producing, and neither ARM64 nor x86-64 will oblige on demand. Queued as M31.6. + **A harness defect worth remembering:** the first sabotage sweep reported "not caught" for the + `pop`-does-not-advance case, because the detector matched on the string `test result: FAILED` and the + test process had instead died with `STATUS_HEAP_CORRUPTION`, which prints no such line. Nine tests had + in fact failed. A sabotage harness that recognizes only one failure shape will eventually certify a + hole that is not there -- or miss one that is. Detect by exit code. + The same defect then bit the *gate*: piping `cargo clippy` through `Select-String` makes + `$LASTEXITCODE` report the filter's status, not cargo's, so a clean-looking `exit=0` was hiding a real + `-D warnings` failure (`clippy::doc_overindented_list_items`, seven sites in `windows-platform-probes`, + actual exit 101) that CI would have caught. Fixed in the preceding commit. Redirect with `*>` and read + `$LASTEXITCODE` before any pipe. - [ ] **M30.4** -- The doorbell, as its own reviewable unit: a queue-owned **manual-reset** event created **lazily**, so a polling-only consumer allocates no kernel object. Level semantics -- signalled exactly @@ -144,6 +169,21 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m Record the result either way -- a measurement that says "the simple thing is fine" is worth as much as one that does not, and is the cheaper outcome to lose track of. +- [ ] **M31.6** -- Verify the memory orderings with a model checker, because stress testing demonstrably + cannot. **Measured, not assumed:** during M30.3's sabotage sweep, weakening the producer's `Acquire` + load of `head` to `Relaxed` left all twenty tests green, while every *logic* defect injected alongside + it was caught. A stress test can only observe the interleavings the hardware and scheduler happen to + produce, and neither ARM64 nor x86-64 will produce the reordering that makes a missing acquire visible + just because a test asks nicely. + `loom` is the tool: it enumerates interleavings under a weak-memory model rather than sampling them, so + a missing `Acquire`/`Release` pair becomes a deterministic failure. It is a dev-dependency and a + `cfg(loom)` shim over the atomics, so it costs the shipped crate nothing. + **Sabotage-verify the verifier**, exactly as here: the loom test is only worth its weight if + reintroducing that same `Relaxed` makes it fail. If it does not, the model is not covering the path + and the test is decoration. + Scope it to the orderings, not the logic -- loom explores exponentially, so a loom test that also + checks FIFO order over a thousand items will not terminate. + ## M32 -- Contracts the runtime cannot be written without These are decision items, not implementation. Each is open in the session record, and each would change diff --git a/crates/windows-waitable-queues/src/error.rs b/crates/windows-waitable-queues/src/error.rs new file mode 100644 index 00000000..f21f4a5d --- /dev/null +++ b/crates/windows-waitable-queues/src/error.rs @@ -0,0 +1,158 @@ +// Copyright (c) Mike Grier. + +//! Errors shared by every queue shape. +//! +//! They live at the crate root rather than inside a shape's module because the +//! shapes must agree on them: a trait cannot unify `push` across shapes if each +//! returns a differently-named error meaning the same thing. + +use core::fmt; + +/// Why a capacity was rejected at construction. +/// +/// Constructing a queue is the one place a caller can get this wrong, so it is +/// reported rather than rounded away. Silently rounding 100 up to 128 would +/// hand back a bound the caller cannot see they got, and a bound is exactly the +/// number a caller chose deliberately. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CapacityError { + requested: usize, + kind: CapacityErrorKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CapacityErrorKind { + Zero, + NotPowerOfTwo, + TooLarge, +} + +impl CapacityError { + pub(crate) fn zero() -> Self { + Self { + requested: 0, + kind: CapacityErrorKind::Zero, + } + } + + pub(crate) fn not_power_of_two(requested: usize) -> Self { + Self { + requested, + kind: CapacityErrorKind::NotPowerOfTwo, + } + } + + pub(crate) fn too_large(requested: usize) -> Self { + Self { + requested, + kind: CapacityErrorKind::TooLarge, + } + } + + /// The capacity that was asked for. + #[must_use] + pub fn requested(&self) -> usize { + self.requested + } + + /// The largest valid capacity not greater than the request, if there is + /// one. + /// + /// Offered so a caller can correct the call without working out the + /// arithmetic: a rejected 100 reports 64 here and 128 from + /// [`Self::next_valid`]. + #[must_use] + pub fn previous_valid(&self) -> Option { + match self.kind { + CapacityErrorKind::Zero => None, + CapacityErrorKind::NotPowerOfTwo | CapacityErrorKind::TooLarge => { + Some(1_usize << (usize::BITS - 1 - self.requested.leading_zeros())) + } + } + } + + /// The smallest valid capacity not less than the request, if there is one. + #[must_use] + pub fn next_valid(&self) -> Option { + match self.kind { + CapacityErrorKind::Zero => Some(1), + CapacityErrorKind::NotPowerOfTwo => self.requested.checked_next_power_of_two(), + CapacityErrorKind::TooLarge => None, + } + } +} + +impl fmt::Display for CapacityError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.kind { + CapacityErrorKind::Zero => { + write!(f, "a queue capacity of zero can never accept an item") + } + CapacityErrorKind::NotPowerOfTwo => { + let (lo, hi) = (self.previous_valid(), self.next_valid()); + write!( + f, + "capacity {} is not a power of two; the nearest valid capacities are {:?} and {:?}", + self.requested, lo, hi + ) + } + CapacityErrorKind::TooLarge => write!( + f, + "capacity {} is too large; it must not exceed half of usize::MAX, so that the \ + difference between the producer and consumer positions stays unambiguous across \ + wraparound", + self.requested + ), + } + } +} + +impl core::error::Error for CapacityError {} + +/// Why a push did not happen, carrying the item back. +/// +/// The item is returned rather than dropped, because a queue that swallows what +/// it refuses gives a caller no way to retry, redirect, or account for it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PushError { + /// The queue is at capacity. + /// + /// **This is the backpressure signal**, not a malfunction. A bounded queue + /// exists so that a producer outrunning its consumer is told so, rather + /// than allowed to consume memory until something worse happens. + Full(T), + + /// Every consumer is gone, so nothing will ever take this item. + /// + /// Distinguished from [`Self::Full`] because the responses differ: a full + /// queue may drain, and a disconnected one never will, so retrying the + /// first is sensible and retrying the second is a spin. + Disconnected(T), +} + +impl PushError { + /// Takes the item back out. + #[must_use] + pub fn into_inner(self) -> T { + match self { + Self::Full(item) | Self::Disconnected(item) => item, + } + } + + /// Whether a later attempt could plausibly succeed. + #[must_use] + pub fn is_retryable(&self) -> bool { + matches!(self, Self::Full(_)) + } +} + +impl fmt::Display for PushError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Full(_) => write!(f, "the queue is at capacity"), + Self::Disconnected(_) => write!(f, "every consumer is gone"), + } + } +} + +impl core::error::Error for PushError {} diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index f217ec2d..5d15ef67 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -43,10 +43,31 @@ //! //! # Status //! -//! Skeleton. The shapes land in the milestones tracked by -//! `CHECKLIST-io-domains.md` at the workspace root; the decisions they will be +//! [`spsc`] is implemented and has no doorbell yet -- it is a plain concurrent +//! ring, and the `HANDLE` the crate is named for arrives with the milestone +//! after it. The remaining shapes land in the milestones tracked by +//! `CHECKLIST-io-domains.md` at the workspace root; the decisions they are //! built against are recorded in `DESIGN-NOTES.md` beside this file. #![cfg_attr(docsrs, feature(doc_cfg))] #![warn(missing_docs)] #![warn(unsafe_op_in_unsafe_fn)] + +mod error; +pub mod spsc; + +pub use error::{CapacityError, PushError}; + +/// Pads and aligns a value onto its own cache line. +/// +/// The producer's position and the consumer's position are written by different +/// threads on every operation. Left adjacent they would share a cache line, and +/// each write would invalidate the other thread's copy of a value it only ever +/// reads -- false sharing, which converts an uncontended queue into a +/// contended one while every load and store remains individually correct. +/// +/// 128 rather than 64: that is the cache line on aarch64, and on x86-64 the +/// adjacent-line prefetcher pulls pairs of 64-byte lines, so 64 does not +/// reliably separate them. +#[repr(align(128))] +struct CacheAligned(T); diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs new file mode 100644 index 00000000..263ef082 --- /dev/null +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -0,0 +1,406 @@ +// Copyright (c) Mike Grier. + +//! The single-producer, single-consumer bounded ring. +//! +//! The cheapest shape in the crate: neither side ever executes a +//! compare-and-swap, because each owns one of the two positions outright and +//! only *reads* the other's. It is the completion direction of a two-layer +//! ring, where one domain thread produces and one drainer consumes. +//! +//! # The signatures this module fixes for every later shape +//! +//! This is the first shape written, so its method signatures become the ones a +//! capability trait must be able to name. Written down before the type, per +//! [D-3](../../DESIGN-NOTES.md#d-3), because a second shape that spells the +//! same operation differently cannot later be unified without breaking one of +//! them: +//! +//! ```text +//! trait Producer { +//! type Item; +//! fn push(&self, item: Self::Item) -> Result<(), PushError>; +//! fn is_disconnected(&self) -> bool; +//! } +//! +//! trait Consumer { +//! type Item; +//! fn pop(&self) -> Option; +//! fn is_disconnected(&self) -> bool; +//! } +//! +//! trait Bounded { +//! fn capacity(&self) -> usize; +//! fn len(&self) -> usize; +//! fn is_empty(&self) -> bool; +//! } +//! ``` +//! +//! The traits themselves are deliberately absent until a second shape exists to +//! validate them. +//! +//! # Why the operations take `&self` +//! +//! `&mut self` would also make single-producer sound, and several SPSC crates +//! spell it that way. It is rejected here because it does not generalize: a +//! multi-producer shape must let several threads push through a shared handle, +//! which `&mut self` forbids. Since one spelling has to serve every shape, the +//! one that serves the widest is chosen. +//! +//! Cardinality is then carried by the auto traits instead, which is +//! [D-4](../../DESIGN-NOTES.md#d-4): +//! +//! | | [`Clone`] | [`Send`] | [`Sync`] | +//! |---|---|---|---| +//! | [`Producer`] | no | yes, if `T: Send` | **no** | +//! | [`Consumer`] | no | yes, if `T: Send` | **no** | +//! +//! Not [`Sync`] is what makes "single" true: a handle that cannot be shared +//! between threads and cannot be duplicated is held by exactly one thread. The +//! compiler enforces it, so no documented precondition has to be remembered. A +//! multi-producer shape will relax exactly one cell of that table. + +use core::cell::{Cell, UnsafeCell}; +use core::fmt; +use core::marker::PhantomData; +use core::mem::MaybeUninit; +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; + +use crate::CacheAligned; +use crate::error::{CapacityError, PushError}; + +/// The largest capacity that keeps the producer-minus-consumer difference +/// unambiguous once the positions wrap. +/// +/// Positions are monotonic and wrap with the integer, so the number of items +/// held is `tail.wrapping_sub(head)`. That is correct across wraparound only +/// while the true difference cannot exceed half the range. +const MAX_CAPACITY: usize = usize::MAX / 2; + +/// Creates a single-producer, single-consumer bounded ring. +/// +/// `capacity` must be a power of two, and is the exact number of items the +/// queue holds -- not a hint, and not rounded. See [`CapacityError`] for why a +/// rejection is preferred to rounding. +/// +/// # Errors +/// +/// Returns [`CapacityError`] if `capacity` is zero, is not a power of two, or +/// exceeds [`usize::MAX`] / 2. +/// +/// # Examples +/// +/// ``` +/// use windows_waitable_queues::spsc; +/// +/// let (tx, rx) = spsc::bounded::(2)?; +/// tx.push(7).expect("a fresh queue has room"); +/// assert_eq!(rx.pop(), Some(7)); +/// assert_eq!(rx.pop(), None); +/// # Ok::<(), windows_waitable_queues::CapacityError>(()) +/// ``` +pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { + if capacity == 0 { + return Err(CapacityError::zero()); + } + if !capacity.is_power_of_two() { + return Err(CapacityError::not_power_of_two(capacity)); + } + if capacity > MAX_CAPACITY { + return Err(CapacityError::too_large(capacity)); + } + + let mut slots = Vec::with_capacity(capacity); + slots.resize_with(capacity, || UnsafeCell::new(MaybeUninit::uninit())); + + let shared = Arc::new(Shared { + slots: slots.into_boxed_slice(), + mask: capacity - 1, + capacity, + head: CacheAligned(AtomicUsize::new(0)), + tail: CacheAligned(AtomicUsize::new(0)), + producer_live: AtomicBool::new(true), + consumer_live: AtomicBool::new(true), + }); + + Ok(( + Producer { + shared: Arc::clone(&shared), + not_sync: PhantomData, + }, + Consumer { + shared, + not_sync: PhantomData, + }, + )) +} + +struct Shared { + slots: Box<[UnsafeCell>]>, + mask: usize, + capacity: usize, + /// Where the consumer will next read. Owned by the consumer. + head: CacheAligned, + /// Where the producer will next write. Owned by the producer. + tail: CacheAligned, + producer_live: AtomicBool, + consumer_live: AtomicBool, +} + +// SAFETY: the two positions partition the slot array between the threads. A +// slot in `[head, tail)` is owned by the consumer and read exactly once; a slot +// outside it is owned by the producer and written exactly once. Each side +// publishes its position with a release store that the other acquires, so the +// write of an item happens-before the read of that item. `T: Send` is required +// and sufficient because an item is moved between the threads and never +// referenced from both. +unsafe impl Sync for Shared {} +// SAFETY: as above; sending the shared state is sending the items it holds. +unsafe impl Send for Shared {} + +impl Shared { + /// Items currently held. + /// + /// Both loads are `Acquire` so that a caller on either side sees a value + /// consistent with the items it can actually observe. It is a snapshot the + /// moment it is returned: the peer may push or pop immediately afterwards, + /// which is why nothing here invites a check-then-act. + fn len(&self) -> usize { + let tail = self.tail.0.load(Ordering::Acquire); + let head = self.head.0.load(Ordering::Acquire); + tail.wrapping_sub(head) + } +} + +impl Drop for Shared { + fn drop(&mut self) { + // Both handles are gone, so no synchronization is needed and the + // positions can be read directly. Every slot in `[head, tail)` still + // holds an initialized item that nobody took, and dropping the queue + // must drop them rather than leak them. + let head = *self.head.0.get_mut(); + let tail = *self.tail.0.get_mut(); + let mut pos = head; + while pos != tail { + // SAFETY: `pos` is in `[head, tail)`, so this slot was written by + // the producer and never read by the consumer. It is dropped + // exactly once, because `pos` advances every iteration. + unsafe { + (*self.slots[pos & self.mask].get()).assume_init_drop(); + } + pos = pos.wrapping_add(1); + } + } +} + +/// The writing half of an [`spsc`](self) ring. +/// +/// Neither [`Clone`] nor [`Sync`], which is what makes "single producer" a fact +/// the compiler checks rather than a rule to remember. +pub struct Producer { + shared: Arc>, + /// Removes [`Sync`] without removing [`Send`]. A [`Cell`] is exactly that + /// shape, and no value of it is ever created. + not_sync: PhantomData>, +} + +impl Producer { + /// Appends an item. + /// + /// # Errors + /// + /// [`PushError::Full`] when the queue is at capacity, which is the + /// backpressure signal rather than a malfunction, and + /// [`PushError::Disconnected`] when the consumer is gone. Either way the + /// item comes back, so nothing is lost by the refusal. + pub fn push(&self, item: T) -> Result<(), PushError> { + // Relaxed: this thread is the only writer of `tail`, so it cannot read + // a stale value of its own. + let tail = self.shared.tail.0.load(Ordering::Relaxed); + // Acquire: pairs with the consumer's release store, so a slot it freed + // is visible as free here. + let head = self.shared.head.0.load(Ordering::Acquire); + + if tail.wrapping_sub(head) == self.shared.capacity { + // Report disconnection in preference to fullness: a full queue + // whose consumer is gone will never drain, and telling the caller + // to retry would be telling it to spin forever. + if !self.shared.consumer_live.load(Ordering::Acquire) { + return Err(PushError::Disconnected(item)); + } + return Err(PushError::Full(item)); + } + if !self.shared.consumer_live.load(Ordering::Acquire) { + return Err(PushError::Disconnected(item)); + } + + // SAFETY: `tail` is outside `[head, tail)`, so this slot is owned by + // the producer and holds no initialized item. Writing a `MaybeUninit` + // over uninitialized memory drops nothing. + unsafe { + (*self.shared.slots[tail & self.shared.mask].get()).write(item); + } + + // Release: publishes the slot write to the consumer's acquire load. The + // store must come after the write, and this is what forbids the + // compiler and the processor from moving it earlier. + self.shared + .tail + .0 + .store(tail.wrapping_add(1), Ordering::Release); + Ok(()) + } + + /// The exact number of items this queue holds when full. + #[must_use] + pub fn capacity(&self) -> usize { + self.shared.capacity + } + + /// Items currently held, as a snapshot. + #[must_use] + pub fn len(&self) -> usize { + self.shared.len() + } + + /// Whether the queue holds nothing, as a snapshot. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Whether the next push would be refused for want of room, as a snapshot. + /// + /// Advisory only. Nothing is gained by testing it before [`Self::push`], + /// which reports the same condition without the window in between; it is + /// offered for metrics rather than for control flow. + #[must_use] + pub fn is_full(&self) -> bool { + self.len() == self.shared.capacity + } + + /// Whether the consumer has been dropped. + #[must_use] + pub fn is_disconnected(&self) -> bool { + !self.shared.consumer_live.load(Ordering::Acquire) + } +} + +// Hand-written rather than derived: deriving would demand `T: Debug`, which +// would make a handle to a queue of non-`Debug` items un-printable for no +// reason. The item type is not the handle's business, so the handle reports the +// queue's state instead. +impl fmt::Debug for Producer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("spsc::Producer") + .field("capacity", &self.capacity()) + .field("len", &self.len()) + .field("disconnected", &self.is_disconnected()) + .finish() + } +} + +impl Drop for Producer { + fn drop(&mut self) { + // Release: everything this producer pushed happens-before a consumer + // observing the disconnection, so a consumer that sees it can trust + // that draining to empty really has drained everything. + self.shared.producer_live.store(false, Ordering::Release); + } +} + +/// The reading half of an [`spsc`](self) ring. +/// +/// Neither [`Clone`] nor [`Sync`], which is what makes "single consumer" a fact +/// the compiler checks rather than a rule to remember. +pub struct Consumer { + shared: Arc>, + /// See [`Producer::not_sync`]. + not_sync: PhantomData>, +} + +impl Consumer { + /// Takes the oldest item, or `None` if there is none right now. + /// + /// `None` does not mean the queue is finished. Pair it with + /// [`Self::is_disconnected`] to distinguish "empty for now" from "empty for + /// good"; the order matters, and [`Self::is_disconnected`] documents which + /// way round. + pub fn pop(&self) -> Option { + // Relaxed: this thread is the only writer of `head`. + let head = self.shared.head.0.load(Ordering::Relaxed); + // Acquire: pairs with the producer's release store, so an item it + // published is visible here. + let tail = self.shared.tail.0.load(Ordering::Acquire); + + if head == tail { + return None; + } + + // SAFETY: `head` is in `[head, tail)`, so the producer wrote this slot + // and released it. It is read exactly once, because `head` advances + // below before any other read can observe the slot as free. + let item = + unsafe { (*self.shared.slots[head & self.shared.mask].get()).assume_init_read() }; + + // Release: publishes the slot as free to the producer's acquire load. + // It must come after the read, or the producer could overwrite an item + // this thread has not finished taking. + self.shared + .head + .0 + .store(head.wrapping_add(1), Ordering::Release); + Some(item) + } + + /// The exact number of items this queue holds when full. + #[must_use] + pub fn capacity(&self) -> usize { + self.shared.capacity + } + + /// Items currently held, as a snapshot. + #[must_use] + pub fn len(&self) -> usize { + self.shared.len() + } + + /// Whether the queue holds nothing, as a snapshot. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Whether the producer has been dropped. + /// + /// **Check this only after [`Self::pop`] has returned `None`.** A producer + /// may push and then drop, so a queue can be disconnected and still hold + /// items; testing this first would discard them. Draining to empty and + /// then finding the producer gone is the only order that cannot lose an + /// item, and the release store in the producer's `Drop` is what makes the + /// preceding pushes visible to a consumer that observes it. + #[must_use] + pub fn is_disconnected(&self) -> bool { + !self.shared.producer_live.load(Ordering::Acquire) + } +} + +/// See [`Producer`]'s impl for why this is hand-written. +impl fmt::Debug for Consumer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("spsc::Consumer") + .field("capacity", &self.capacity()) + .field("len", &self.len()) + .field("disconnected", &self.is_disconnected()) + .finish() + } +} + +impl Drop for Consumer { + fn drop(&mut self) { + self.shared.consumer_live.store(false, Ordering::Release); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs new file mode 100644 index 00000000..288d6c98 --- /dev/null +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -0,0 +1,332 @@ +// Copyright (c) Mike Grier. + +//! Tests for the SPSC bounded ring. +//! +//! Every one runs in memory in microseconds. The cross-thread cases use a +//! joined thread rather than a sleep, so they are deterministic: the assertion +//! runs after the peer has finished, not after a guess about how long it takes. + +use super::{Producer, bounded}; +use crate::PushError; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// Counts its own drops, so a test can prove an item was destroyed rather than +/// leaked. `Arc` rather than a `static`, so tests that run +/// concurrently in one process cannot see each other's counts. +#[derive(Debug)] +struct DropCounter(Arc); + +impl Drop for DropCounter { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +#[test] +fn a_pushed_item_comes_back_out() { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + tx.push(42).expect("a fresh queue has room"); + assert_eq!(rx.pop(), Some(42)); +} + +#[test] +fn an_empty_queue_pops_nothing() { + let (_tx, rx) = bounded::(4).expect("a power-of-two capacity"); + assert_eq!(rx.pop(), None); + assert!(rx.is_empty()); + assert_eq!(rx.len(), 0); +} + +#[test] +fn items_come_out_in_the_order_they_went_in() { + let (tx, rx) = bounded::(8).expect("a power-of-two capacity"); + for value in 0..8 { + tx.push(value).expect("room for eight"); + } + let drained: Vec = std::iter::from_fn(|| rx.pop()).collect(); + assert_eq!(drained, (0..8).collect::>()); +} + +#[test] +fn a_full_queue_refuses_and_hands_the_item_back() { + let (tx, rx) = bounded::(2).expect("a power-of-two capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + assert!(tx.is_full()); + + match tx.push(3) { + Err(PushError::Full(returned)) => assert_eq!( + returned, 3, + "the refused item must come back, or a caller cannot retry it" + ), + other => panic!("expected Full, got {other:?}"), + } + + // And the refusal did not disturb what was already there. + assert_eq!(rx.pop(), Some(1)); + assert_eq!(rx.pop(), Some(2)); +} + +#[test] +fn a_capacity_of_one_holds_exactly_one() { + let (tx, rx) = bounded::(1).expect("one is a power of two"); + tx.push(1).expect("room for one"); + assert!(matches!(tx.push(2), Err(PushError::Full(2)))); + assert_eq!(rx.pop(), Some(1)); + tx.push(3).expect("the slot was freed"); + assert_eq!(rx.pop(), Some(3)); +} + +#[test] +fn the_ring_wraps_many_times_without_losing_order() { + // Far more operations than slots, so every slot is reused repeatedly and a + // mistake in the masking or in the free-slot arithmetic shows up as a + // wrong value rather than as a crash. + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + for round in 0..1000 { + tx.push(round).expect("the previous item was taken"); + assert_eq!(rx.pop(), Some(round)); + } + assert!(rx.is_empty()); +} + +#[test] +fn a_partly_full_ring_wraps_correctly() { + // Keeps two items resident while cycling, so head and tail are never equal + // and never a whole lap apart -- the case a simple "empty when equal" test + // never reaches. + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + tx.push(0).expect("room"); + tx.push(1).expect("room"); + for round in 2..500 { + tx.push(round) + .expect("room, because one is taken each round"); + assert_eq!(rx.pop(), Some(round - 2)); + assert_eq!(rx.len(), 2); + } +} + +#[test] +fn len_tracks_pushes_and_pops() { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + assert_eq!(tx.len(), 0); + tx.push(1).expect("room"); + assert_eq!(tx.len(), 1); + assert_eq!(rx.len(), 1, "both handles report the same queue"); + tx.push(2).expect("room"); + assert_eq!(tx.len(), 2); + rx.pop().expect("an item"); + assert_eq!(rx.len(), 1); + rx.pop().expect("an item"); + assert!(rx.is_empty()); +} + +#[test] +fn dropping_the_queue_drops_the_items_it_still_holds() { + let drops = Arc::new(AtomicUsize::new(0)); + { + let (tx, _rx) = bounded::(8).expect("a power-of-two capacity"); + for _ in 0..5 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + } + assert_eq!(drops.load(Ordering::Relaxed), 0, "nothing dropped yet"); + } + assert_eq!( + drops.load(Ordering::Relaxed), + 5, + "every undrained item must be dropped, not leaked" + ); +} + +#[test] +fn dropping_the_queue_after_a_wrap_drops_only_what_is_resident() { + // The interesting case for the drop loop: head and tail are both far from + // zero and the live range straddles the end of the slot array, so a drop + // that iterated `0..len` instead of `head..tail` would destroy the wrong + // slots -- and would drop uninitialized memory. + let drops = Arc::new(AtomicUsize::new(0)); + { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + for _ in 0..6 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + rx.pop().expect("an item"); + } + assert_eq!( + drops.load(Ordering::Relaxed), + 6, + "the six taken were dropped" + ); + + // Now leave three resident, starting from a wrapped position. + for _ in 0..3 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + } + } + assert_eq!( + drops.load(Ordering::Relaxed), + 9, + "the three still resident must also be dropped" + ); +} + +#[test] +fn a_consumer_that_is_gone_turns_a_push_into_a_disconnect() { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + assert!(!tx.is_disconnected()); + drop(rx); + assert!(tx.is_disconnected()); + + match tx.push(1) { + Err(PushError::Disconnected(returned)) => assert_eq!(returned, 1), + other => panic!("expected Disconnected, got {other:?}"), + } +} + +#[test] +fn a_full_queue_whose_consumer_is_gone_reports_disconnected_not_full() { + // The distinction is the whole point of having two variants: Full invites a + // retry, and retrying this one would spin for ever. + let (tx, rx) = bounded::(2).expect("a power-of-two capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + drop(rx); + + match tx.push(3) { + Err(PushError::Disconnected(_)) => {} + Err(PushError::Full(_)) => { + panic!("a full queue with no consumer will never drain, so Full would invite a spin") + } + Ok(()) => panic!("the queue was full"), + } +} + +#[test] +fn a_producer_that_is_gone_leaves_the_queued_items_takeable() { + // Disconnection must not discard what was already pushed, which is why the + // documented order is drain first and check afterwards. + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + drop(tx); + + assert!(rx.is_disconnected()); + assert_eq!(rx.pop(), Some(1), "a dropped producer does not discard"); + assert_eq!(rx.pop(), Some(2)); + assert_eq!(rx.pop(), None); +} + +#[test] +fn a_zero_capacity_is_refused_because_it_could_never_accept_anything() { + let error = bounded::(0).expect_err("zero is not a usable capacity"); + assert_eq!(error.requested(), 0); + assert_eq!(error.next_valid(), Some(1)); +} + +#[test] +fn a_non_power_of_two_capacity_is_refused_with_both_neighbours() { + let error = bounded::(100).expect_err("100 is not a power of two"); + assert_eq!(error.requested(), 100); + assert_eq!( + (error.previous_valid(), error.next_valid()), + (Some(64), Some(128)), + "the error should make the correction obvious without arithmetic" + ); +} + +#[test] +fn every_power_of_two_capacity_up_to_a_reasonable_bound_is_accepted() { + for shift in 0..16 { + let capacity = 1_usize << shift; + let (tx, rx) = bounded::(capacity).expect("a power of two"); + assert_eq!(tx.capacity(), capacity); + assert_eq!(rx.capacity(), capacity, "both handles agree"); + tx.push(shift).expect("a fresh queue has room"); + assert_eq!(rx.pop(), Some(shift)); + } +} + +#[test] +fn a_capacity_above_half_the_address_space_is_refused() { + // Not because the allocation would fail first, but because the position + // arithmetic would become ambiguous across wraparound. Checked explicitly + // so the reason survives even though no machine could allocate it. + let error = bounded::(1_usize << (usize::BITS - 1)).expect_err("too large"); + assert!( + error.next_valid().is_none(), + "there is nothing larger to suggest" + ); +} + +#[test] +fn zero_sized_items_round_trip() { + // A ZST exercises the slot arithmetic with no bytes to copy, so a mistake + // cannot hide behind a memcpy that happens to do the right thing. + let (tx, rx) = bounded::<()>(2).expect("a power-of-two capacity"); + tx.push(()).expect("room"); + tx.push(()).expect("room"); + assert!(matches!(tx.push(()), Err(PushError::Full(())))); + assert_eq!(rx.pop(), Some(())); + assert_eq!(rx.pop(), Some(())); + assert_eq!(rx.pop(), None); +} + +#[test] +fn items_cross_a_thread_boundary_in_order_and_intact() { + // The real test of the memory ordering. Each item carries a value derived + // from its index, so a torn or stale read is a wrong value rather than a + // silent pass. Boxed, so each item is a heap pointer the consumer must + // observe fully initialized -- a missing release would surface as a + // corrupt pointer rather than as a wrong integer. + const COUNT: usize = 20_000; + let (tx, rx) = bounded::>(64).expect("a power-of-two capacity"); + + let producer = std::thread::spawn(move || { + for value in 0..COUNT { + // Spin rather than sleep: the consumer is draining concurrently, + // so a full queue clears in nanoseconds. + let mut item = Box::new(value); + loop { + match tx.push(item) { + Ok(()) => break, + Err(PushError::Full(returned)) => { + item = returned; + std::hint::spin_loop(); + } + Err(PushError::Disconnected(_)) => panic!("the consumer is alive"), + } + } + } + }); + + let mut received = 0_usize; + while received < COUNT { + if let Some(item) = rx.pop() { + assert_eq!(*item, received, "items must arrive in order and intact"); + received += 1; + } else { + std::hint::spin_loop(); + } + } + + producer.join().expect("the producer thread"); + assert_eq!(rx.pop(), None); +} + +#[test] +fn a_producer_can_be_moved_to_another_thread() { + // `Send` is the property that makes the split useful, and it is worth + // pinning: a handle that could not move would force construction on the + // thread that ends up owning it. + fn assert_send() {} + assert_send::>(); + assert_send::>(); + + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + std::thread::spawn(move || { + tx.push(7).expect("room"); + }) + .join() + .expect("the pushing thread"); + assert_eq!(rx.pop(), Some(7)); +} From 66947f4b5af8455c7dd58440cb5fc081beb90525 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 20:26:43 -0400 Subject: [PATCH 021/361] feat(waitable-queues): add the doorbell and join it to the SPSC ring The part the crate is named for: a queue whose readiness is a waitable `HANDLE`, so a consumer can park on it alongside an I/O completion and a shutdown event in one `WaitForMultipleObjects`. `src/doorbell.rs` is a lazily created manual-reset event. Lazy because a consumer that only polls should not be charged for a kernel object, and that laziness is asserted rather than assumed. Manual-reset because the doorbell is a state, not an edge: a wait that reports one of several signalled handles routinely ignores another, and an auto-reset event consumed by that wait would lose the ignored source's only notification. `Consumer` gains `doorbell`, `doorbell_owned`, `arm`, `recv`, and `recv_timeout`, with `RecvError` and `RecvTimeoutError` to match. The producer signals after its release store, never before, and also on `Drop` -- a disconnection is a wakeup, and the only one no other party can deliver. The correctness argument is `Consumer::arm`, which clears the doorbell and *then* re-checks emptiness. That is the reverse of the order that reads naturally, and the natural one is a permanent hang: a push landing between the check and the clear both signals and has its signal erased, leaving a consumer asleep on a queue that is not empty and will never be signalled again. Lazy creation is the same hazard a third time, which is why `arm` creates the event before the check rather than assuming a caller did. Nine sabotages, eight defects and one control, all behaving as expected. The eight are caught -- three of them as hangs, which is the correct shape for a lost-wakeup defect and the reason the harness judges by exit code under a timeout rather than by reading output. The control removes the skip-redundant-signal optimization and must NOT fail; it does not, so the suite asserts the contract rather than the implementation. The sweep found two things that reading the code had not. A test gap: the drain-after-disconnect guard sat in a race window no test could reach, so breaking it changed nothing -- now extracted as `Consumer::finish`, a named step a test can call directly instead of hoping to schedule the window. And a harness defect: one sabotage inserted dead code beside the live call instead of deleting it, so it sabotaged nothing and the resulting pass read as a hole in the tests. A sabotage that does not sabotage is worse than none, because it retires a question that was never asked. D-5 said the reset must be "atomic" with the observation of emptiness. That is how a lock achieves it and not the only way, and read literally it would have condemned the lock-free implementation shipped here. Amended, with the lock-free realization recorded as D-9, rather than leaving two statements of one rule to disagree. These two items land together because they are not independent: a `Doorbell` no queue calls is dead code, and this workspace builds with `-D warnings`, so M30.4 cannot compile alone. Recorded as an acknowledged checklist-structuring defect rather than papered over by making the type public to silence a lint. Completed item: M30.4: The doorbell, as its own reviewable unit: a queue-owned manual-reset event created lazily, with level semantics, handed out as a borrowed handle plus an owned duplicate. Completed item: M30.5: Join the two, and sabotage-verify the lost-wakeup guard: a test that reverses the reset and the emptiness check must deadlock, and must stop deadlocking when the order is restored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 36 +- .../windows-waitable-queues/DESIGN-NOTES.md | 57 ++- .../windows-waitable-queues/src/doorbell.rs | 271 ++++++++++ .../src/doorbell/tests.rs | 315 ++++++++++++ crates/windows-waitable-queues/src/error.rs | 106 ++++ crates/windows-waitable-queues/src/lib.rs | 9 +- crates/windows-waitable-queues/src/spsc.rs | 209 +++++++- .../windows-waitable-queues/src/spsc/tests.rs | 468 +++++++++++++++++- 8 files changed, 1457 insertions(+), 14 deletions(-) create mode 100644 crates/windows-waitable-queues/src/doorbell.rs create mode 100644 crates/windows-waitable-queues/src/doorbell/tests.rs diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 2b59969f..db50c6e1 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -125,17 +125,47 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m actual exit 101) that CI would have caught. Fixed in the preceding commit. Redirect with `*>` and read `$LASTEXITCODE` before any pipe. -- [ ] **M30.4** -- The doorbell, as its own reviewable unit: a queue-owned **manual-reset** event created +- [x] **M30.4** -- The doorbell, as its own reviewable unit: a queue-owned **manual-reset** event created **lazily**, so a polling-only consumer allocates no kernel object. Level semantics -- signalled exactly when the consumer has something to observe. **The reset must be atomic with the observation that there is nothing to take; the signal need not be** (C-1b measured why: a late signal is a spurious wakeup, a stale reset is a lost one). Hand it out as a borrowed handle plus an owned duplicate, per the file-watcher's precedent. - -- [ ] **M30.5** -- Join the two, and **sabotage-verify the lost-wakeup guard**: a test that reverses the + **Landed together with M30.5 in one commit, because these two items are not independent and the + checklist was wrong to split them.** A `Doorbell` that no queue calls is dead code, and this workspace + builds with `-D warnings`, so M30.4 cannot compile on its own. Recorded as an acknowledged + structuring defect rather than worked around by widening the type's visibility to silence the lint -- + making an API public to dodge a warning is a real design decision taken for a fake reason. + Delivered as `src/doorbell.rs`: lazily created (a poll-only consumer allocates no kernel object, + asserted, not assumed), manual-reset, with `handle` / `owned` / `signal` / `clear`. The redundant + signal is skipped through an `AtomicBool` mirroring the event, which is sound in exactly one + direction -- see the done-note on M30.5 for the asymmetry that permits it. + +- [x] **M30.5** -- Join the two, and **sabotage-verify the lost-wakeup guard**: a test that reverses the reset and the emptiness check must deadlock, and must stop deadlocking when the order is restored. A wakeup invariant asserted only by a passing test is a test of nothing -- this is the same discipline the ioring crate's `wait_then_drain` and the M17.4 calibration established. + **Done. The guard is `Consumer::arm`, which clears the doorbell and *then* checks emptiness** -- the + reverse of the order that reads naturally, which is why it needed proving rather than asserting. The + sabotage test drives the race deterministically on one thread (an interleaving that must be hit to + prove a point is not one to leave to the scheduler) and ends in a real bounded `WaitForSingleObject`: + reversed, it returns `WAIT_TIMEOUT` with an item sitting in the queue -- the lost wakeup, reproduced; + correct, the check finds the item and never waits at all. + **Nine sabotages, eight defects and one control, all behaving as expected.** Caught: push not + signalling; producer `Drop` not signalling; `arm` checking before clearing; `arm` not creating the + doorbell before checking; the final drain returning nothing; `clear` resetting the event but not the + mirror flag; auto-reset instead of manual-reset; the event created already signalled. Three of those + are caught **as hangs rather than failures**, which is the correct shape for a lost-wakeup defect. + **The control matters as much as the defects:** removing the skip-redundant-signal optimisation must + *not* fail, and does not -- so the suite is asserting the contract rather than the implementation. + **The sweep paid for itself twice, and neither finding came from reading the code.** + (1) A test gap: the drain-after-disconnect guard sat in a race window no test could reach, so + breaking it changed nothing. Fixed by extracting it as `Consumer::finish`, a named step a test can + call directly instead of hoping to schedule the window. + (2) A harness defect: one sabotage inserted `if false { signal(); }` beside the live call instead of + deleting it, so it sabotaged nothing and the resulting pass read as a hole in the tests. A sabotage + that does not sabotage is worse than none, because it retires a question that was never asked -- + always confirm the injected defect actually changes behaviour before believing a "not caught". ## M31 -- The MPSC shape and the queue's contract diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 98f92486..688a7a5f 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -27,7 +27,8 @@ preferred. | D-2 | **Capabilities are sliced into narrow traits, not gathered into one.** The `std::io` shape -- `Read`, `Write`, `Seek`, `BufRead` -- rather than a single fat `WaitableQueue`. Forced by the shapes themselves: a poll-only queue cannot implement a trait containing `doorbell()`, and an unbounded one cannot implement `capacity()` meaningfully. | | D-3 | **No trait ships until a second implementation exists to validate it.** The trait *shape* is fixed now so signatures stay compatible; the traits themselves land with the second shape. | | D-4 | **Every shape is split into producer and consumer handles, and cardinality is carried by `Clone`.** Single-producer becomes a compile-time guarantee rather than a documented precondition. | -| D-5 | **The doorbell is level state owned by the queue: signalled exactly when the consumer has something to observe.** The **reset** must be atomic with the observation that there is nothing to take; the **signal** need not be. Manual-reset, and created lazily. | +| D-5 | **The doorbell is level state owned by the queue: signalled exactly when the consumer has something to observe.** The **reset** must not be separable from the observation that there is nothing to take; the **signal** may be. Manual-reset, and created lazily. Realized without a lock by [D-9](#d-9). | +| D-9 | **Without a lock, the reset is made inseparable from the observation by ordering: clear first, then re-check, and never wait if the re-check finds anything.** `Consumer::arm` is that step. The natural order -- check, then clear -- is the lost wakeup, and is asserted to hang by a deliberate sabotage rather than argued to be wrong. | | D-6 | **Overflow fails or reserves, and never overwrites.** For telemetry an overwritten entry is a lost sample; for an I/O submission it is a lost operation, and the two must not share a policy knob. | | D-7 | **Shapes are plain modules, not Cargo features, until compile time justifies otherwise.** Two features are four configurations to test, against a benefit dead-code elimination already provides. | | D-8 | **Published, and the obligation is accepted deliberately.** Unlike `windows-guard-alloc`, this is general-purpose and its first consumer is not its only plausible one. | @@ -114,9 +115,13 @@ The asymmetry is the part that is easy to get wrong, and it was worked out by wa - **The signal may be given outside any lock.** A late `SetEvent` can at worst arrive after the consumer already drained that item and parked, which produces a spurious wakeup: the consumer wakes, finds nothing, parks again. Harmless, and consumers must tolerate it regardless. -- **The reset must be atomic with the observation that there is nothing to take.** Otherwise: consumer - drains to empty, producer pushes and signals, consumer resets -- clearing the signal for an item that - is still there -- and parks. That wakeup is lost and the item is stranded. +- **The reset must not be separable from the observation that there is nothing to take.** Otherwise: + consumer drains to empty, producer pushes and signals, consumer resets -- clearing the signal for an + item that is still there -- and parks. That wakeup is lost and the item is stranded. + An earlier wording of this said the two must be *atomic*, which is how a lock achieves it but not the + only way, and taken literally it would have condemned the lock-free implementation this crate actually + ships. What is required is that no push can fall between them unnoticed; [D-9](#d-9) gets that from + ordering plus a re-check instead of from mutual exclusion. So a redundant signal is free and a stale reset is fatal, which is the whole reason the queue owns its doorbell rather than accepting one. The same invariant, reached independently, is stated in @@ -139,6 +144,50 @@ twenty-three the doorbell already costs less per operation than the push it acco one requiring the consumer to publish whether it is parked is deferred until a measurement against real work justifies its lost-wakeup risk. +## D-9: the arming protocol, which is how a lock-free queue keeps D-5 + +[D-5](#d-5) says the reset must not be separable from the observation that there is nothing to take. A +lock-based queue gets that by doing both under the lock it already holds, which is what +[windows-file-watcher's queue](../windows-file-watcher/src/queue.rs) does. This crate's shapes are +lock-free by construction -- a producer-side lock serializes exactly what multi-producer exists to +parallelize -- so the property has to come from somewhere else. + +It comes from **ordering plus a re-check**, and the order is the reverse of the one that reads +naturally: + +1. Take everything available. +2. Clear the doorbell. +3. **Check emptiness again.** If anything is there, do not wait. +4. Wait. + +`Consumer::arm` is steps 2 and 3, and returns whether step 4 is safe. Step 3 is what carries the +guarantee: an item arriving before the clear is found by the check, and an item arriving after the clear +signals a doorbell that is no longer about to be reset. There is no third case. + +**Check-then-clear is the lost wakeup**, and it is the easier code to write: a push landing between the +check and the clear both signals and has its signal erased, so the consumer sleeps on a queue that is +not empty and will never be signalled again. Not a stall -- a permanent hang. + +**Lazy creation is a third case of the same hazard.** A producer running while no event exists skips +signalling, because there is nothing to signal. So the doorbell must be created *before* the emptiness +check that decides to wait, which is why `arm` creates it rather than assuming a caller did. Making the +initial state agree with the queue at creation time would not fix this and was rejected: doing it +race-free needs sequential consistency on both the event pointer and the queue position, which is a +`SeqCst` fence on the producer's hot path to close a hole the re-check already closes for free. + +**This is asserted by sabotage, not by argument.** The suite reverses steps 2 and 3 deliberately and +requires the result to hang -- a real `WaitForSingleObject` that returns `WAIT_TIMEOUT` while an item +sits in the queue. The race is driven deterministically from one thread, because an interleaving that +must be hit to prove a point is not one to leave to the scheduler. Three further sabotages (push not +signalling, producer `Drop` not signalling, `clear` not resetting the mirror flag) are likewise caught +*as hangs*, which is the correct shape for this class of defect and the reason the sabotage harness +judges by exit code with a timeout rather than by reading output. + +**The signal side is cheapened, and a control proves that is all it is.** An `AtomicBool` mirrors the +event so a redundant `SetEvent` costs ~7 ns instead of ~81. Removing that optimization must leave the +suite green -- and does. Had it failed, the tests would have been asserting the implementation instead +of the contract. + ## D-6: overflow fails or reserves, and never overwrites Three policies, and the absence of a fourth: diff --git a/crates/windows-waitable-queues/src/doorbell.rs b/crates/windows-waitable-queues/src/doorbell.rs new file mode 100644 index 00000000..7df60a4d --- /dev/null +++ b/crates/windows-waitable-queues/src/doorbell.rs @@ -0,0 +1,271 @@ +// Copyright (c) Mike Grier. + +//! A queue's readiness, expressed as a waitable Windows `HANDLE`. +//! +//! This is the part of the crate its name refers to. A queue shape owns a +//! [`Doorbell`] and keeps it in agreement with its own emptiness; a client that +//! wants to park on the queue *and* on an I/O completion *and* on a shutdown +//! event in one wait borrows the handle and hands it to +//! `WaitForMultipleObjects` alongside the others. +//! +//! # Manual-reset, and level-triggered +//! +//! The event is manual-reset, so it means "there is something to take" rather +//! than "something arrived". That is the difference between a state and an +//! edge, and only the state composes: `WaitForMultipleObjects` may report any +//! one of several signalled handles, so a waiter routinely learns about one +//! ready source while ignoring another. An auto-reset event consumed by that +//! wait would lose the second source's only edge. A level survives being +//! ignored, and will still be there on the next pass. +//! +//! The crate's own probe made this concrete before the design was fixed: an +//! auto-reset event does not count signals, so two pushes and one wait leave a +//! consumer blocked forever on an item that is sitting in the queue. +//! +//! # Created lazily, so polling is free +//! +//! A consumer that only ever calls `pop` in a loop of its own never needs a +//! kernel object, and should not be charged for one. The event is therefore +//! created on the first request for the handle and not before, following the +//! precedent already set by `windows-file-watcher`'s notification queue. +//! +//! The cost of that laziness is a race worth stating plainly: a producer that +//! runs while no event exists yet skips signalling, because there is nothing to +//! signal. If a consumer could create the doorbell and then immediately wait on +//! it, an item pushed during that window would never wake anyone. What closes +//! the hole is the arming protocol below, not the creation itself -- the +//! doorbell must exist *before* the emptiness check that decides to wait. +//! +//! # The arming protocol, which is the whole correctness argument +//! +//! [`Doorbell`] cannot enforce this itself, because it cannot see the queue. A +//! shape that owns one must observe this order, and no other: +//! +//! 1. Take everything available. +//! 2. [`Doorbell::clear`]. +//! 3. **Check emptiness again.** If anything is there, do not wait -- go to 1. +//! 4. Wait on the handle. +//! +//! The re-check at step 3 is not an optimisation, and removing it is not a +//! missed wakeup once in a while -- it is a permanent hang. A producer that +//! pushes between steps 1 and 2 may signal before the clear at step 2 erases +//! it, leaving an item in the queue and the doorbell unsignalled. Nothing later +//! will signal again, because nothing later will arrive. +//! +//! Reversing steps 2 and 3 -- checking emptiness and then clearing -- fails the +//! same way and is the easier mistake to make, because it reads more naturally. +//! `spsc`'s test suite asserts this by reversing them deliberately and +//! requiring the result to hang. +//! +//! A lock-based queue gets this for free by clearing under the lock it already +//! holds while deciding there is nothing to take, which is what the file +//! watcher does. A lock-free queue has no such lock, so the ordering above is +//! the substitute, and it has to be written down because the compiler will not +//! ask about it. +//! +//! # Why a redundant signal is skipped, but a redundant clear is not +//! +//! The two directions are not symmetric, and the asymmetry is the reason this +//! type keeps a flag at all. +//! +//! A **late signal** is a spurious wakeup: a waiter wakes, finds nothing, and +//! waits again. A **stale clear** is a lost wakeup: a waiter sleeps on a +//! non-empty queue forever. Cheapening the signal side is therefore safe, and +//! cheapening the clear side is not. +//! +//! So `signal` keeps an [`AtomicBool`] mirroring the event and returns without +//! a syscall when the event is already signalled. On this crate's reference +//! machine `SetEvent` on an already-signalled event measured 81.2 ns against +//! 7.2 ns for an uncontended atomic, so a backlogged producer that would +//! otherwise pay a syscall per push pays roughly a tenth of one. +//! +//! The flag is allowed to disagree with the event briefly, and that is sound in +//! exactly one direction: it may claim signalled while the `SetEvent` has not +//! landed yet, which costs a skipped redundant signal, never a skipped +//! necessary one. It is never permitted to claim clear while the event is +//! signalled in a way that matters, because [`Doorbell::clear`] writes the flag +//! before touching the event. + +use std::io; +use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}; +use std::ptr; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; + +use windows_sys::Win32::Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle, FALSE, TRUE}; +use windows_sys::Win32::System::Threading::{ + CreateEventW, GetCurrentProcess, ResetEvent, SetEvent, +}; + +/// A lazily created manual-reset event that reports whether a queue has +/// anything to take. +/// +/// See the [module documentation](self) for the arming protocol every owner +/// must follow; this type cannot enforce it, because it cannot see the queue. +pub(crate) struct Doorbell { + /// The event, absent until somebody asks for the handle. + event: OnceLock, + /// Mirrors the event's state so a redundant [`Doorbell::signal`] can skip + /// its syscall. Only [`Doorbell::signal`] and [`Doorbell::clear`] write it. + signalled: AtomicBool, +} + +impl Doorbell { + /// A doorbell that owns no kernel object yet. + pub(crate) const fn new() -> Self { + Self { + event: OnceLock::new(), + signalled: AtomicBool::new(false), + } + } + + /// Borrow the event, creating it on the first call. + /// + /// The borrow is deliberate: the event belongs to the queue and must not be + /// closed by a caller. Use [`Doorbell::owned`] where ownership is required, + /// such as arming a `ThreadpoolWait`. + /// + /// The event is created unsignalled regardless of what the queue holds, + /// because this type cannot see the queue. The owner is responsible for + /// bringing it into agreement, which the arming protocol does for free: the + /// re-check after [`Doorbell::clear`] runs after creation, so an item that + /// arrived before the doorbell existed is found rather than waited on. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` on the first call. + pub(crate) fn handle(&self) -> io::Result> { + if let Some(event) = self.event.get() { + return Ok(event.as_handle()); + } + // A racing caller may win the `set`, in which case ours is dropped and + // closed and theirs is used. Both are unsignalled, so the loser's + // disappearance costs nothing; only one event can ever be published. + let created = create_event()?; + let _ = self.event.set(created); + Ok(self + .event + .get() + .expect("the doorbell was just published") + .as_handle()) + } + + /// A duplicate of [`Doorbell::handle`] that the caller owns. + /// + /// The duplicate refers to the same event, so signalling reaches both, and + /// the caller may close its copy whenever it likes. This is the form a + /// `ThreadpoolWait` needs, since arming one takes ownership of its target. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` or `DuplicateHandle`. + pub(crate) fn owned(&self) -> io::Result { + duplicate(self.handle()?) + } + + /// Report that the queue has something to take. + /// + /// Does nothing when no handle has ever been requested, and nothing when + /// the event is already signalled. Both skips are safe; see the [module + /// documentation](self) for why the signal side may be cheapened and the + /// clear side may not. + /// + /// A failure of `SetEvent` is not reported. There is no useful reaction on + /// a producer's hot path, and the only documented failures are invalid + /// handles, which cannot occur for an event this type owns for its whole + /// lifetime. + pub(crate) fn signal(&self) { + let Some(event) = self.event.get() else { + // Nobody is waiting on a handle that does not exist. A consumer + // that creates one later re-checks the queue before waiting, so + // this skip cannot strand an item. + return; + }; + if self.signalled.swap(true, Ordering::AcqRel) { + // Already signalled, and a manual-reset event does not count, so + // setting it again would change nothing. + return; + } + // SAFETY: a live manual-reset event owned by this type for as long as + // it exists; `SetEvent` has no other precondition. + unsafe { + SetEvent(event.as_raw_handle()); + } + } + + /// Report that the queue appears to have nothing to take. + /// + /// **The caller must re-check emptiness after this returns**, and must not + /// wait if the re-check finds anything. See the [module + /// documentation](self); this is the step whose omission is a permanent + /// hang rather than an occasional stall. + pub(crate) fn clear(&self) { + let Some(event) = self.event.get() else { + return; + }; + // Written before the event is reset, so a producer racing this call + // sees a clear flag and issues a real `SetEvent`. That signal may then + // be erased by the `ResetEvent` below -- which is precisely why the + // caller's re-check, and not this ordering, is what carries the + // guarantee. + self.signalled.store(false, Ordering::Release); + // SAFETY: as in `signal`. + unsafe { + ResetEvent(event.as_raw_handle()); + } + } + + /// Whether the event has been created, for tests and for asserting that + /// laziness actually holds. + #[cfg(test)] + pub(crate) fn is_armed(&self) -> bool { + self.event.get().is_some() + } +} + +impl std::fmt::Debug for Doorbell { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Doorbell") + .field("created", &self.event.get().is_some()) + .field("signalled", &self.signalled.load(Ordering::Relaxed)) + .finish() + } +} + +/// Create an unnamed, unsignalled, manual-reset event. +fn create_event() -> io::Result { + // SAFETY: creates an unnamed event with default security attributes; both + // pointer arguments are null by design. + let raw = unsafe { CreateEventW(ptr::null(), TRUE, FALSE, ptr::null()) }; + if raw.is_null() { + return Err(io::Error::last_os_error()); + } + // SAFETY: the call returned a fresh, exclusively owned event handle. + Ok(unsafe { OwnedHandle::from_raw_handle(raw) }) +} + +/// Duplicate a handle into this process, so the caller owns its own copy. +fn duplicate(handle: BorrowedHandle<'_>) -> io::Result { + let mut duplicated = ptr::null_mut(); + // SAFETY: duplicates a live handle within this process with the same + // access; `duplicated` is a valid out-pointer for the call's duration. + let ok = unsafe { + DuplicateHandle( + GetCurrentProcess(), + handle.as_raw_handle(), + GetCurrentProcess(), + &raw mut duplicated, + 0, + FALSE, + DUPLICATE_SAME_ACCESS, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: the call succeeded, so `duplicated` is a fresh owned handle. + Ok(unsafe { OwnedHandle::from_raw_handle(duplicated) }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/doorbell/tests.rs b/crates/windows-waitable-queues/src/doorbell/tests.rs new file mode 100644 index 00000000..27fa726b --- /dev/null +++ b/crates/windows-waitable-queues/src/doorbell/tests.rs @@ -0,0 +1,315 @@ +// Copyright (c) Mike Grier. + +//! Tests for the doorbell in isolation, with no queue attached. +//! +//! These assert the properties the arming protocol is built on -- laziness, +//! level semantics, and that a redundant signal is skipped without losing a +//! necessary one. The protocol *itself* cannot be tested here, because it is a +//! statement about a queue this type cannot see; that is `spsc`'s job. + +use std::os::windows::io::AsRawHandle; + +use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::System::Threading::WaitForSingleObject; + +use super::Doorbell; + +/// Whether the doorbell is signalled right now, by asking the kernel rather +/// than by reading the mirror flag. +/// +/// A test that consulted the flag would be testing the flag against itself. The +/// zero timeout makes this a state query rather than a wait, and it does not +/// consume the signal because the event is manual-reset. +fn is_signalled(doorbell: &Doorbell) -> bool { + let handle = doorbell.handle().expect("the doorbell must be creatable"); + // SAFETY: a live event handle borrowed for the call; a zero timeout returns + // immediately and has no other precondition. + let result = unsafe { WaitForSingleObject(handle.as_raw_handle(), 0) }; + assert!( + result == WAIT_OBJECT_0 || result == WAIT_TIMEOUT, + "the wait must resolve to signalled or not, got {result:#x}" + ); + result == WAIT_OBJECT_0 +} + +#[test] +fn creates_no_kernel_object_until_asked() { + let doorbell = Doorbell::new(); + assert!( + !doorbell.is_armed(), + "a fresh doorbell must own no event, so a polling consumer pays nothing" + ); +} + +#[test] +fn signalling_an_unarmed_doorbell_creates_nothing() { + let doorbell = Doorbell::new(); + doorbell.signal(); + doorbell.signal(); + assert!( + !doorbell.is_armed(), + "a producer must not conjure a kernel object nobody asked for" + ); +} + +#[test] +fn clearing_an_unarmed_doorbell_creates_nothing() { + let doorbell = Doorbell::new(); + doorbell.clear(); + assert!(!doorbell.is_armed(), "clearing must not create the event"); +} + +#[test] +fn asking_for_the_handle_creates_the_event() { + let doorbell = Doorbell::new(); + let _handle = doorbell.handle().expect("creation must succeed"); + assert!( + doorbell.is_armed(), + "the handle request must create the event" + ); +} + +#[test] +fn the_handle_is_stable_across_calls() { + let doorbell = Doorbell::new(); + let first = doorbell + .handle() + .expect("creation must succeed") + .as_raw_handle(); + let second = doorbell + .handle() + .expect("creation must succeed") + .as_raw_handle(); + assert_eq!( + first, second, + "the event is created once, so every borrow must name the same object" + ); +} + +#[test] +fn a_new_doorbell_is_unsignalled() { + let doorbell = Doorbell::new(); + assert!( + !is_signalled(&doorbell), + "a doorbell must not claim readiness before anything is pushed" + ); +} + +#[test] +fn signal_makes_it_signalled() { + let doorbell = Doorbell::new(); + let _handle = doorbell.handle().expect("creation must succeed"); + doorbell.signal(); + assert!(is_signalled(&doorbell), "signalling must be observable"); +} + +#[test] +fn the_signal_is_a_level_and_survives_being_observed() { + let doorbell = Doorbell::new(); + let _handle = doorbell.handle().expect("creation must succeed"); + doorbell.signal(); + + // Three observations, because the failure this guards against is an + // auto-reset event, where the first wait consumes the signal and the second + // blocks. That exact mistake hung the crate's own doorbell probe for four + // hundred seconds before the design was fixed. + for observation in 1..=3 { + assert!( + is_signalled(&doorbell), + "observation {observation} must still see the level" + ); + } +} + +#[test] +fn clear_makes_it_unsignalled() { + let doorbell = Doorbell::new(); + let _handle = doorbell.handle().expect("creation must succeed"); + doorbell.signal(); + doorbell.clear(); + assert!(!is_signalled(&doorbell), "clearing must reset the level"); +} + +#[test] +fn a_signal_after_a_clear_is_delivered() { + let doorbell = Doorbell::new(); + let _handle = doorbell.handle().expect("creation must succeed"); + + // The sequence that the skip-redundant-signals flag could plausibly break: + // if `clear` failed to reset the flag, this second signal would be skipped + // and the doorbell would stay dark with an item waiting. + doorbell.signal(); + doorbell.clear(); + doorbell.signal(); + + assert!( + is_signalled(&doorbell), + "a signal after a clear is the one signal that must never be skipped" + ); +} + +#[test] +fn many_clear_signal_cycles_stay_in_agreement() { + let doorbell = Doorbell::new(); + let _handle = doorbell.handle().expect("creation must succeed"); + + for cycle in 0..64 { + doorbell.signal(); + assert!(is_signalled(&doorbell), "cycle {cycle} must signal"); + doorbell.clear(); + assert!(!is_signalled(&doorbell), "cycle {cycle} must clear"); + } +} + +#[test] +fn repeated_signals_remain_signalled() { + let doorbell = Doorbell::new(); + let _handle = doorbell.handle().expect("creation must succeed"); + + // The redundant ones take the skip path. The observable state must not + // depend on how many were issued. + for _ in 0..16 { + doorbell.signal(); + } + assert!( + is_signalled(&doorbell), + "redundant signals must not clear it" + ); + + doorbell.clear(); + assert!( + !is_signalled(&doorbell), + "one clear must undo any number of signals, because the event is a level" + ); +} + +#[test] +fn repeated_clears_remain_clear() { + let doorbell = Doorbell::new(); + let _handle = doorbell.handle().expect("creation must succeed"); + doorbell.signal(); + for _ in 0..16 { + doorbell.clear(); + } + assert!( + !is_signalled(&doorbell), + "redundant clears must not signal it" + ); +} + +#[test] +fn a_signal_issued_before_the_handle_existed_is_not_delivered() { + let doorbell = Doorbell::new(); + + // This is the lazy-creation hole, asserted rather than hoped about: the + // producer ran while there was no event, so its signal went nowhere. The + // arming protocol's re-check is what makes this survivable, and that is + // tested against a real queue in `spsc`. + doorbell.signal(); + + assert!( + !is_signalled(&doorbell), + "a doorbell created after the fact cannot know what it missed, which is \ + precisely why the owner must re-check emptiness before waiting" + ); +} + +#[test] +fn the_owned_duplicate_names_the_same_event() { + let doorbell = Doorbell::new(); + let owned = doorbell.owned().expect("duplication must succeed"); + + // A distinct handle value, but the same underlying object: signalling + // through the queue's copy must be visible through the caller's. + doorbell.signal(); + + // SAFETY: `owned` is a live event handle; a zero timeout returns at once. + let result = unsafe { WaitForSingleObject(owned.as_raw_handle(), 0) }; + assert_eq!( + result, WAIT_OBJECT_0, + "the duplicate must observe the original's signal" + ); + + doorbell.clear(); + // SAFETY: as above. + let result = unsafe { WaitForSingleObject(owned.as_raw_handle(), 0) }; + assert_eq!( + result, WAIT_TIMEOUT, + "the duplicate must observe the original's clear" + ); +} + +#[test] +fn dropping_the_owned_duplicate_leaves_the_doorbell_usable() { + let doorbell = Doorbell::new(); + let owned = doorbell.owned().expect("duplication must succeed"); + drop(owned); + + // The caller closing its own copy must not close the queue's. If it did, + // this signal would be a use-after-close rather than a no-op. + doorbell.signal(); + assert!( + is_signalled(&doorbell), + "the queue's event must outlive any duplicate handed out" + ); +} + +#[test] +fn a_duplicate_taken_before_a_signal_still_sees_it() { + let doorbell = Doorbell::new(); + let owned = doorbell.owned().expect("duplication must succeed"); + doorbell.signal(); + + // SAFETY: `owned` is a live event handle; a zero timeout returns at once. + let result = unsafe { WaitForSingleObject(owned.as_raw_handle(), 0) }; + assert_eq!( + result, WAIT_OBJECT_0, + "duplication order must not affect what the duplicate observes" + ); +} + +#[test] +fn several_duplicates_all_observe_the_same_state() { + let doorbell = Doorbell::new(); + let handles: Vec<_> = (0..4) + .map(|_| doorbell.owned().expect("duplication must succeed")) + .collect(); + doorbell.signal(); + + for (index, handle) in handles.iter().enumerate() { + // SAFETY: each is a live event handle; a zero timeout returns at once. + let result = unsafe { WaitForSingleObject(handle.as_raw_handle(), 0) }; + assert_eq!( + result, WAIT_OBJECT_0, + "duplicate {index} must see the signal" + ); + } +} + +#[test] +fn a_waiting_thread_is_released_by_a_signal() { + use std::sync::Arc; + use std::thread; + + // The property the whole crate exists for, end to end through the kernel: + // a thread parked in a real blocking wait is released by `signal`. Every + // other test here uses a zero timeout, which never actually blocks. + let doorbell = Arc::new(Doorbell::new()); + let waiter = doorbell.clone(); + let handle = waiter.owned().expect("duplication must succeed"); + + let joiner = thread::spawn(move || { + // Five seconds is not a timing assertion; it is a bound so a broken + // doorbell fails the suite instead of hanging it forever. + // SAFETY: a live event handle owned by this thread for the call. + unsafe { WaitForSingleObject(handle.as_raw_handle(), 5_000) } + }); + + doorbell.signal(); + + let result = joiner.join().expect("the waiting thread must not panic"); + assert_eq!( + result, WAIT_OBJECT_0, + "a blocked waiter must be released by a signal, not by the timeout" + ); +} diff --git a/crates/windows-waitable-queues/src/error.rs b/crates/windows-waitable-queues/src/error.rs index f21f4a5d..f65339b7 100644 --- a/crates/windows-waitable-queues/src/error.rs +++ b/crates/windows-waitable-queues/src/error.rs @@ -7,6 +7,7 @@ //! returns a differently-named error meaning the same thing. use core::fmt; +use std::io; /// Why a capacity was rejected at construction. /// @@ -156,3 +157,108 @@ impl fmt::Display for PushError { } impl core::error::Error for PushError {} + +/// Why a blocking receive gave up. +/// +/// There is no `Empty` variant, because a blocking receive does not return on +/// an empty queue -- it waits. Emptiness is only ever terminal when the +/// producer is gone as well, and that is [`RecvError::Disconnected`]. +#[derive(Debug)] +#[non_exhaustive] +pub enum RecvError { + /// Every producer has been dropped and the queue has been drained. + /// + /// Reported only after the queue is genuinely empty, never merely because + /// the producer went away: a producer may push and then drop, and those + /// items are still owed to the consumer. + Disconnected, + /// A Windows call failed while creating or waiting on the doorbell. + /// + /// Kept distinct from [`RecvError::Disconnected`] because the two demand + /// opposite reactions: disconnection is the orderly end of a stream, while + /// this means the wait itself is broken and retrying will not help. + Io(io::Error), +} + +impl From for RecvError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl fmt::Display for RecvError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Disconnected => { + f.write_str("the queue is empty and every producer has been dropped") + } + Self::Io(error) => write!(f, "waiting on the queue's doorbell failed: {error}"), + } + } +} + +impl core::error::Error for RecvError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::Disconnected => None, + Self::Io(error) => Some(error), + } + } +} + +/// Why a blocking receive with a deadline gave up. +/// +/// Distinct from [`RecvError`] rather than a variant of it, so a caller that +/// cannot time out is not obliged to handle a case that cannot happen. +#[derive(Debug)] +#[non_exhaustive] +pub enum RecvTimeoutError { + /// The deadline passed with the queue still empty. + /// + /// The queue is still live, and this is not a malfunction: a caller polling + /// with a short deadline will see it constantly and should simply ask + /// again. + Timeout, + /// Every producer has been dropped and the queue has been drained. + Disconnected, + /// A Windows call failed while creating or waiting on the doorbell. + Io(io::Error), +} + +impl RecvTimeoutError { + /// Whether asking again could succeed. + /// + /// True only for [`RecvTimeoutError::Timeout`]. Both other variants are + /// terminal -- no further item will ever arrive, so retrying is a spin. + #[must_use] + pub const fn is_retryable(&self) -> bool { + matches!(self, Self::Timeout) + } +} + +impl From for RecvTimeoutError { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +impl fmt::Display for RecvTimeoutError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Timeout => f.write_str("the queue was still empty when the deadline passed"), + Self::Disconnected => { + f.write_str("the queue is empty and every producer has been dropped") + } + Self::Io(error) => write!(f, "waiting on the queue's doorbell failed: {error}"), + } + } +} + +impl core::error::Error for RecvTimeoutError { + fn source(&self) -> Option<&(dyn core::error::Error + 'static)> { + match self { + Self::Timeout | Self::Disconnected => None, + Self::Io(error) => Some(error), + } + } +} diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 5d15ef67..3f1849ff 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -43,9 +43,9 @@ //! //! # Status //! -//! [`spsc`] is implemented and has no doorbell yet -- it is a plain concurrent -//! ring, and the `HANDLE` the crate is named for arrives with the milestone -//! after it. The remaining shapes land in the milestones tracked by +//! [`spsc`] is implemented, with its doorbell: it can be polled with no kernel +//! object at all, blocked on directly, or waited on alongside other handles. +//! The remaining shapes land in the milestones tracked by //! `CHECKLIST-io-domains.md` at the workspace root; the decisions they are //! built against are recorded in `DESIGN-NOTES.md` beside this file. @@ -53,10 +53,11 @@ #![warn(missing_docs)] #![warn(unsafe_op_in_unsafe_fn)] +mod doorbell; mod error; pub mod spsc; -pub use error::{CapacityError, PushError}; +pub use error::{CapacityError, PushError, RecvError, RecvTimeoutError}; /// Pads and aligns a value onto its own cache line. /// diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 263ef082..1e1d34a6 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -64,10 +64,17 @@ use core::fmt; use core::marker::PhantomData; use core::mem::MaybeUninit; use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::io; +use std::os::windows::io::{AsRawHandle, BorrowedHandle, OwnedHandle}; use std::sync::Arc; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::System::Threading::{INFINITE, WaitForSingleObject}; use crate::CacheAligned; -use crate::error::{CapacityError, PushError}; +use crate::doorbell::Doorbell; +use crate::error::{CapacityError, PushError, RecvError, RecvTimeoutError}; /// The largest capacity that keeps the producer-minus-consumer difference /// unambiguous once the positions wrap. @@ -121,6 +128,7 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit tail: CacheAligned(AtomicUsize::new(0)), producer_live: AtomicBool::new(true), consumer_live: AtomicBool::new(true), + doorbell: Doorbell::new(), }); Ok(( @@ -145,6 +153,9 @@ struct Shared { tail: CacheAligned, producer_live: AtomicBool, consumer_live: AtomicBool, + /// Readiness as a waitable `HANDLE`. Costs nothing until somebody asks for + /// the handle, so a polling consumer never allocates a kernel object. + doorbell: Doorbell, } // SAFETY: the two positions partition the slot array between the threads. A @@ -248,6 +259,17 @@ impl Producer { .tail .0 .store(tail.wrapping_add(1), Ordering::Release); + + // After the release store, never before: the doorbell says "there is + // something to take", and that must not become true before the item is + // actually takeable. A consumer woken early would find the queue empty, + // clear the doorbell, and go back to sleep on an item that is about to + // exist -- a lost wakeup manufactured by signalling too eagerly. + // + // Cheap when it is redundant: `signal` returns without a syscall if the + // doorbell is already lit, so a producer running ahead of its consumer + // pays one atomic per push rather than one `SetEvent`. + self.shared.doorbell.signal(); Ok(()) } @@ -306,6 +328,12 @@ impl Drop for Producer { // observing the disconnection, so a consumer that sees it can trust // that draining to empty really has drained everything. self.shared.producer_live.store(false, Ordering::Release); + + // Disconnection is a wakeup like any other, and the only one nobody + // else can deliver. A consumer blocked on the doorbell would otherwise + // wait forever for an item that can no longer be sent -- the queue + // would be correct and the program would still hang. + self.shared.doorbell.signal(); } } @@ -383,6 +411,185 @@ impl Consumer { pub fn is_disconnected(&self) -> bool { !self.shared.producer_live.load(Ordering::Acquire) } + + /// Borrows the queue's readiness as a waitable `HANDLE`. + /// + /// This is the point of the crate. The handle is a manual-reset event that + /// is signalled exactly while the queue has something to take, so it can go + /// into `WaitForMultipleObjects` beside an I/O completion, a shutdown + /// event, or a timer -- a wait that no queue with a private parking + /// primitive can join. + /// + /// The event is created on the first call, so a consumer that only ever + /// polls with [`Self::pop`] is charged for no kernel object. + /// + /// The borrow is deliberate: the event belongs to the queue and must not be + /// closed. Use [`Self::doorbell_owned`] where ownership is required. + /// + /// # Waiting on it correctly + /// + /// **Do not simply wait and then drain.** Use [`Self::arm`] to decide + /// whether waiting is safe, or the wait can miss an item and block forever: + /// + /// ```no_run + /// # use windows_waitable_queues::spsc; + /// # use windows_sys::Win32::System::Threading::{WaitForSingleObject, INFINITE}; + /// # use std::os::windows::io::AsRawHandle; + /// # fn demo(rx: &spsc::Consumer) -> std::io::Result<()> { + /// loop { + /// while let Some(item) = rx.pop() { + /// let _ = item; + /// } + /// if !rx.arm()? { + /// continue; // Something arrived; waiting now would be wrong. + /// } + /// let handle = rx.doorbell()?; + /// // SAFETY: a live event handle borrowed for the call. + /// unsafe { WaitForSingleObject(handle.as_raw_handle(), INFINITE) }; + /// } + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` on the first call. + pub fn doorbell(&self) -> io::Result> { + self.shared.doorbell.handle() + } + + /// A duplicate of [`Self::doorbell`] that the caller owns. + /// + /// The duplicate names the same event, so signalling reaches both, and the + /// caller may close its copy whenever it likes. This is the form a + /// `ThreadpoolWait` needs, since arming one takes ownership of its target. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` or `DuplicateHandle`. + pub fn doorbell_owned(&self) -> io::Result { + self.shared.doorbell.owned() + } + + /// Clears the doorbell and reports whether it is safe to wait on it. + /// + /// `true` means the queue was still empty after the doorbell was cleared, + /// so any later push is guaranteed to signal and a wait cannot be missed. + /// `false` means something arrived in the meantime: take it instead of + /// waiting. + /// + /// The order inside this method is the whole correctness argument, and it + /// is the reverse of the one that reads naturally. Clearing *first* and + /// checking emptiness *second* is what makes a lost wakeup impossible: an + /// item that arrives before the clear is found by the check, and an item + /// that arrives after the clear signals a doorbell that is no longer about + /// to be reset. Checking first would leave a window in which a push both + /// signals and has its signal erased, and the consumer would sleep on a + /// queue that is not empty and will never be signalled again. + /// + /// This also creates the doorbell if it does not exist, which must happen + /// before the emptiness check for the same reason: a producer running while + /// there is no event skips signalling, so the check has to come after the + /// event exists to catch what that skip left behind. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` on the first call. + pub fn arm(&self) -> io::Result { + // Before the clear, and so before the check: see above. + self.shared.doorbell.handle()?; + self.shared.doorbell.clear(); + Ok(self.is_empty()) + } + + /// The last take before reporting the end of the stream. + /// + /// Called only after [`Self::is_disconnected`] has returned `true`, which + /// makes the answer final rather than a snapshot: no producer remains to + /// add anything, so `None` here means empty forever. + /// + /// This exists as a named step, rather than as a bare `pop` inlined into + /// each caller, because it guards a race that is real and narrow: a + /// producer may push *and then* drop in the window between a receive's + /// first `pop` and its disconnection check. Reporting the disconnection + /// without this final take would silently discard an item that was + /// successfully sent. Being a separate function is what lets a test reach + /// it directly instead of hoping to schedule that window. + fn finish(&self) -> Option { + self.pop() + } + + /// Takes the oldest item, blocking until one arrives. + /// + /// Parks on the doorbell rather than spinning, so a consumer with nothing + /// to do costs nothing. + /// + /// # Errors + /// + /// [`RecvError::Disconnected`] once the producer is gone *and* the queue is + /// drained -- items pushed before the producer dropped are still delivered. + /// [`RecvError::Io`] if the doorbell cannot be created or waited on. + pub fn recv(&self) -> Result { + loop { + if let Some(item) = self.pop() { + return Ok(item); + } + if !self.arm()? { + continue; + } + if self.is_disconnected() { + return self.finish().ok_or(RecvError::Disconnected); + } + wait(self.doorbell()?, INFINITE)?; + } + } + + /// Takes the oldest item, blocking until one arrives or the deadline + /// passes. + /// + /// The timeout bounds the whole call, not each individual wait: a consumer + /// woken spuriously does not get a fresh budget. + /// + /// # Errors + /// + /// [`RecvTimeoutError::Timeout`] if the deadline passes with the queue + /// still empty, which is not a malfunction. Otherwise as [`Self::recv`]. + pub fn recv_timeout(&self, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + loop { + if let Some(item) = self.pop() { + return Ok(item); + } + if !self.arm()? { + continue; + } + if self.is_disconnected() { + return self.finish().ok_or(RecvTimeoutError::Disconnected); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(RecvTimeoutError::Timeout); + } + // Saturating rather than wrapping: a duration longer than a `u32` + // of milliseconds is roughly 49 days, and clamping it to that is a + // longer wait than any caller meant, where truncating it would be a + // far shorter one. The loop re-arms and waits again, so clamping + // costs an extra turn and nothing else. + let millis = u32::try_from(remaining.as_millis()).unwrap_or(u32::MAX); + wait(self.doorbell()?, millis)?; + } + } +} + +/// Block on a doorbell handle, translating the Win32 result. +fn wait(handle: BorrowedHandle<'_>, millis: u32) -> io::Result<()> { + // SAFETY: a live event handle borrowed for the duration of the call. + let result = unsafe { WaitForSingleObject(handle.as_raw_handle(), millis) }; + match result { + // A timeout is not an error here: the caller's loop re-checks its own + // deadline and decides what a timeout means. + WAIT_OBJECT_0 | WAIT_TIMEOUT => Ok(()), + _ => Err(io::Error::last_os_error()), + } } /// See [`Producer`]'s impl for why this is hand-written. diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index 288d6c98..96005430 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -6,10 +6,16 @@ //! joined thread rather than a sleep, so they are deterministic: the assertion //! runs after the peer has finished, not after a guess about how long it takes. -use super::{Producer, bounded}; -use crate::PushError; +use super::{Consumer, Producer, bounded}; +use crate::{PushError, RecvError, RecvTimeoutError}; +use std::os::windows::io::AsRawHandle; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::System::Threading::WaitForSingleObject; /// Counts its own drops, so a test can prove an item was destroyed rather than /// leaked. `Arc` rather than a `static`, so tests that run @@ -330,3 +336,461 @@ fn a_producer_can_be_moved_to_another_thread() { .expect("the pushing thread"); assert_eq!(rx.pop(), Some(7)); } + +// --------------------------------------------------------------------------- +// The doorbell, joined to the queue. +// +// The tests below are about the *pairing* of the two; the doorbell's own +// behaviour as a kernel object is covered in `crate::doorbell`'s suite. +// --------------------------------------------------------------------------- + +/// Whether the queue's doorbell is signalled right now, asked of the kernel +/// rather than of the mirror flag. +/// +/// Uses a zero timeout, so it is a state query and never blocks. The event is +/// manual-reset, so asking does not consume the answer. +fn doorbell_is_lit(consumer: &Consumer) -> bool { + let handle = consumer.doorbell().expect("the doorbell must be creatable"); + // SAFETY: a live event handle borrowed for the call; a zero timeout returns + // immediately and has no other precondition. + let result = unsafe { WaitForSingleObject(handle.as_raw_handle(), 0) }; + assert!( + result == WAIT_OBJECT_0 || result == WAIT_TIMEOUT, + "the wait must resolve to signalled or not, got {result:#x}" + ); + result == WAIT_OBJECT_0 +} + +#[test] +fn polling_never_creates_a_kernel_object() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + + // The laziness claim, asserted rather than assumed: a consumer that only + // ever polls must not be charged for an event it never waits on. + for value in 0..4 { + tx.push(value).expect("there is room"); + } + while rx.pop().is_some() {} + drop(tx); + while rx.pop().is_some() {} + + assert!( + !rx.shared.doorbell.is_armed(), + "a poll-only consumer must allocate no kernel object" + ); +} + +#[test] +fn a_push_lights_the_doorbell() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + assert!( + !doorbell_is_lit(&rx), + "an empty queue must not claim readiness" + ); + tx.push(1).expect("there is room"); + assert!(doorbell_is_lit(&rx), "a pushed item must be announced"); +} + +#[test] +fn the_doorbell_stays_lit_across_repeated_observation() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + // Created before the push, which is not incidental: a push that runs while + // no event exists signals nothing, as + // `an_item_pushed_before_the_doorbell_existed_is_still_found` asserts. This + // test is about the level, so it starts from an armed doorbell. + rx.doorbell().expect("the doorbell must be creatable"); + tx.push(1).expect("there is room"); + + // A level, not an edge. An auto-reset event would fail the second pass, and + // a consumer sharing the wait with other handles would lose the queue. + for observation in 1..=3 { + assert!( + doorbell_is_lit(&rx), + "observation {observation} must still see the level" + ); + } +} + +#[test] +fn arm_reports_unsafe_to_wait_while_items_remain() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("there is room"); + + assert!( + !rx.arm().expect("arming must succeed"), + "arming must refuse to bless a wait while an item is sitting there" + ); +} + +#[test] +fn arm_reports_safe_to_wait_when_empty() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("there is room"); + assert_eq!(rx.pop(), Some(1)); + + assert!( + rx.arm().expect("arming must succeed"), + "a drained queue is safe to wait on" + ); + assert!( + !doorbell_is_lit(&rx), + "arming must clear the doorbell, or the next wait returns at once forever" + ); +} + +#[test] +fn arm_relights_the_doorbell_for_a_later_push() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("there is room"); + assert_eq!(rx.pop(), Some(1)); + assert!(rx.arm().expect("arming must succeed")); + + // The signal that must never be skipped: the doorbell was cleared, so the + // producer's mirror flag has to have been cleared with it. + tx.push(2).expect("there is room"); + assert!( + doorbell_is_lit(&rx), + "the first push after a clear must light the doorbell again" + ); +} + +#[test] +fn an_item_pushed_before_the_doorbell_existed_is_still_found() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + + // The lazy-creation hole. This push signals nothing, because there is no + // event yet to signal -- `crate::doorbell`'s suite asserts that directly. + tx.push(1).expect("there is room"); + assert!(!rx.shared.doorbell.is_armed(), "no event exists yet"); + + // Arming creates the event and only then checks emptiness, so the item is + // found instead of waited on. Had the check come first, this would report + // "safe to wait" and the consumer would block on an item already queued. + assert!( + !rx.arm().expect("arming must succeed"), + "arming must not bless a wait over an item that predates the doorbell" + ); +} + +// --------------------------------------------------------------------------- +// The lost-wakeup guard, verified by sabotage. +// +// `Consumer::arm` clears the doorbell and *then* checks emptiness. The reverse +// reads more naturally and is wrong. These two tests build the identical race +// against each order and assert that one produces a hang and the other does +// not, which is the only evidence that the order in `arm` is load-bearing +// rather than incidental. +// +// The race is driven deterministically on one thread rather than raced for on +// two: an interleaving that must be hit to prove a point is not one to leave to +// the scheduler. +// --------------------------------------------------------------------------- + +/// The sabotage: emptiness observed *before* the doorbell is cleared. +/// +/// Deliberately wrong, and called by nothing but the test that indicts it. +/// Mirrors [`Consumer::arm`] in every other respect, so the only difference +/// under test is the order of the two statements in the middle. +/// +/// `racing` runs in the window the wrong order opens -- between the emptiness +/// check and the clear. Passing it in makes the interleaving deterministic +/// rather than something two threads have to be lucky to produce. +fn arm_reversed_racing(consumer: &Consumer, racing: impl FnOnce()) -> bool { + consumer + .shared + .doorbell + .handle() + .expect("the doorbell must be creatable"); + let empty = consumer.is_empty(); + racing(); + consumer.shared.doorbell.clear(); + empty +} + +#[test] +fn reversing_the_clear_and_the_check_strands_an_item() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + // Step 1 of the protocol: the consumer drains and sees nothing. + assert_eq!(rx.pop(), None); + + // The producer lands inside the window the wrong order opens: after the + // reversed check has already read `is_empty` as true, but before its clear + // erases the signal that push is about to raise. Driving it through + // `arm_reversed` keeps the sabotage a single definition rather than a + // paraphrase that could drift from the thing it is meant to indict. + let empty_before = arm_reversed_racing(&rx, || { + tx.push(1).expect("there is room"); + }); + + assert!( + empty_before, + "the reversed check saw an empty queue and would bless a wait" + ); + assert_eq!(rx.len(), 1, "yet the queue holds an item"); + assert!( + !doorbell_is_lit(&rx), + "and the doorbell is dark, so nothing will ever wake a waiter" + ); + + // Proof that this state really is a hang and not merely suspicious: a real + // wait against it times out. A generous 250 ms, because the assertion is + // "this never fires", not "this is slow". + let handle = rx.doorbell().expect("the doorbell must be creatable"); + // SAFETY: a live event handle borrowed for the call. + let result = unsafe { WaitForSingleObject(handle.as_raw_handle(), 250) }; + assert_eq!( + result, WAIT_TIMEOUT, + "a consumer that checked before clearing waits forever on a queue that \ + is not empty -- this is the lost wakeup, reproduced" + ); +} + +#[test] +fn clearing_before_the_check_finds_the_item_instead_of_waiting() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + assert_eq!(rx.pop(), None); + + // The identical race, against the correct order. `arm` clears first, so the + // producer's push either lands before the clear and is caught by the check, + // or lands after it and lights a doorbell nobody is about to reset. + tx.push(1).expect("there is room"); + let safe_to_wait = rx.arm().expect("arming must succeed"); + + assert!( + !safe_to_wait, + "the check after the clear must see the item and refuse the wait" + ); + assert_eq!(rx.len(), 1, "the item is still there to be taken"); + assert_eq!(rx.pop(), Some(1), "and taking it is what happens instead"); +} + +#[test] +fn a_push_after_arming_lights_a_doorbell_that_stays_lit() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + assert!( + rx.arm().expect("arming must succeed"), + "empty, so safe to wait" + ); + + // The other half of the window: a push landing after the clear. Nothing + // resets the doorbell between the signal and the wait, so the wait returns + // at once rather than blocking. + tx.push(1).expect("there is room"); + + let handle = rx.doorbell().expect("the doorbell must be creatable"); + // SAFETY: a live event handle borrowed for the call. + let result = unsafe { WaitForSingleObject(handle.as_raw_handle(), 250) }; + assert_eq!( + result, WAIT_OBJECT_0, + "a push after arming must wake a waiter immediately" + ); +} + +// --------------------------------------------------------------------------- +// Blocking receive. +// --------------------------------------------------------------------------- + +#[test] +fn recv_returns_an_item_already_queued() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(7).expect("there is room"); + assert_eq!(rx.recv().expect("an item is queued"), 7); +} + +#[test] +fn recv_blocks_until_a_push_arrives() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let producer = thread::spawn(move || { + // A short sleep so the consumer is genuinely parked rather than racing + // to the first `pop`. Correctness does not depend on winning that race + // -- it depends on the wakeup arriving either way. + thread::sleep(Duration::from_millis(50)); + tx.push(99).expect("there is room"); + }); + + assert_eq!(rx.recv().expect("the producer pushes"), 99); + producer.join().expect("the producer must not panic"); +} + +#[test] +fn recv_reports_disconnection_once_drained() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + drop(tx); + assert!( + matches!(rx.recv(), Err(RecvError::Disconnected)), + "an empty queue with no producer is finished" + ); +} + +#[test] +fn recv_delivers_items_pushed_before_the_producer_dropped() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("there is room"); + tx.push(2).expect("there is room"); + drop(tx); + + // Disconnection must not discard what was already sent. Testing the flag + // before draining is the mistake this guards. + assert_eq!(rx.recv().expect("item one is owed"), 1); + assert_eq!(rx.recv().expect("item two is owed"), 2); + assert!( + matches!(rx.recv(), Err(RecvError::Disconnected)), + "and only then is it finished" + ); +} + +#[test] +fn a_blocked_recv_is_released_by_the_producer_dropping() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let producer = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + drop(tx); + }); + + // Without a signal in the producer's `Drop` this hangs forever: the queue + // would be correct and the program would still be wedged. + assert!( + matches!(rx.recv(), Err(RecvError::Disconnected)), + "dropping the producer must wake a parked consumer" + ); + producer.join().expect("the producer must not panic"); +} + +#[test] +fn recv_timeout_gives_up_on_an_empty_live_queue() { + let (_tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let started = Instant::now(); + let result = rx.recv_timeout(Duration::from_millis(60)); + + assert!( + matches!(result, Err(RecvTimeoutError::Timeout)), + "an empty queue with a live producer times out rather than ending" + ); + assert!( + result.is_err_and(|error| error.is_retryable()), + "and a timeout is worth retrying, unlike the other two variants" + ); + assert!( + started.elapsed() >= Duration::from_millis(50), + "it must actually have waited rather than returned at once" + ); +} + +#[test] +fn recv_timeout_returns_an_item_that_arrives_in_time() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let producer = thread::spawn(move || { + thread::sleep(Duration::from_millis(30)); + tx.push(5).expect("there is room"); + }); + + assert_eq!( + rx.recv_timeout(Duration::from_secs(5)) + .expect("the push lands well inside the deadline"), + 5 + ); + producer.join().expect("the producer must not panic"); +} + +#[test] +fn recv_timeout_reports_disconnection_rather_than_waiting_out_the_clock() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + drop(tx); + + let started = Instant::now(); + let result = rx.recv_timeout(Duration::from_secs(30)); + + assert!( + matches!(result, Err(RecvTimeoutError::Disconnected)), + "a finished queue is finished, deadline or not" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "and it must be reported at once rather than after the deadline" + ); +} + +#[test] +fn a_blocking_consumer_receives_every_item_in_order() { + // The whole mechanism under load: a capacity far smaller than the run, so + // the producer blocks on a full queue and the consumer blocks on an empty + // one, repeatedly, in both directions. + const COUNT: u32 = 10_000; + let (tx, rx) = bounded::(16).expect("16 is a valid capacity"); + + let producer = thread::spawn(move || { + for value in 0..COUNT { + let mut item = value; + while let Err(PushError::Full(returned)) = tx.push(item) { + item = returned; + std::hint::spin_loop(); + } + } + }); + + for expected in 0..COUNT { + assert_eq!( + rx.recv().expect("the producer is still sending"), + expected, + "items must arrive exactly once and in order" + ); + } + producer.join().expect("the producer must not panic"); + + assert!( + matches!(rx.recv(), Err(RecvError::Disconnected)), + "and the stream ends cleanly once the producer is gone" + ); +} + +#[test] +fn the_owned_doorbell_outlives_the_consumers_use_of_it() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let owned = rx.doorbell_owned().expect("duplication must succeed"); + tx.push(1).expect("there is room"); + + // SAFETY: `owned` is a live event handle; a zero timeout returns at once. + let result = unsafe { WaitForSingleObject(owned.as_raw_handle(), 0) }; + assert_eq!( + result, WAIT_OBJECT_0, + "a caller holding its own duplicate must see the queue's signals" + ); +} + +#[test] +fn the_final_drain_returns_an_item_that_raced_the_disconnection() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + + // The race `Consumer::finish` guards, reconstructed rather than waited for: + // a producer pushed and then dropped in the window between a receive's + // first `pop` and its disconnection check. At this point the queue reports + // disconnected *and* holds an item. + tx.push(1).expect("there is room"); + drop(tx); + assert!(rx.is_disconnected(), "the producer is gone"); + + assert_eq!( + rx.finish(), + Some(1), + "the end of the stream must not discard an item that was sent before it" + ); + assert_eq!( + rx.finish(), + None, + "and once genuinely drained, the answer is final" + ); +} + +#[test] +fn the_final_drain_is_empty_when_nothing_was_sent() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + drop(tx); + assert_eq!( + rx.finish(), + None, + "nothing was ever sent, so nothing is owed" + ); +} From e57c4586056bee8442c5099d3b80a361f320d435 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 20:58:04 -0400 Subject: [PATCH 022/361] chore(tools): promote the sabotage harness into a reusable instrument Sabotage verification is already required in several places here -- of a derived-fact binding, of the baseline-consistency checker, of the queue crate's lost-wakeup guard -- but there was no way to run one without reinventing it, badly, each time. Two of the last three sweeps found something, and one of the findings was a defect in the throwaway harness rather than in the code, which is a strong argument for having a harness that is not thrown away. tools/run-sabotage.ps1 takes a manifest of deliberate defects and, for each, patches the source, runs the suite, restores the source, and records whether the suite noticed. The first manifest is crates/windows-waitable-queues/sabotage.json; its nine entries reproduce the M30.4/M30.5 sweep exactly through the promoted tool -- eight defects caught (four as hangs), one control survived. Three rules are encoded because each was learned by getting it wrong. Judge by exit code, never by reading output: a process that dies of heap corruption prints no "test result: FAILED" line, and grepping for one reports a hole in the tests where there is none. A timeout counts as caught: a missing wakeup hangs rather than fails. The baseline must be green before anything is patched, or every sabotage "fails" and the sweep proves nothing while reading as a clean bill of health. The tool's own guards were verified by making each one fire, rather than by assuming they work: a name filter matching nothing, a missing file, a dirty target, a pattern matching fourteen sites instead of one, a patch that changes nothing, and a deliberately red baseline. A harness whose guards are untested is precisely the thing it exists to warn about. Two subtleties go in DESIGN-NOTES rather than staying folded into the script. A survived sabotage may be a defect in the *sabotage* rather than a hole in the tests -- a patch that inserts unreachable code beside a live call changes the file without changing behaviour, which happened here and was misread for a while -- so the patch is now printed on every unexpected result. And a too-short timeout manufactures a false "caught", crediting tests with detecting a defect they never ran against, so the bound errs generous even though that makes the hanging sabotages slower. Not wired into CI: every sabotage forces a rebuild and each hang costs the full timeout, so this manifest alone runs past twenty minutes. It is an occasional instrument, to be run when a guard is written or changed. Numbered M34 because the three root-level checklists share one milestone space and M22 is already CHECKLIST-thread-ambient.md's. Completed item: M34.1: Promote the ad-hoc sabotage harness into a reusable tool, with a manifest format, documentation, and its own guards verified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST.md | 22 ++ DESIGN-NOTES.md | 35 ++ crates/windows-waitable-queues/sabotage.json | 133 ++++++++ tools/README-sabotage.md | 141 +++++++++ tools/run-sabotage.ps1 | 317 +++++++++++++++++++ 5 files changed, 648 insertions(+) create mode 100644 crates/windows-waitable-queues/sabotage.json create mode 100644 tools/README-sabotage.md create mode 100644 tools/run-sabotage.ps1 diff --git a/CHECKLIST.md b/CHECKLIST.md index da22c021..f9382abf 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -106,6 +106,28 @@ be settled rather than discovered later. completion, then submits the query. A compound entry is reserved for a measured performance argument and would be a fusion of these two entries rather than a capability they lack. Depends on M21.3. +## M34 -- Tooling + +Numbered M34 rather than M22 because the three root-level checklists share one milestone space: +[CHECKLIST.md](CHECKLIST.md) holds M19-M21, [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) +M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. + +- [x] **M34.1** -- Promote the ad-hoc sabotage harness into a reusable tool. **Done.** + [tools/run-sabotage.ps1](tools/run-sabotage.ps1) plus + [tools/README-sabotage.md](tools/README-sabotage.md), driven by a `sabotage.json` kept beside the + code it patches; the first is + [crates/windows-waitable-queues/sabotage.json](crates/windows-waitable-queues/sabotage.json), whose + nine entries reproduce the M30.4/M30.5 sweep exactly through the promoted tool. + Six of the tool's own guards were verified by making each one fire: a name filter matching nothing, + a missing file, a dirty target, a pattern matching 14 sites instead of 1, a patch that changes + nothing, and a deliberately red baseline. A harness whose guards are untested is the thing it exists + to warn about. + Two subtleties are recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> `Sabotage sweeps` rather than + left in the script: a **survived** sabotage may be a defect in the *sabotage* rather than a hole in + the tests, which is why the patch is now printed on every unexpected result; and a **too-short + timeout manufactures a false "caught"**, crediting tests with catching a defect they never ran + against, so the bound errs generous. + ## M-inf -- Parked Ungated work with no identified predecessor deliverable. diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index 7d768bdd..e3c22def 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -1282,6 +1282,41 @@ written into the *audit table* while the decision row it contradicted still said finding is not discharged by being recorded in the audit. It is discharged when the statement it contradicted has changed. +## Sabotage verification has an instrument, and three rules that are not obvious + +Sabotage verification is already required here in several places -- of a derived-fact binding under +[restatement drift](#restatement-drift), of the baseline-consistency checker, of the queue crate's +lost-wakeup guard. What was missing was a way to run one that did not have to be reinvented, badly, +each time. [tools/run-sabotage.ps1](tools/run-sabotage.ps1) is that instrument, documented in +[README-sabotage.md](tools/README-sabotage.md), driven by a `sabotage.json` kept beside the code it +patches. + +The principle is stated elsewhere and is not restated here. What belongs here is the three operational +rules the script encodes, because each was arrived at by getting it wrong and none is guessable: + +- **Judge by exit code, never by reading output.** A test process that dies of heap corruption prints + no `test result: FAILED` line, so a harness that greps for one reports a hole in the tests where + there is none. This cost a real hunt for a nonexistent defect. +- **A timeout counts as caught.** A missing wakeup does not fail a test, it hangs it. An unbounded + harness hangs with it, and a bounded one that treats a timeout as inconclusive throws away the + detection it just achieved. +- **The baseline must be green before anything is patched.** Against an already-red suite every + sabotage "fails" and the sweep proves nothing while reading as a clean bill of health. + +Two further points that are easy to skip and expensive to skip: + +**A survived sabotage is not automatically a hole in the tests.** It may be a defect in the sabotage. +A patch that inserts unreachable code beside a live call, instead of deleting the call, changes the +file without changing the behaviour, and the suite then passes for the honest reason that nothing was +broken. That happened here and was misread as a test hole before anyone read the patch, which is why +the script now prints the patch for every unexpected result. + +**A manifest without a control is only half an instrument.** A control is a change that is *not* a +defect -- typically removing an optimisation -- and it must leave the suite green. Without one, a +sweep can tell you the tests are sensitive but not that they are sensitive to the right things; a +control reported as caught means a test has begun asserting the implementation rather than the +contract, and that test is the thing to fix. + ## Remoting synchronous namespace operations: the measured platform A planned facility makes synchronous-only Win32 operations available diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json new file mode 100644 index 00000000..fa7c262d --- /dev/null +++ b/crates/windows-waitable-queues/sabotage.json @@ -0,0 +1,133 @@ +{ + "package": "windows-waitable-queues", + "description": "Sabotages for the SPSC ring and its doorbell. Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format and for why the results are read the way they are.", + "sabotages": [ + { + "name": "push does not signal the doorbell", + "file": "src/spsc.rs", + "expect": "caught", + "why": "A producer that never rings the bell leaves a parked consumer asleep on a queue with items in it. Caught as a hang, which is the correct shape for this defect.", + "find": [ + " self.shared.doorbell.signal();", + " Ok(())" + ], + "replace": [ + " Ok(())" + ] + }, + { + "name": "producer drop does not signal", + "file": "src/spsc.rs", + "expect": "caught", + "why": "Disconnection is a wakeup, and the only one no other party can deliver. Without it a blocked consumer waits forever for an item that can no longer be sent: the queue stays correct and the program still hangs. NOTE the shape of this patch -- it deletes the live call. An earlier version inserted unreachable code beside it, which changed the file without changing behaviour, and the resulting pass was misread as a hole in the tests.", + "find": [ + " self.shared.doorbell.signal();", + " }", + "}", + "", + "/// The reading half" + ], + "replace": [ + " }", + "}", + "", + "/// The reading half" + ] + }, + { + "name": "arm checks emptiness before clearing", + "file": "src/spsc.rs", + "expect": "caught", + "why": "The lost wakeup itself, and the whole reason Consumer::arm exists. A push landing between the check and the clear both signals and has its signal erased, so the consumer sleeps on a queue that is not empty and will never be signalled again. This is the order that reads more naturally, which is exactly why it has to be proven wrong rather than assumed to be.", + "find": [ + " self.shared.doorbell.handle()?;", + " self.shared.doorbell.clear();", + " Ok(self.is_empty())" + ], + "replace": [ + " self.shared.doorbell.handle()?;", + " let empty = self.is_empty();", + " self.shared.doorbell.clear();", + " Ok(empty)" + ] + }, + { + "name": "arm does not create the doorbell before checking", + "file": "src/spsc.rs", + "expect": "caught", + "why": "Lazy creation is the same hazard a third time: a producer running while no event exists skips signalling, so the emptiness check has to come after the event exists to catch what that skip left behind.", + "find": [ + " self.shared.doorbell.handle()?;", + " self.shared.doorbell.clear();" + ], + "replace": [ + " self.shared.doorbell.clear();" + ] + }, + { + "name": "the final drain returns nothing", + "file": "src/spsc.rs", + "expect": "caught", + "why": "A producer may push and then drop in the window between a receive's first pop and its disconnection check. Reporting the disconnection without one last take silently discards an item that was successfully sent. This guard was originally unreachable from any test, and this sabotage is what found that out.", + "find": [ + " fn finish(&self) -> Option {", + " self.pop()", + " }" + ], + "replace": [ + " fn finish(&self) -> Option {", + " None", + " }" + ] + }, + { + "name": "clear resets the event but not the mirror flag", + "file": "src/doorbell.rs", + "expect": "caught", + "why": "The mirror flag lets a redundant signal skip its syscall. If clear leaves it set, the next push believes the doorbell is already lit and skips the one signal that actually mattered.", + "find": [ + " self.signalled.store(false, Ordering::Release);" + ], + "replace": [ + "" + ] + }, + { + "name": "the event is auto-reset instead of manual-reset", + "file": "src/doorbell.rs", + "expect": "caught", + "why": "An auto-reset event is an edge, not a level, and it does not count signals. A wait that reports one of several signalled handles would consume the queue's only notification while ignoring it. This exact mistake hung the crate's own doorbell probe for four hundred seconds.", + "find": [ + "CreateEventW(ptr::null(), TRUE, FALSE, ptr::null())" + ], + "replace": [ + "CreateEventW(ptr::null(), FALSE, FALSE, ptr::null())" + ] + }, + { + "name": "the event is created already signalled", + "file": "src/doorbell.rs", + "expect": "caught", + "why": "A doorbell must not claim readiness before anything is pushed, or the first wait returns immediately and the consumer spins.", + "find": [ + "CreateEventW(ptr::null(), TRUE, FALSE, ptr::null())" + ], + "replace": [ + "CreateEventW(ptr::null(), TRUE, TRUE, ptr::null())" + ] + }, + { + "name": "CONTROL: signal always syscalls, skipping the flag optimisation", + "file": "src/doorbell.rs", + "expect": "survives", + "why": "A control, not a defect. Skipping a redundant SetEvent is an optimisation: removing it costs ~74ns per redundant push and changes no observable behaviour, so the suite MUST stay green. If this is ever reported as caught, a test has started asserting the implementation instead of the contract, and that test is the thing to fix.", + "find": [ + " if self.signalled.swap(true, Ordering::AcqRel) {" + ], + "replace": [ + " self.signalled.store(true, Ordering::Release);", + " if false {" + ] + } + ] +} diff --git a/tools/README-sabotage.md b/tools/README-sabotage.md new file mode 100644 index 00000000..fee7656d --- /dev/null +++ b/tools/README-sabotage.md @@ -0,0 +1,141 @@ +# Sabotage sweeps + +A green test suite is evidence that the code passes its tests. It is not +evidence that the tests would fail if the code were wrong. Those are different +claims, and only the second one tells you whether a guard you just wrote is +worth the lines it occupies. + +[run-sabotage.ps1](run-sabotage.ps1) measures the second claim. It takes a +manifest of deliberate defects, and for each one: patches the source, runs the +suite, restores the source, and records whether the suite noticed. + +```powershell +# What is in a manifest +.\tools\run-sabotage.ps1 -Manifest crates\windows-waitable-queues\sabotage.json -List + +# Sweep it +.\tools\run-sabotage.ps1 -Manifest crates\windows-waitable-queues\sabotage.json + +# Re-run one, after changing a test +.\tools\run-sabotage.ps1 -Manifest crates\windows-waitable-queues\sabotage.json -Name '*doorbell*' +``` + +It exits 0 only when every sabotage behaved as the manifest declared. + +**This is an occasional instrument, not a CI gate.** Every sabotage forces a +rebuild, and any that is caught *as a hang* costs the full timeout. The +waitable-queues manifest takes upwards of twenty minutes. Run it when a guard is +written or changed, not on every commit. + +## Reading a result, which is where the judgement is + +**`caught`** -- the suite went red, or hung. The guard is real. + +**`survived (NOT caught)`** -- the suite stayed green with the defect in place. +This is the finding worth having, and it means one of two things. Either the +tests have a hole, or **the sabotage is not a sabotage**. Check the second +before believing the first: the script prints the injected patch for every +unexpected result precisely so you can. A patch that inserts unreachable code +beside a live call, rather than deleting the call, changes the file without +changing the behaviour, and the suite then passes for the honest reason that +nothing was broken. That has happened in this repository, and it read as a hole +in the tests for a while before anyone looked at the patch. + +**`MANIFEST STALE`** -- the pattern no longer matches exactly one site. +Refactoring moved the code out from under the manifest. Fix the manifest; the +sabotage was not run and proves nothing. + +**`MANIFEST INERT`** -- the patch does not change the file at all. + +## Controls matter as much as defects + +A manifest should contain at least one entry with `"expect": "survives"`: a +change that is *not* a defect, usually the removal of an optimisation. It must +leave the suite green. + +If a control is ever reported as caught, a test has started asserting the +implementation rather than the contract, and that test is the thing to fix. A +manifest with no controls can only tell you your tests are sensitive; it cannot +tell you they are sensitive *to the right things*. + +## Three rules the script encodes, each learned by getting it wrong + +**Judge by exit code, never by reading output.** A test process that dies of +heap corruption prints no `test result: FAILED` line at all. A harness that +greps for that string reports a hole in the tests where there is none, and the +time then spent hunting for it is pure loss. + +**A timeout counts as caught.** A missing wakeup does not fail a test, it hangs +it. A harness without a bound hangs with it -- and a lost-wakeup defect that +hangs the suite has been detected exactly as intended, so a hang is a pass for +the tests, not a failure of the run. + +**The baseline must be green before anything is patched.** Against an +already-red suite every sabotage "fails" and the sweep means nothing while +looking like a clean bill of health. The script refuses to start otherwise. + +## Manifest format + +JSON. `find` and `replace` are arrays of lines, joined with newlines -- +line-array rather than one embedded string, so no backslash or newline ever +needs escaping. + +```json +{ + "package": "windows-waitable-queues", + "root": "../some/other/crate", + "testArgs": ["-p", "windows-waitable-queues", "--locked"], + "sabotages": [ + { + "name": "push does not signal the doorbell", + "file": "src/spsc.rs", + "expect": "caught", + "why": "A producer that never rings the bell leaves a parked consumer asleep.", + "find": [" self.shared.doorbell.signal();", " Ok(())"], + "replace": [" Ok(())"] + } + ] +} +``` + +| Field | Required | Meaning | +|---|---|---| +| `package` | yes | Cargo package to test, unless `testArgs` overrides the command. | +| `root` | no | Where `file` paths resolve from, relative to the manifest. Defaults to the manifest's own directory. | +| `testArgs` | no | Replaces the arguments after `cargo test`. | +| `name` | yes | Unique; also the `-Name` filter key and the transcript filename. | +| `file` | yes | Source to patch, relative to `root`. | +| `expect` | yes | `caught` for a defect, `survives` for a control. | +| `why` | yes | What breaks, and why the suite should or should not notice. This is the part a future reader needs; the patch only says what changed. | +| `find` | yes | Lines to replace. Must match **exactly once**. | +| `replace` | yes | Replacement lines. `[""]` deletes. | + +Keep the manifest **beside the code it sabotages** -- `sabotage.json` in the +crate root -- so a refactor and its manifest move together and a stale pattern +shows up in the same review. + +## Writing a good sabotage + +**Delete or invert; do not add.** The strongest patch removes the guard being +tested. A patch that adds something beside it risks changing the file without +changing the behaviour. + +**One defect per entry.** Two at once cannot distinguish which test caught what. + +**Target the guard, not the feature.** `pop` returning `None` unconditionally +will be caught by every test in the file and tells you nothing. Sabotage the +specific ordering, bound, or branch whose necessity is in question. + +**Prefer the smallest patch that inverts the guarantee** -- swapping two +statements, flipping a `TRUE` to a `FALSE`, returning `None` from one accessor. +Small patches survive refactoring and stay readable in the failure output. + +## Safety + +Files are restored in a `finally` block and the restoration is verified by +comparing contents; if it cannot be restored the script stops immediately and +tells you the `git checkout` to run. Targets must be clean in git before a +sweep starts -- that is what makes an interrupted run recoverable -- and +`-AllowDirty` waives it if you accept the risk. + +Transcripts land in `.scratch/sabotage/`, one per sabotage plus the baseline. diff --git a/tools/run-sabotage.ps1 b/tools/run-sabotage.ps1 new file mode 100644 index 00000000..fdbb6d92 --- /dev/null +++ b/tools/run-sabotage.ps1 @@ -0,0 +1,317 @@ +# Copyright (c) 2026 Mike Grier. All rights reserved. +<# +.SYNOPSIS + Runs a sabotage sweep: injects deliberate defects one at a time and reports + whether the test suite noticed. + +.DESCRIPTION + A green suite is evidence that the code passes its tests. It is not evidence + that the tests would fail if the code were wrong, and those are different + claims. This script measures the second one: for each defect in a manifest + it patches the source, runs the suite, restores the source, and records + whether the suite went red. + + Three rules are encoded here because each was learned by getting it wrong. + + JUDGE BY EXIT CODE, NEVER BY READING OUTPUT. A test process that dies of + heap corruption prints no "test result: FAILED" line at all. A harness that + greps for that string will report a hole in the tests where there is none, + and the hours then spent looking for it are pure loss. + + A TIMEOUT COUNTS AS CAUGHT. A missing wakeup does not fail a test, it hangs + it -- so a harness with no timeout hangs too, and a lost-wakeup defect that + hangs the suite has been detected exactly as intended. + + THE BASELINE MUST BE GREEN FIRST. Against an already-red suite every + sabotage "fails" and the whole sweep means nothing while looking like a + clean bill of health. This script refuses to start until the unmodified + suite passes. + + A sabotage that reports NOT CAUGHT is not automatically a hole in the + tests -- it may be a defect in the sabotage. One that inserts unreachable + code beside a live call, rather than removing the call, changes the file + without changing the behaviour, and the suite then passes for the honest + reason that nothing was broken. That failure mode is silent and it has + happened here, so this script prints the applied patch for every unexpected + result: check that the injected defect really is a defect before believing + a hole exists. + + Expect one full rebuild per sabotage. This is a deliberate, occasional + instrument -- run it when a guard is written or changed, not on every + commit. + +.PARAMETER Manifest + Path to a sabotage manifest (JSON). See tools/README-sabotage.md for the + format, and crates/windows-waitable-queues/sabotage.json for a worked + example. + +.PARAMETER Name + Optional wildcard filter over sabotage names, to re-run just one. + +.PARAMETER TimeoutSeconds + Per-sabotage bound covering build and test. Defaults to 300. A run that + exceeds it is killed and counted as caught. + + Err generous rather than tight. Because a timeout counts as caught, a bound + shorter than a legitimate build-and-test manufactures a FALSE "caught" -- + it credits the tests with detecting a defect they never even ran against, + which is the dangerous direction to be wrong in. A too-long bound only + wastes time on the sabotages that genuinely hang. Lower it deliberately + when iterating on one sabotage; leave it alone for a sweep whose result + you intend to believe. + +.PARAMETER OutputDirectory + Where to write per-sabotage transcripts. Defaults to .scratch/sabotage. + +.PARAMETER List + Print the manifest's sabotages and exit without running anything. + +.PARAMETER AllowDirty + Permit running when a target file has uncommitted changes. Off by default: + the script restores files by rewriting their pre-sabotage contents, and if + it is interrupted, a clean starting tree is what makes the damage obvious + and recoverable with `git checkout`. + +.OUTPUTS + Exits 0 only if every sabotage matched its declared expectation. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string] $Manifest, + + [string] $Name = '*', + + [int] $TimeoutSeconds = 300, + + [string] $OutputDirectory, + + [switch] $List, + + [switch] $AllowDirty +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +# Writes to stderr and exits with a code, rather than Write-Error, which under +# $ErrorActionPreference = 'Stop' raises a terminating error that propagates out +# of this script and aborts whatever invoked it. A diagnostic tool reporting a +# bad manifest must not take the caller's session down with it. +function Exit-WithMessage { + param([string] $Message, [int] $Code) + [Console]::Error.WriteLine($Message) + exit $Code +} + +function Get-RepoRoot { + $root = git rev-parse --show-toplevel 2>$null + if ($LASTEXITCODE -ne 0) { throw 'Not inside a git repository.' } + return $root.Replace('/', '\') +} + +# PSScriptAnalyzer asks for `SupportsShouldProcess` on a `Stop-` verb. Declined +# deliberately: that machinery exists to raise a confirmation prompt, and this +# function's whole job is to kill a build that has already hung. A tool built to +# detect hangs must not acquire a way to hang on a prompt. PSScriptAnalyzer is +# not a gate in this repository -- CI executes these scripts rather than linting +# them, and all four siblings in this directory carry the same class of warning. +function Stop-Tree { + param([int] $ProcessId) + Get-CimInstance Win32_Process -Filter "ParentProcessId=$ProcessId" -ErrorAction SilentlyContinue | + ForEach-Object { Stop-Tree -ProcessId $_.ProcessId } + Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue +} + +# Runs cargo under a wall-clock bound, and reports which of the three outcomes +# occurred. A hang is a distinct outcome from a failure because it is what a +# lost-wakeup defect looks like, and collapsing the two would hide that. +function Invoke-Bounded { + param( + [string[]] $CargoArgs, + [string] $WorkingDirectory, + [string] $TranscriptPath, + [int] $Seconds + ) + + $process = Start-Process -FilePath 'cargo' -ArgumentList $CargoArgs ` + -WorkingDirectory $WorkingDirectory -PassThru -NoNewWindow ` + -RedirectStandardOutput $TranscriptPath ` + -RedirectStandardError "$TranscriptPath.err" + + if ($process.WaitForExit($Seconds * 1000)) { + return [pscustomobject]@{ Outcome = ($process.ExitCode -eq 0 ? 'passed' : 'failed'); Code = $process.ExitCode } + } + + Stop-Tree -ProcessId $process.Id + return [pscustomobject]@{ Outcome = 'hung'; Code = $null } +} + +function Format-Patch { + param([string] $Find, [string] $Replace) + $lines = @(' --- injected patch ---') + foreach ($line in $Find -split "`n") { $lines += " - $line" } + foreach ($line in $Replace -split "`n") { $lines += " + $line" } + if ([string]::IsNullOrEmpty($Replace)) { $lines += ' + (removed)' } + return $lines -join "`n" +} + +$repoRoot = Get-RepoRoot +$manifestPath = (Resolve-Path -LiteralPath $Manifest).Path +$manifestDir = Split-Path -Parent $manifestPath +$spec = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + +# Each sabotage's `file` is resolved against the manifest's own directory, which +# is what a manifest sitting in the crate it sabotages wants. An optional `root` +# redirects that, for a manifest kept somewhere other than the code it patches. +$sourceRoot = $manifestDir +if ($spec.PSObject.Properties.Name -contains 'root' -and $spec.root) { + $sourceRoot = (Resolve-Path -LiteralPath (Join-Path $manifestDir $spec.root)).Path +} + +if (-not $OutputDirectory) { $OutputDirectory = Join-Path $repoRoot '.scratch\sabotage' } +New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null + +$package = $spec.package +$testArgs = @('test', '-p', $package, '--locked') +if ($spec.PSObject.Properties.Name -contains 'testArgs' -and $spec.testArgs) { + $testArgs = @('test') + $spec.testArgs +} + +$selected = @($spec.sabotages | Where-Object { $_.name -like $Name }) + +if ($List) { + "Manifest : $manifestPath" + "Package : $package" + "Command : cargo $($testArgs -join ' ')" + '' + $selected | ForEach-Object { + "{0,-10} {1}" -f $_.expect, $_.name + } + exit 0 +} + +if ($selected.Count -eq 0) { + Exit-WithMessage "No sabotage in $manifestPath matches name filter '$Name'." 2 +} + +# Resolve and validate every target before touching anything, so a manifest +# typo cannot leave the tree half-patched. +foreach ($sabotage in $selected) { + $target = Join-Path $sourceRoot $sabotage.file + if (-not (Test-Path -LiteralPath $target)) { + Exit-WithMessage "Sabotage '$($sabotage.name)' names a file that does not exist: $target" 2 + } + if (-not $AllowDirty) { + $status = git -C $repoRoot status --porcelain -- $target + if ($status) { + Exit-WithMessage (@( + "Sabotage targets must be clean in git, and this one is not:" + " $target" + "This script restores files by rewriting their previous contents; starting" + "from a clean tree is what makes an interrupted run recoverable with a" + "'git checkout'. Commit or stash first, or pass -AllowDirty to accept that risk." + ) -join "`n") 2 + } + } +} + +Write-Host 'Baseline: running the unmodified suite.' -ForegroundColor Cyan +$baselinePath = Join-Path $OutputDirectory 'baseline.txt' +$baseline = Invoke-Bounded -CargoArgs $testArgs -WorkingDirectory $repoRoot ` + -TranscriptPath $baselinePath -Seconds $TimeoutSeconds + +if ($baseline.Outcome -ne 'passed') { + Exit-WithMessage (@( + "The baseline suite did not pass ($($baseline.Outcome))." + "Transcript: $baselinePath" + "A sweep against a red suite reports every sabotage as caught and proves" + "nothing while looking like a clean bill of health. Fix the suite first." + ) -join "`n") 2 +} +Write-Host 'Baseline is green. Sweeping.' -ForegroundColor Cyan +'' + +$results = @() + +foreach ($sabotage in $selected) { + $target = Join-Path $sourceRoot $sabotage.file + $find = ($sabotage.find -join "`n") + $replace = ($sabotage.replace -join "`n") + $original = [System.IO.File]::ReadAllText($target) + + # Exactly once, not at least once: a pattern matching two sites patches + # whichever the string replace happens to reach, and the sabotage is then + # not the one described. + $occurrences = ([regex]::Matches($original, [regex]::Escape($find))).Count + if ($occurrences -ne 1) { + $results += [pscustomobject]@{ + Sabotage = $sabotage.name; Expected = $sabotage.expect + Actual = "MANIFEST STALE: pattern found $occurrences times, expected 1" + Ok = $false; Patch = (Format-Patch -Find $find -Replace $replace) + } + continue + } + + $patched = $original.Replace($find, $replace) + if ($patched -eq $original) { + $results += [pscustomobject]@{ + Sabotage = $sabotage.name; Expected = $sabotage.expect + Actual = 'MANIFEST INERT: the patch does not change the file' + Ok = $false; Patch = (Format-Patch -Find $find -Replace $replace) + } + continue + } + + $transcript = Join-Path $OutputDirectory ((($sabotage.name -replace '[^A-Za-z0-9]+', '-')) + '.txt') + [System.IO.File]::WriteAllText($target, $patched, $utf8NoBom) + try { + $run = Invoke-Bounded -CargoArgs $testArgs -WorkingDirectory $repoRoot ` + -TranscriptPath $transcript -Seconds $TimeoutSeconds + } + finally { + [System.IO.File]::WriteAllText($target, $original, $utf8NoBom) + if ([System.IO.File]::ReadAllText($target) -ne $original) { + Exit-WithMessage "FAILED TO RESTORE $target -- recover it with 'git checkout -- $target' before doing anything else." 3 + } + } + + $caught = $run.Outcome -ne 'passed' + $actual = switch ($run.Outcome) { + 'passed' { 'survived (NOT caught)' } + 'failed' { "caught (suite failed, exit $($run.Code))" } + 'hung' { "caught (suite HUNG past ${TimeoutSeconds}s)" } + } + $ok = if ($sabotage.expect -eq 'caught') { $caught } else { -not $caught } + + $results += [pscustomobject]@{ + Sabotage = $sabotage.name; Expected = $sabotage.expect + Actual = $actual; Ok = $ok; Patch = (Format-Patch -Find $find -Replace $replace) + } + + $colour = if ($ok) { 'Green' } else { 'Red' } + Write-Host ("{0,-58} {1}" -f $sabotage.name, $actual) -ForegroundColor $colour +} + +'' +$results | Select-Object Sabotage, Expected, Actual, Ok | Format-Table -AutoSize -Wrap + +$unexpected = @($results | Where-Object { -not $_.Ok }) +if ($unexpected.Count -eq 0) { + Write-Host "All $($results.Count) sabotages behaved as declared." -ForegroundColor Green + exit 0 +} + +'' +Write-Host 'UNEXPECTED RESULTS -- read the patch before concluding the tests have a hole.' -ForegroundColor Red +Write-Host 'A sabotage that does not actually break anything will be survived for an honest reason.' -ForegroundColor Red +foreach ($result in $unexpected) { + '' + Write-Host " $($result.Sabotage)" -ForegroundColor Red + Write-Host " expected $($result.Expected), got: $($result.Actual)" + $result.Patch +} +'' +Exit-WithMessage "$($unexpected.Count) of $($results.Count) sabotages did not behave as declared." 1 From 2047dc2adb57bb467cd5213c2d7a249bde728cbc Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Sun, 30 Aug 2026 22:03:15 -0400 Subject: [PATCH 023/361] fix(waitable-queues): close a lost-wakeup hole and three arithmetic defects found by review Six defects. Four came from a code review of M30.3-M30.5, one from a test written for another, and one from re-running the sabotage sweep under a tighter bound and watching a result flip. THE ORDERING DEFECT, which is the one that mattered. `Doorbell::signal` decides whether to signal by LOADING the doorbell's state, having just had the queue position published by a release store. The consumer does the mirror image: it stores the doorbell state, then loads the queue position. Store-then-load on each side over two locations is the store-buffer shape from Dekker's algorithm, and release/acquire does not forbid both loads from returning stale values. When both do, the item is queued, no signal is raised, and the consumer parks forever. The remedy is a SeqCst fence on each side -- before the loads in `signal`, after the stores in `clear`. Every published eventcount carries the same fence in the same place, which is the clearest sign this is a known shape rather than a local quirk. D-9 is amended rather than extended: it had actually IDENTIFIED the sequential-consistency requirement and then dismissed it, reasoning that the re-check closed the hole for free. That was wrong in an instructive way. The re-check closes the program-order version of the hazard -- the one that is easy to picture -- and leaves the visibility version, which is not. Reasoning about interleavings in terms of "what happens first" silently assumes the sequential consistency that is exactly what is missing. Two temptations recorded as refused, both PLATFORM INTEGRITY rule 2: the consumer's `ResetEvent` is a syscall and very probably a barrier, and `stlr`/`ldar` on aarch64 are ordered more strongly than the model demands. Either would likely mask this on today's toolchain. Neither is specified. No test can catch it, and that is a property of the hazard rather than a gap to close by trying harder. Both fences are therefore deliberately absent from sabotage.json -- recording them as `caught` would fail the sweep, and as `survives` would assert they are harmless, which is false and worse -- and are now the named first targets of the loom work in M31.6. THE TEST THAT EXERCISED A COPY. M30.5 claimed the lost-wakeup guard was sabotage-verified. It was not, quite. The deterministic test drove `arm_reversed_racing`, a hand-written duplicate of `arm` with two statements swapped, so it could only show that *a* reversed order is wrong -- it was structurally incapable of noticing the real `arm` being reversed. Measured: that sabotage was caught in one run out of three, detection resting on two threads interleaving inside a window tens of nanoseconds wide. This is the anti-pattern CONTRACT INTEGRITY rule 1 names, and it was found only because splitting the sweep's timeout made a result flip. `Consumer::arm` now carries a `#[cfg(test)]` hook that fires between the clear and the check, so a test drives the real `arm` through that exact window on one thread. Caught every run since. THE OTHERS. `recv_timeout` computed its deadline with `Instant + Duration`, which panics when the sum is not representable; `Duration::MAX` is an ordinary way to spell "effectively forever". It now degrades to the untimed wait the caller asked for. The rest of that function was already careful about this exact class of problem, which is what made the panicking operator easy to miss. `CapacityError::previous_valid` rounded down to the nearest power of two without clamping, answering any request at or above 2^63 with 2^63 -- larger than the largest representable capacity, and refused. `next_valid` had the same defect on a path the review did not check, found by the test written for `previous_valid`. Both clamp now, and `CapacityError` carries the rejecting shape's bound rather than assuming a crate-wide constant. Two tests asserted vacuously: both pushed before anything created the event, so `signal` took its "no event yet" path and "arming clears it" held trivially. Confirmed by deleting `clear`'s `ResetEvent` and watching them pass anyway. Capacity validation is extracted as `validate_capacity` so the suggestion tests can ask the rule rather than restate it, and rather than call `bounded` -- which near the bound means requesting half the address space. The first version of that test aborted with a four-exabyte allocation failure. Sabotage manifest grows to thirteen entries, each verified caught. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 23 ++- .../windows-waitable-queues/DESIGN-NOTES.md | 40 ++++- crates/windows-waitable-queues/sabotage.json | 59 ++++++- .../windows-waitable-queues/src/doorbell.rs | 89 ++++++++++- crates/windows-waitable-queues/src/error.rs | 60 +++++++- crates/windows-waitable-queues/src/spsc.rs | 85 +++++++++-- .../windows-waitable-queues/src/spsc/tests.rs | 144 +++++++++++++++++- tools/README-sabotage.md | 40 ++++- tools/run-sabotage.ps1 | 94 +++++++++--- 9 files changed, 581 insertions(+), 53 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index db50c6e1..e7455cef 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -151,7 +151,17 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m prove a point is not one to leave to the scheduler) and ends in a real bounded `WaitForSingleObject`: reversed, it returns `WAIT_TIMEOUT` with an item sitting in the queue -- the lost wakeup, reproduced; correct, the check finds the item and never waits at all. - **Nine sabotages, eight defects and one control, all behaving as expected.** Caught: push not + **Corrected after review: the deterministic test named above tested a *copy* of the wrong order, + not the real `arm`.** `arm_reversed_racing` is a hand-written duplicate with the two statements + swapped, so it could only ever show that *a* reversed order is wrong -- it could not detect the real + `arm` being reversed. Measured: sabotaging the real `arm` was caught in **one run out of three**, + because detection then depended on two threads interleaving inside a window tens of nanoseconds wide. + This is the anti-pattern CONTRACT INTEGRITY rule 1 names -- a second copy of a rule checks the copy, + not the rule -- and it was found only because the sweep was re-run under a tighter bound and the + result flipped. `Consumer::arm` now carries a `#[cfg(test)]` hook that fires between the clear and the + check, so a test drives the **real** `arm` through that exact window on one thread. Caught every run + since. + **Thirteen sabotages, twelve defects and one control, all behaving as expected.** Caught: push not signalling; producer `Drop` not signalling; `arm` checking before clearing; `arm` not creating the doorbell before checking; the final drain returning nothing; `clear` resetting the event but not the mirror flag; auto-reset instead of manual-reset; the event created already signalled. Three of those @@ -213,6 +223,17 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m and the test is decoration. Scope it to the orderings, not the logic -- loom explores exponentially, so a loom test that also checks FIFO order over a thousand items will not terminate. + **A second, sharper target arrived from the M30.4/M30.5 code review, and it is the more important + one.** The doorbell carries two `SeqCst` fences -- before the loads in `Doorbell::signal`, after the + stores in `Doorbell::clear` -- which defeat a store-buffer (Dekker) reordering between the producer's + decision to skip signalling and the consumer's emptiness check. Without them the item is queued, no + signal is raised, and the consumer parks forever. **Removing either fence leaves the entire suite + green**, and no sabotage can express it, because the defect is a fact about the memory model rather + than an interleaving a scheduler can be coaxed into producing. Both fences must therefore be loom's + first two subjects, and the test earns its place only if deleting each one makes it fail. + Note that loom must model the doorbell's `OnceLock` publication too, since the lazy-creation path is + one of the two sides of the hazard; a loom test that only models the steady state will pass with the + `signal` fence removed and prove nothing about the case that motivated it. ## M32 -- Contracts the runtime cannot be written without diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 688a7a5f..69453f5c 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -28,7 +28,7 @@ preferred. | D-3 | **No trait ships until a second implementation exists to validate it.** The trait *shape* is fixed now so signatures stay compatible; the traits themselves land with the second shape. | | D-4 | **Every shape is split into producer and consumer handles, and cardinality is carried by `Clone`.** Single-producer becomes a compile-time guarantee rather than a documented precondition. | | D-5 | **The doorbell is level state owned by the queue: signalled exactly when the consumer has something to observe.** The **reset** must not be separable from the observation that there is nothing to take; the **signal** may be. Manual-reset, and created lazily. Realized without a lock by [D-9](#d-9). | -| D-9 | **Without a lock, the reset is made inseparable from the observation by ordering: clear first, then re-check, and never wait if the re-check finds anything.** `Consumer::arm` is that step. The natural order -- check, then clear -- is the lost wakeup, and is asserted to hang by a deliberate sabotage rather than argued to be wrong. | +| D-9 | **Without a lock, the reset is made inseparable from the observation by two things: ordering (clear, then re-check, and never wait if the re-check finds anything) and a `SeqCst` fence on each side.** `Consumer::arm` is the ordering step; the fences defeat the store-buffer hazard that ordering alone leaves open. The natural order -- check, then clear -- is asserted to hang by deliberate sabotage; the fences are beyond any test's reach and are M31.6's target. **Amended: this decision originally claimed the ordering alone sufficed.** | | D-6 | **Overflow fails or reserves, and never overwrites.** For telemetry an overwritten entry is a lost sample; for an I/O submission it is a lost operation, and the two must not share a policy knob. | | D-7 | **Shapes are plain modules, not Cargo features, until compile time justifies otherwise.** Two features are four configurations to test, against a benefit dead-code elimination already provides. | | D-8 | **Published, and the obligation is accepted deliberately.** Unlike `windows-guard-alloc`, this is general-purpose and its first consumer is not its only plausible one. | @@ -170,10 +170,40 @@ not empty and will never be signalled again. Not a stall -- a permanent hang. **Lazy creation is a third case of the same hazard.** A producer running while no event exists skips signalling, because there is nothing to signal. So the doorbell must be created *before* the emptiness -check that decides to wait, which is why `arm` creates it rather than assuming a caller did. Making the -initial state agree with the queue at creation time would not fix this and was rejected: doing it -race-free needs sequential consistency on both the event pointer and the queue position, which is a -`SeqCst` fence on the producer's hot path to close a hole the re-check already closes for free. +check that decides to wait, which is why `arm` creates it rather than assuming a caller did. + +**The ordering above is necessary and, on its own, was not sufficient -- this decision originally said +it was.** A code review found the hole. Program order does not relate the producer's decision to skip +signalling to the consumer's emptiness check, because each side *stores* one location and then *loads* +another: the producer stores the queue position and loads the doorbell state, while the consumer stores +the doorbell state and loads the queue position. That is the store-buffer shape from Dekker's +algorithm, and release/acquire permits both loads to return stale values. When both do, the item is +queued, no signal was raised, and the consumer parks forever. + +The remedy is a `SeqCst` fence on each side -- before the loads in `Doorbell::signal`, after the stores +in `Doorbell::clear`. Every published eventcount carries the same fence in the same place for the same +reason, which is the clearest sign that this is a known shape rather than a local quirk. + +The original text had actually *identified* the sequential-consistency requirement and then dismissed +it, on the reasoning that the re-check closed the hole for free and the fence was only needed for a +different design (signalling at creation time). That reasoning was wrong, and the shape of the error is +worth keeping: the re-check closes the *program-order* version of the hazard, which is the one that is +easy to picture, and leaves the *visibility* version, which is not. Reasoning about interleavings in +terms of "what happens first" silently assumes the sequential consistency that is exactly what is +missing. + +Two temptations recorded as refused, both instances of +[PLATFORM INTEGRITY](../../.github/copilot-instructions.md) rule 2. The consumer's `ResetEvent` is a +syscall and is very probably a full barrier; and `stlr`/`ldar` on aarch64 are ordered more strongly +than the abstract model demands. Either would likely mask this defect on today's toolchain and today's +processors. Neither is a specified guarantee, and binding correctness to the incidental behaviour of a +code generator plus a particular processor -- rather than to the ordering primitives -- is the precise +trap that rule exists to name. + +**No test can catch this, and that is a property of the hazard.** Removing either fence leaves the +whole suite green, and no entry in `sabotage.json` can express it, because the defect is a fact about +the memory model rather than an interleaving a scheduler can be coaxed into producing. It is the named +target of the `loom` work in [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) item M31.6. **This is asserted by sabotage, not by argument.** The suite reverses steps 2 and 3 deliberately and requires the result to hang -- a real `WaitForSingleObject` that returns `WAIT_TIMEOUT` while an item diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json index fa7c262d..444de268 100644 --- a/crates/windows-waitable-queues/sabotage.json +++ b/crates/windows-waitable-queues/sabotage.json @@ -1,6 +1,7 @@ { "package": "windows-waitable-queues", "description": "Sabotages for the SPSC ring and its doorbell. Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format and for why the results are read the way they are.", + "notCoveredHere": "The two SeqCst fences in doorbell.rs are deliberately ABSENT from this manifest, and their absence is not an oversight. Removing either leaves every test green, because the defect they prevent is a store-buffer reordering that no amount of stress testing reliably produces -- it is a fact about the memory model, not about any interleaving a scheduler will hand you. Adding them here with expect:'caught' would fail the sweep; adding them with expect:'survives' would assert they are harmless, which is false and far worse. They are verifiable only under a model checker, and are the named target of checklist item M31.6.", "sabotages": [ { "name": "push does not signal the doorbell", @@ -38,16 +39,18 @@ "name": "arm checks emptiness before clearing", "file": "src/spsc.rs", "expect": "caught", - "why": "The lost wakeup itself, and the whole reason Consumer::arm exists. A push landing between the check and the clear both signals and has its signal erased, so the consumer sleeps on a queue that is not empty and will never be signalled again. This is the order that reads more naturally, which is exactly why it has to be proven wrong rather than assumed to be.", + "why": "The lost wakeup itself, and the whole reason Consumer::arm exists. A push landing between the check and the clear both signals and has its signal erased, so the consumer sleeps on a queue that is not empty and will never be signalled again. This is the order that reads more naturally, which is exactly why it has to be proven wrong rather than assumed to be. MEASURED: this entry was caught in only one run of three while the sole deterministic test exercised a hand-written COPY of the reversed order rather than the real `arm` -- detection depended on two threads interleaving inside a window tens of nanoseconds wide. The `ARM_RACE_HOOK` in spsc.rs now drives the real `arm` through that window on one thread, and this is caught every run.", "find": [ - " self.shared.doorbell.handle()?;", " self.shared.doorbell.clear();", + " #[cfg(test)]", + " run_arm_race_hook();", " Ok(self.is_empty())" ], "replace": [ - " self.shared.doorbell.handle()?;", " let empty = self.is_empty();", " self.shared.doorbell.clear();", + " #[cfg(test)]", + " run_arm_race_hook();", " Ok(empty)" ] }, @@ -92,6 +95,56 @@ "" ] }, + { + "name": "clear does not reset the event", + "file": "src/doorbell.rs", + "expect": "caught", + "why": "Distinct from the mirror-flag sabotage below it: this leaves the flag correct and the kernel object wrong, so a consumer's wait returns immediately, forever. Added after a code review found that the two tests named for this property both asserted it vacuously -- they pushed before the event existed, so the doorbell was never lit and 'arming clears it' held trivially. Both now light the doorbell first, and this entry is what keeps them honest.", + "find": [ + " unsafe {", + " ResetEvent(event.as_raw_handle());", + " }" + ], + "replace": [ + " let _ = event;" + ] + }, + { + "name": "previous_valid is not clamped to the shape's bound", + "file": "src/error.rs", + "expect": "caught", + "why": "Rounding a request down to the nearest power of two gives 2^63 for anything at or above it, which exceeds the largest representable capacity. The suggestion exists so a caller can correct the call, and one that is itself refused is worse than none.", + "find": [ + " Some(rounded.min(self.largest_power_of_two_within_bound()))" + ], + "replace": [ + " Some(rounded)" + ] + }, + { + "name": "next_valid is not clamped to the shape's bound", + "file": "src/error.rs", + "expect": "caught", + "why": "A request that is merely not a power of two can still sit between the largest valid power of two and the bound, and rounding it up overshoots. Found by the test written for previous_valid, which the review had not flagged -- the reviewer checked next_valid only on the TooLarge path.", + "find": [ + " (rounded <= self.max_valid).then_some(rounded)" + ], + "replace": [ + " Some(rounded)" + ] + }, + { + "name": "recv_timeout computes its deadline with the panicking add", + "file": "src/spsc.rs", + "expect": "caught", + "why": "`Instant + Duration` panics when the sum is not representable, and Duration::MAX is an ordinary way to spell 'effectively forever'. The rest of the function is careful about exactly this class of problem, which is what made the panicking operator easy to miss.", + "find": [ + " let Some(deadline) = Instant::now().checked_add(timeout) else {" + ], + "replace": [ + " let Some(deadline) = Some(Instant::now() + timeout) else {" + ] + }, { "name": "the event is auto-reset instead of manual-reset", "file": "src/doorbell.rs", diff --git a/crates/windows-waitable-queues/src/doorbell.rs b/crates/windows-waitable-queues/src/doorbell.rs index 7df60a4d..1289842f 100644 --- a/crates/windows-waitable-queues/src/doorbell.rs +++ b/crates/windows-waitable-queues/src/doorbell.rs @@ -32,9 +32,15 @@ //! The cost of that laziness is a race worth stating plainly: a producer that //! runs while no event exists yet skips signalling, because there is nothing to //! signal. If a consumer could create the doorbell and then immediately wait on -//! it, an item pushed during that window would never wake anyone. What closes -//! the hole is the arming protocol below, not the creation itself -- the -//! doorbell must exist *before* the emptiness check that decides to wait. +//! it, an item pushed during that window would never wake anyone. Closing that +//! hole takes **two** things, and an earlier version of this note claimed the +//! first was enough: +//! +//! 1. The doorbell must exist *before* the emptiness check that decides to +//! wait, which is what the arming protocol below arranges. +//! 2. The producer's decision to skip signalling and the consumer's emptiness +//! check must be sequentially consistent with respect to each other. Program +//! order alone does not give this. See "The store-buffer hazard" below. //! //! # The arming protocol, which is the whole correctness argument //! @@ -63,6 +69,43 @@ //! the substitute, and it has to be written down because the compiler will not //! ask about it. //! +//! # The store-buffer hazard, which ordering alone does not fix +//! +//! The arming protocol says the consumer clears and then re-checks. The +//! producer pushes and then checks whether to signal. Written out as memory +//! operations, each side stores one location and then loads another: +//! +//! | Producer (`push` then [`Doorbell::signal`]) | Consumer ([`Doorbell::clear`] then re-check) | +//! |---|---| +//! | store the queue position (release) | store `signalled` / reset the event | +//! | load `event` and `signalled` | load the queue position (acquire) | +//! +//! This is the store-buffer shape -- the same one Dekker's algorithm runs +//! into -- and release/acquire does **not** forbid both loads from returning +//! stale values. When both do, the item is in the queue, the producer decided +//! no signal was needed, and the consumer decided it was safe to wait. That is +//! a permanent hang, not a stall. +//! +//! The remedy is sequential consistency on both sides, and it is not optional: +//! a `SeqCst` fence sits before the loads in [`Doorbell::signal`] and after the +//! stores in [`Doorbell::clear`]. Every published eventcount carries the same +//! fence in the same place for the same reason. +//! +//! Two temptations to record as refused. The consumer's `ResetEvent` is a +//! syscall and is very probably a full barrier, and `stlr`/`ldar` on aarch64 +//! happen to be ordered more strongly than the abstract model requires -- so on +//! today's compiler and today's processors this may well never misbehave. +//! Neither is a specified guarantee, and binding correctness to the incidental +//! behaviour of a code generator and a particular processor instead of to the +//! ordering primitives is exactly the trap this workspace has paid for before. +//! +//! **This hazard is invisible to the test suite**, which is a property of the +//! hazard and not a gap to be closed by trying harder: no amount of stress +//! testing reliably produces the interleaving, and none of the sabotages in +//! `sabotage.json` can express it. Removing either fence leaves every test +//! green. It is verifiable only under a model checker, which is what makes it +//! the named target of the `loom` work in checklist item M31.6. +//! //! # Why a redundant signal is skipped, but a redundant clear is not //! //! The two directions are not symmetric, and the asymmetry is the reason this @@ -90,7 +133,7 @@ use std::io; use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}; use std::ptr; use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, Ordering, fence}; use windows_sys::Win32::Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle, FALSE, TRUE}; use windows_sys::Win32::System::Threading::{ @@ -175,10 +218,32 @@ impl Doorbell { /// handles, which cannot occur for an event this type owns for its whole /// lifetime. pub(crate) fn signal(&self) { + // This fence is load-bearing and is not an abundance of caution. + // + // The caller has just published an item with a release store, and is + // about to LOAD state that decides whether to signal. The consumer does + // the mirror image: it stores that same state, then loads the queue's + // position. Store-then-load on each side, over two different locations, + // is the store-buffer (Dekker) shape, and release/acquire does not + // forbid both loads from seeing stale values. If both do, the item is + // queued, no signal is raised, and the consumer parks forever. + // + // Sequential consistency is the documented remedy, and every published + // eventcount carries the same fence in the same place for the same + // reason. Both skip paths below are loads, so the fence has to precede + // them rather than sit between them. + // + // Deliberately not relying on the fact that a particular compiler and a + // particular processor happen not to reorder this today, nor on the + // consumer's `ResetEvent` syscall incidentally acting as a barrier: the + // memory model permits the reordering, so the ordering must come from a + // specified primitive. + fence(Ordering::SeqCst); + let Some(event) = self.event.get() else { - // Nobody is waiting on a handle that does not exist. A consumer - // that creates one later re-checks the queue before waiting, so - // this skip cannot strand an item. + // Nobody can be waiting on a handle that does not exist yet, and + // the fence above guarantees that a consumer which publishes one + // after this load will see the item this push just added. return; }; if self.signalled.swap(true, Ordering::AcqRel) { @@ -213,6 +278,16 @@ impl Doorbell { unsafe { ResetEvent(event.as_raw_handle()); } + + // The other half of the pair described in `signal`. The caller's + // emptiness re-check is a LOAD of the queue's position, and it follows + // this store of `signalled`; without a sequentially consistent fence on + // both sides, that load and the producer's load of `signalled` may both + // observe stale values, which is the lost wakeup. `ResetEvent` above is + // very probably a barrier in its own right, but that is an incidental + // property of an implementation rather than a documented guarantee, so + // it is not what this relies on. + fence(Ordering::SeqCst); } /// Whether the event has been created, for tests and for asserting that diff --git a/crates/windows-waitable-queues/src/error.rs b/crates/windows-waitable-queues/src/error.rs index f65339b7..f46aaaea 100644 --- a/crates/windows-waitable-queues/src/error.rs +++ b/crates/windows-waitable-queues/src/error.rs @@ -18,6 +18,14 @@ use std::io; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CapacityError { requested: usize, + /// The largest capacity the rejecting shape accepts. + /// + /// Carried on the error rather than assumed to be a crate-wide constant: + /// the bound follows from how a shape represents its positions, and a + /// future shape that represents them differently would have a different + /// one. A suggestion computed against the wrong bound is worse than no + /// suggestion, because a caller will act on it. + max_valid: usize, kind: CapacityErrorKind, } @@ -29,27 +37,36 @@ enum CapacityErrorKind { } impl CapacityError { - pub(crate) fn zero() -> Self { + pub(crate) fn zero(max_valid: usize) -> Self { Self { requested: 0, + max_valid, kind: CapacityErrorKind::Zero, } } - pub(crate) fn not_power_of_two(requested: usize) -> Self { + pub(crate) fn not_power_of_two(requested: usize, max_valid: usize) -> Self { Self { requested, + max_valid, kind: CapacityErrorKind::NotPowerOfTwo, } } - pub(crate) fn too_large(requested: usize) -> Self { + pub(crate) fn too_large(requested: usize, max_valid: usize) -> Self { Self { requested, + max_valid, kind: CapacityErrorKind::TooLarge, } } + /// The largest capacity the shape that rejected this request will accept. + #[must_use] + pub fn max_valid(&self) -> usize { + self.max_valid + } + /// The capacity that was asked for. #[must_use] pub fn requested(&self) -> usize { @@ -62,24 +79,55 @@ impl CapacityError { /// Offered so a caller can correct the call without working out the /// arithmetic: a rejected 100 reports 64 here and 128 from /// [`Self::next_valid`]. + /// + /// Never returns a value the shape would itself reject. Rounding a request + /// down to the nearest power of two is not sufficient on its own: the + /// nearest power of two below `usize::MAX` is 2^63, which exceeds the + /// largest representable capacity, so the answer is clamped to + /// [`Self::max_valid`]. A suggestion that is itself refused would be worse + /// than none, because a caller acts on it and gets a second error. #[must_use] pub fn previous_valid(&self) -> Option { match self.kind { CapacityErrorKind::Zero => None, CapacityErrorKind::NotPowerOfTwo | CapacityErrorKind::TooLarge => { - Some(1_usize << (usize::BITS - 1 - self.requested.leading_zeros())) + let rounded = 1_usize << (usize::BITS - 1 - self.requested.leading_zeros()); + Some(rounded.min(self.largest_power_of_two_within_bound())) } } } + /// The largest power of two that does not exceed [`Self::max_valid`]. + /// + /// The clamp target for [`Self::previous_valid`]: `max_valid` is itself not + /// necessarily a power of two -- for a ring of monotonic wrapping positions + /// it is `usize::MAX / 2`, which is `2^63 - 1` -- so clamping to it + /// directly would hand back a capacity that fails the power-of-two test + /// instead of the size test. + fn largest_power_of_two_within_bound(&self) -> usize { + if self.max_valid == 0 { + return 0; + } + 1_usize << (usize::BITS - 1 - self.max_valid.leading_zeros()) + } + /// The smallest valid capacity not less than the request, if there is one. + /// + /// `None` when rounding up would leave the shape's bound behind, which is + /// not only the case for a request that was already too large: one that is + /// merely *not a power of two* can still sit between the largest valid + /// power of two and the bound, and rounding it up then overshoots. There is + /// genuinely no valid capacity at or above such a request, so saying so is + /// the honest answer -- [`Self::previous_valid`] is the one that can still + /// help. #[must_use] pub fn next_valid(&self) -> Option { - match self.kind { + let rounded = match self.kind { CapacityErrorKind::Zero => Some(1), CapacityErrorKind::NotPowerOfTwo => self.requested.checked_next_power_of_two(), CapacityErrorKind::TooLarge => None, - } + }?; + (rounded <= self.max_valid).then_some(rounded) } } diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 1e1d34a6..7d8d7bfb 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -107,15 +107,7 @@ const MAX_CAPACITY: usize = usize::MAX / 2; /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { - if capacity == 0 { - return Err(CapacityError::zero()); - } - if !capacity.is_power_of_two() { - return Err(CapacityError::not_power_of_two(capacity)); - } - if capacity > MAX_CAPACITY { - return Err(CapacityError::too_large(capacity)); - } + validate_capacity(capacity)?; let mut slots = Vec::with_capacity(capacity); slots.resize_with(capacity, || UnsafeCell::new(MaybeUninit::uninit())); @@ -143,6 +135,26 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit )) } +/// Whether this shape will accept a capacity, and why not if it will not. +/// +/// Separated from [`bounded`] so the rule can be *asked* rather than restated. +/// A test that wanted to check a suggested capacity is acceptable would +/// otherwise have to either re-encode these three conditions -- a second copy +/// of a rule, free to drift from this one -- or call `bounded`, which for a +/// capacity near the bound means trying to allocate half the address space. +fn validate_capacity(capacity: usize) -> Result<(), CapacityError> { + if capacity == 0 { + return Err(CapacityError::zero(MAX_CAPACITY)); + } + if !capacity.is_power_of_two() { + return Err(CapacityError::not_power_of_two(capacity, MAX_CAPACITY)); + } + if capacity > MAX_CAPACITY { + return Err(CapacityError::too_large(capacity, MAX_CAPACITY)); + } + Ok(()) +} + struct Shared { slots: Box<[UnsafeCell>]>, mask: usize, @@ -498,6 +510,8 @@ impl Consumer { // Before the clear, and so before the check: see above. self.shared.doorbell.handle()?; self.shared.doorbell.clear(); + #[cfg(test)] + run_arm_race_hook(); Ok(self.is_empty()) } @@ -554,7 +568,17 @@ impl Consumer { /// [`RecvTimeoutError::Timeout`] if the deadline passes with the queue /// still empty, which is not a malfunction. Otherwise as [`Self::recv`]. pub fn recv_timeout(&self, timeout: Duration) -> Result { - let deadline = Instant::now() + timeout; + // `Instant + Duration` panics when the sum is not representable, and + // `Duration::MAX` is a perfectly ordinary way to spell "effectively + // forever". A library that panics on that is worse than one that + // blocks, so an unrepresentable deadline degrades to the untimed wait + // it was asking for rather than aborting the caller. + let Some(deadline) = Instant::now().checked_add(timeout) else { + return self.recv().map_err(|error| match error { + RecvError::Disconnected => RecvTimeoutError::Disconnected, + RecvError::Io(io) => RecvTimeoutError::Io(io), + }); + }; loop { if let Some(item) = self.pop() { return Ok(item); @@ -580,6 +604,47 @@ impl Consumer { } } +/// Test-only: runs inside [`Consumer::arm`], between the clear and the +/// emptiness check. +/// +/// This exists so a test can drive the *real* `arm` through the exact race the +/// clear-then-check order defends against, deterministically and on one thread. +/// +/// It replaces a hand-written copy of `arm` with the two statements swapped. +/// That copy could only ever demonstrate that *a* reversed order is wrong; it +/// could not detect the real `arm` being reversed, because it was not the real +/// `arm`. Measured: with the copy as the only deterministic test, sabotaging +/// the real `arm` was caught in one run out of three -- detection relied on two +/// threads happening to interleave inside a window tens of nanoseconds wide. +/// A second copy of a rule is a check of the copy, not of the rule. +#[cfg(test)] +fn run_arm_race_hook() { + ARM_RACE_HOOK.with(|hook| { + // Taken out for the call rather than held borrowed across it, so a hook + // that touches the queue cannot trip a `RefCell` re-entrancy panic. + let taken = hook.borrow_mut().take(); + if let Some(mut race) = taken { + race(); + *hook.borrow_mut() = Some(race); + } + }); +} + +#[cfg(test)] +thread_local! { + static ARM_RACE_HOOK: core::cell::RefCell>> = + const { core::cell::RefCell::new(None) }; +} + +/// Test-only: installs a hook for the duration of a closure. +#[cfg(test)] +pub(crate) fn with_arm_race(race: impl FnMut() + 'static, body: impl FnOnce() -> R) -> R { + ARM_RACE_HOOK.with(|hook| *hook.borrow_mut() = Some(Box::new(race))); + let result = body(); + ARM_RACE_HOOK.with(|hook| *hook.borrow_mut() = None); + result +} + /// Block on a doorbell handle, translating the Win32 result. fn wait(handle: BorrowedHandle<'_>, millis: u32) -> io::Result<()> { // SAFETY: a live event handle borrowed for the duration of the call. diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index 96005430..fcbe654c 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -6,7 +6,7 @@ //! joined thread rather than a sleep, so they are deterministic: the assertion //! runs after the peer has finished, not after a guess about how long it takes. -use super::{Consumer, Producer, bounded}; +use super::{Consumer, Producer, bounded, validate_capacity}; use crate::{PushError, RecvError, RecvTimeoutError}; use std::os::windows::io::AsRawHandle; use std::sync::Arc; @@ -425,7 +425,16 @@ fn arm_reports_unsafe_to_wait_while_items_remain() { #[test] fn arm_reports_safe_to_wait_when_empty() { let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + // Created before the push, and that is the whole test. Without it the push + // takes `signal`'s "no event yet" path, the doorbell is never lit, and the + // assertion below that arming CLEARS it holds trivially -- it passed with + // `clear`'s `ResetEvent` deleted, which is how this was found. + rx.doorbell().expect("the doorbell must be creatable"); tx.push(1).expect("there is room"); + assert!( + doorbell_is_lit(&rx), + "the doorbell must be lit before a test of clearing it can mean anything" + ); assert_eq!(rx.pop(), Some(1)); assert!( @@ -441,7 +450,11 @@ fn arm_reports_safe_to_wait_when_empty() { #[test] fn arm_relights_the_doorbell_for_a_later_push() { let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + // As above: the first push must actually SET the mirror flag, or the claim + // that `clear` cleared it is a claim about a flag that was never set. + rx.doorbell().expect("the doorbell must be creatable"); tx.push(1).expect("there is room"); + assert!(doorbell_is_lit(&rx), "the first push must light it"); assert_eq!(rx.pop(), Some(1)); assert!(rx.arm().expect("arming must succeed")); @@ -794,3 +807,132 @@ fn the_final_drain_is_empty_when_nothing_was_sent() { "nothing was ever sent, so nothing is owed" ); } + +#[test] +fn recv_timeout_does_not_panic_on_an_unrepresentable_deadline() { + // `Instant + Duration` panics when the sum is not representable, and + // `Duration::MAX` is an ordinary way to spell "effectively forever". The + // queue is disconnected up front so the call has a reason to return at all; + // the assertion is that it returns rather than aborting the process. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + drop(tx); + + assert!( + matches!( + rx.recv_timeout(Duration::MAX), + Err(RecvTimeoutError::Disconnected) + ), + "an unrepresentable deadline must degrade to the untimed wait it asked for" + ); +} + +#[test] +fn recv_timeout_delivers_an_item_under_an_unrepresentable_deadline() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(3).expect("there is room"); + + // The degraded path must still be a working receive, not merely one that + // does not panic. + assert_eq!( + rx.recv_timeout(Duration::from_secs(u64::MAX)) + .expect("an item is queued"), + 3 + ); +} + +#[test] +fn a_suggested_capacity_is_one_the_constructor_would_accept() { + // The suggestion exists so a caller can correct the call. One that is + // itself refused is worse than none, because the caller acts on it. + // + // Asks `validate_capacity` rather than `bounded`, and rather than + // re-listing the rules here. Calling `bounded` would be a truer test of the + // real path, but a suggestion near the bound is 2^62, and constructing that + // queue means asking for half the address space -- the first version of + // this test aborted the process with a four-exabyte allocation failure. + for requested in [1_usize, 3, 100, 1000, 0, usize::MAX / 2, usize::MAX] { + let Err(error) = validate_capacity(requested) else { + continue; + }; + if let Some(previous) = error.previous_valid() { + assert!( + validate_capacity(previous).is_ok(), + "previous_valid() for {requested} suggested {previous}, which is itself rejected" + ); + } + if let Some(next) = error.next_valid() { + assert!( + validate_capacity(next).is_ok(), + "next_valid() for {requested} suggested {next}, which is itself rejected" + ); + } + } +} + +#[test] +fn the_largest_request_is_clamped_rather_than_rounded() { + // Rounding `usize::MAX` down to the nearest power of two gives 2^63, which + // is larger than the largest representable capacity. Before this was fixed + // the suggestion was exactly that unusable value. + let error = validate_capacity(usize::MAX).expect_err("usize::MAX is not a valid capacity"); + let previous = error + .previous_valid() + .expect("there is a valid capacity below usize::MAX"); + + assert!( + previous <= error.max_valid(), + "the suggestion {previous} must not exceed the shape's own bound {}", + error.max_valid() + ); + assert!( + previous.is_power_of_two(), + "and it must still be a power of two" + ); + assert!(validate_capacity(previous).is_ok(), "and must be accepted"); +} + +#[test] +fn the_real_arm_finds_an_item_that_lands_inside_its_window() { + // The deterministic indictment of the reversed order, driven through the + // REAL `Consumer::arm` rather than through a copy of it. + // + // The hook fires between `arm`'s clear and its emptiness check -- precisely + // the window a producer must hit for the hazard to bite. With the correct + // order the check follows the push and finds it, so arming refuses to bless + // a wait. With the two statements swapped the check has already happened, + // arming returns "safe to wait", and the consumer parks on a queue holding + // an item whose signal the clear erased. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + // The hook owns the producer outright. An `Arc` would be pointless here and + // clippy says so: a `Producer` is deliberately `!Sync`, so sharing one is + // exactly what the type system is built to prevent. + let safe_to_wait = super::with_arm_race( + move || { + tx.push(1).expect("there is room"); + }, + || rx.arm().expect("arming must succeed"), + ); + + assert!( + !safe_to_wait, + "an item landing between the clear and the check must be found, not waited past" + ); +} + +#[test] +fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { + // The complement, so the test above cannot pass by `arm` simply never + // blessing a wait -- which would satisfy it while breaking every consumer. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + drop(tx); + + let safe_to_wait = super::with_arm_race(|| {}, || rx.arm().expect("arming must succeed")); + + assert!( + safe_to_wait, + "an empty queue must still be safe to wait on, or the wait never happens at all" + ); +} diff --git a/tools/README-sabotage.md b/tools/README-sabotage.md index fee7656d..d3e20f33 100644 --- a/tools/README-sabotage.md +++ b/tools/README-sabotage.md @@ -23,10 +23,26 @@ suite, restores the source, and records whether the suite noticed. It exits 0 only when every sabotage behaved as the manifest declared. **This is an occasional instrument, not a CI gate.** Every sabotage forces a -rebuild, and any that is caught *as a hang* costs the full timeout. The -waitable-queues manifest takes upwards of twenty minutes. Run it when a guard is +rebuild, and any that is caught *as a hang* costs the full test timeout. The +waitable-queues manifest takes about three minutes. Run it when a guard is written or changed, not on every commit. +**Build and test are timed separately, and the split is what keeps the test +bound tight.** A hang is what a lost wakeup looks like and it happens during +test execution, so that phase gets a short bound (60s). A build is merely slow +sometimes, and a slow build killed by a short bound would be reported as a hang +-- crediting the tests with a detection that never happened -- so the build gets +a generous one (300s) and its failure is reported as its own outcome. Under a +single combined bound the number had to cover the slowest imaginable cold build, +which made every genuinely-hanging sabotage cost that same large number; the +split cut this manifest from twenty-five minutes to three. + +Measured here: building the crate after a one-file edit takes under a second, +while test execution takes about twelve, nearly all of it compiling doctests -- +`cargo test --no-run` does not build those, and Cargo offers no `--doc --no-run` +to pre-pay it. Pass `testArgs` with `--lib` if you want the sweep faster and +accept that a sabotage caught only by a doctest would then read as survived. + ## Reading a result, which is where the judgement is **`caught`** -- the suite went red, or hung. The guard is real. @@ -47,6 +63,26 @@ sabotage was not run and proves nothing. **`MANIFEST INERT`** -- the patch does not change the file at all. +## Beware the test that exercises a copy of the code + +If a sabotage is caught only *sometimes*, the usual cause is not a slow machine. +It is that the test which was supposed to catch it deterministically tests a +hand-written duplicate of the logic rather than the real thing, leaving the real +path covered only by whatever races the scheduler happens to produce. + +This is worth stating because it is invisible from a green suite and from a +passing sweep. It was found here only because a sweep was re-run under a tighter +bound and one result flipped from caught to survived: the guard's deterministic +test exercised a copy of the sequence with two statements swapped, so it proved +that *a* reversed order was wrong while being structurally incapable of noticing +the real one being reversed. Measured detection was one run in three. + +**A flaky sabotage is a finding, not noise.** Re-run it a few times before +accepting either answer, and if it is intermittent, look for a duplicate of the +logic in the test rather than reaching for a longer timeout. The fix is usually +a `#[cfg(test)]` hook that lets a test drive the real code through the window in +question on one thread. + ## Controls matter as much as defects A manifest should contain at least one entry with `"expect": "survives"`: a diff --git a/tools/run-sabotage.ps1 b/tools/run-sabotage.ps1 index fdbb6d92..7fb46dc6 100644 --- a/tools/run-sabotage.ps1 +++ b/tools/run-sabotage.ps1 @@ -49,16 +49,32 @@ Optional wildcard filter over sabotage names, to re-run just one. .PARAMETER TimeoutSeconds - Per-sabotage bound covering build and test. Defaults to 300. A run that - exceeds it is killed and counted as caught. - - Err generous rather than tight. Because a timeout counts as caught, a bound - shorter than a legitimate build-and-test manufactures a FALSE "caught" -- - it credits the tests with detecting a defect they never even ran against, - which is the dangerous direction to be wrong in. A too-long bound only - wastes time on the sabotages that genuinely hang. Lower it deliberately - when iterating on one sabotage; leave it alone for a sweep whose result - you intend to believe. + Bound on TEST EXECUTION only, defaulting to 60. A run that exceeds it is + killed and counted as caught, because a lost wakeup hangs rather than + fails. + + This is deliberately separate from -BuildTimeoutSeconds, and the split is + what makes a tight bound safe here. Because a timeout counts as caught, a + bound shorter than legitimate work manufactures a FALSE "caught" -- it + credits the tests with detecting a defect they never ran against, which is + the dangerous direction to be wrong in. Under a single combined bound the + number had to be generous enough for the slowest imaginable cold build, + which made every genuinely-hanging sabotage cost that same generous number. + + Measured on this workspace: building the crate after a one-file edit takes + under a second, and test execution takes about twelve, nearly all of it + compiling doctests -- `cargo test --no-run` does not build those, and Cargo + offers no `--doc --no-run` to pre-pay it. The default therefore leaves + roughly five times headroom over the measured cost. Raise it for a + substantially slower machine or a much larger suite; a sweep whose result + you intend to believe should never be run with this tightened for speed. + +.PARAMETER BuildTimeoutSeconds + Bound on the build phase, defaulting to 300. Generous on purpose: a slow + cold build must never be mistaken for a hang, and it costs nothing when + builds are fast. A sabotage that fails to build is reported as such rather + than as caught -- the compiler rejecting a patch says nothing about whether + the tests would have noticed it. .PARAMETER OutputDirectory Where to write per-sabotage transcripts. Defaults to .scratch/sabotage. @@ -82,7 +98,9 @@ param( [string] $Name = '*', - [int] $TimeoutSeconds = 300, + [int] $TimeoutSeconds = 60, + + [int] $BuildTimeoutSeconds = 300, [string] $OutputDirectory, @@ -149,6 +167,37 @@ function Invoke-Bounded { return [pscustomobject]@{ Outcome = 'hung'; Code = $null } } +# Builds, then runs, under two separate bounds. +# +# The phases are timed apart because they mean different things. A hang is what +# a lost wakeup looks like, and it happens in test EXECUTION -- so that phase +# gets a tight bound. A build is merely slow sometimes, and a slow build killed +# by a tight bound would be reported as a hang, crediting the tests with a +# detection that never happened. So the build gets a generous one, and its +# failure is reported as its own outcome rather than folded into "caught": +# the compiler rejecting a patch tells you nothing about your tests. +function Invoke-Sabotaged { + param( + [string[]] $CargoArgs, + [string] $WorkingDirectory, + [string] $TranscriptPath, + [int] $BuildSeconds, + [int] $TestSeconds + ) + + $build = Invoke-Bounded -CargoArgs ($CargoArgs + '--no-run') -WorkingDirectory $WorkingDirectory ` + -TranscriptPath "$TranscriptPath.build" -Seconds $BuildSeconds + if ($build.Outcome -eq 'failed') { + return [pscustomobject]@{ Outcome = 'build-failed'; Code = $build.Code } + } + if ($build.Outcome -eq 'hung') { + return [pscustomobject]@{ Outcome = 'build-hung'; Code = $null } + } + + return Invoke-Bounded -CargoArgs $CargoArgs -WorkingDirectory $WorkingDirectory ` + -TranscriptPath $TranscriptPath -Seconds $TestSeconds +} + function Format-Patch { param([string] $Find, [string] $Replace) $lines = @(' --- injected patch ---') @@ -220,8 +269,8 @@ foreach ($sabotage in $selected) { Write-Host 'Baseline: running the unmodified suite.' -ForegroundColor Cyan $baselinePath = Join-Path $OutputDirectory 'baseline.txt' -$baseline = Invoke-Bounded -CargoArgs $testArgs -WorkingDirectory $repoRoot ` - -TranscriptPath $baselinePath -Seconds $TimeoutSeconds +$baseline = Invoke-Sabotaged -CargoArgs $testArgs -WorkingDirectory $repoRoot ` + -TranscriptPath $baselinePath -BuildSeconds $BuildTimeoutSeconds -TestSeconds $TimeoutSeconds if ($baseline.Outcome -ne 'passed') { Exit-WithMessage (@( @@ -268,8 +317,8 @@ foreach ($sabotage in $selected) { $transcript = Join-Path $OutputDirectory ((($sabotage.name -replace '[^A-Za-z0-9]+', '-')) + '.txt') [System.IO.File]::WriteAllText($target, $patched, $utf8NoBom) try { - $run = Invoke-Bounded -CargoArgs $testArgs -WorkingDirectory $repoRoot ` - -TranscriptPath $transcript -Seconds $TimeoutSeconds + $run = Invoke-Sabotaged -CargoArgs $testArgs -WorkingDirectory $repoRoot ` + -TranscriptPath $transcript -BuildSeconds $BuildTimeoutSeconds -TestSeconds $TimeoutSeconds } finally { [System.IO.File]::WriteAllText($target, $original, $utf8NoBom) @@ -278,13 +327,22 @@ foreach ($sabotage in $selected) { } } - $caught = $run.Outcome -ne 'passed' $actual = switch ($run.Outcome) { 'passed' { 'survived (NOT caught)' } 'failed' { "caught (suite failed, exit $($run.Code))" } - 'hung' { "caught (suite HUNG past ${TimeoutSeconds}s)" } + 'hung' { "caught (tests HUNG past ${TimeoutSeconds}s)" } + # Not "caught": the tests never ran, so this says nothing about them. + # It means the patch is not valid Rust -- a manifest problem to fix, + # not a result to record. + 'build-failed' { 'MANIFEST DOES NOT COMPILE (tests never ran)' } + 'build-hung' { "BUILD HUNG past ${BuildTimeoutSeconds}s (tests never ran)" } + } + $ok = switch ($run.Outcome) { + 'passed' { $sabotage.expect -eq 'survives' } + 'failed' { $sabotage.expect -eq 'caught' } + 'hung' { $sabotage.expect -eq 'caught' } + default { $false } } - $ok = if ($sabotage.expect -eq 'caught') { $caught } else { -not $caught } $results += [pscustomobject]@{ Sabotage = $sabotage.name; Expected = $sabotage.expect From 92d68f3a27efa5da933dbadd978a48be4cfb3b02 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sun, 30 Aug 2026 22:44:16 -0400 Subject: [PATCH 024/361] feat(waitable-queues): add the bounded array MPSC and ship the capability traits Vyukov's sequence protocol: a producer claims a position with a compare-and-swap on the tail, writes, then publishes by storing the slot's sequence. The sequence is what lets the consumer tell a claimed slot from a written one, which a plain fetch-and-add cannot -- a producer preempted between the two would otherwise leave a hole and the consumer would read uninitialized memory. Lock-free rather than wait-free, bounded by construction so backpressure costs one load, and no allocation after the constructor. Head and tail are padded onto separate cache lines, commented at both fields because the padding is load-bearing and looks like waste. The capability traits ship here because M30.2 scheduled them here and D-3 required a second implementation to validate them against. The signatures spsc wrote into its documentation before its types existed held unchanged; the load-bearing one was push(&self), which &mut self would have made unimplementable by this shape. The protocol refuses a capacity of one: with a single slot, "published at p" and "free again at p + capacity" are the same number. spsc accepts one, so the minimum belongs to the shape rather than the crate, and CapacityError -- which already carried max_valid on that argument -- now carries min_valid too. The blocking receive loop and the arm-race hook were extracted rather than copied. The loop IS the arming protocol, so a second spelling would be a second copy of a rule, which is the mistake M30.5 already paid for. Completed item: M31.1: The bounded array MPSC: Vyukov's sequence protocol, where a producer CASes the tail forward, writes, then publishes by storing the slot's sequence. Lock-free rather than wait-free, bounded by construction so backpressure is free, and no allocation anywhere. Pad the head and tail onto separate cache lines and say so in a comment, because the padding is load-bearing and looks like waste. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 35 +- .../windows-waitable-queues/DESIGN-NOTES.md | 136 ++- crates/windows-waitable-queues/README.md | 36 +- crates/windows-waitable-queues/sabotage.json | 142 ++- .../windows-waitable-queues/src/arm_race.rs | 56 + .../windows-waitable-queues/src/blocking.rs | 155 +++ .../windows-waitable-queues/src/capacity.rs | 75 ++ crates/windows-waitable-queues/src/error.rs | 56 +- crates/windows-waitable-queues/src/lib.rs | 20 +- crates/windows-waitable-queues/src/mpsc.rs | 858 +++++++++++++++ .../windows-waitable-queues/src/mpsc/tests.rs | 977 ++++++++++++++++++ crates/windows-waitable-queues/src/spsc.rs | 232 ++--- .../windows-waitable-queues/src/spsc/tests.rs | 21 +- crates/windows-waitable-queues/src/traits.rs | 209 ++++ .../src/traits/tests.rs | 183 ++++ 15 files changed, 3024 insertions(+), 167 deletions(-) create mode 100644 crates/windows-waitable-queues/src/arm_race.rs create mode 100644 crates/windows-waitable-queues/src/blocking.rs create mode 100644 crates/windows-waitable-queues/src/capacity.rs create mode 100644 crates/windows-waitable-queues/src/mpsc.rs create mode 100644 crates/windows-waitable-queues/src/mpsc/tests.rs create mode 100644 crates/windows-waitable-queues/src/traits.rs create mode 100644 crates/windows-waitable-queues/src/traits/tests.rs diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index e7455cef..3a238ca8 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -179,10 +179,43 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m ## M31 -- The MPSC shape and the queue's contract -- [ ] **M31.1** -- The bounded array MPSC: Vyukov's sequence protocol, where a producer CASes the tail +- [x] **M31.1** -- The bounded array MPSC: Vyukov's sequence protocol, where a producer CASes the tail forward, writes, then publishes by storing the slot's sequence. Lock-free rather than wait-free, bounded by construction so backpressure is free, and no allocation anywhere. Pad the head and tail onto separate cache lines and say so in a comment, because the padding is load-bearing and looks like waste. + **Done as `src/mpsc.rs`**, with the padding commented at *both* positions rather than once, since a + reader arriving at either field is the one who might delete it. Recorded as + [D-10](crates/windows-waitable-queues/DESIGN-NOTES.md#d-10). + **The traits landed here too, because M30.2 scheduled them here** ("the traits themselves land with + M31.1") and [D-3](crates/windows-waitable-queues/DESIGN-NOTES.md#d-3) required a second implementation + to validate them against. **The signatures `spsc` wrote down in advance held unchanged**, which is that + check actually being run rather than assumed, and the load-bearing one turned out to be `push(&self)`: + `&mut self` would have been sound for one producer and would have made the trait *unimplementable* by + this shape. Recorded as [D-11](crates/windows-waitable-queues/DESIGN-NOTES.md#d-11). `Reserving`, + `LossReporting` and `Observable` are deliberately still absent -- they belong to M31.2 and M31.4, and + shipping an empty trait now would be the design-in-a-vacuum D-3 forbids, one level up. + **The protocol refused a capacity of one, and that is reported rather than worked around.** With a + single slot, "published at `p`" and "free again at `p + capacity`" are the *same number*, so a producer + would read the sequence of the item it had just pushed, conclude the slot was free, and overwrite an + unread item. `spsc` accepts one, so the minimum is a property of the *shape*, not of the crate -- + `CapacityError` already carried a `max_valid` on exactly that argument and now carries a `min_valid` + too. Every workaround considered puts a load of the consumer's position back on the producer's hot path + for every queue, in order to serve a capacity of one that `spsc` already represents exactly. + [D-12](crates/windows-waitable-queues/DESIGN-NOTES.md#d-12). + **Two things were extracted rather than copied, and one of them is a contract.** The blocking receive + loop *is* the arming protocol (D-9), not glue around it, so a second spelling of it would have been a + second copy of a rule -- the exact mistake M30.5 already paid for, where a lost-wakeup proof exercised a + hand-written duplicate of `arm` and could not have noticed the real `arm` being reversed. It now lives + in `blocking.rs` with shapes binding to it, and the `ARM_RACE` hook is shared for the same reason + ([D-13](crates/windows-waitable-queues/DESIGN-NOTES.md#d-13)). The capacity rule moved to `capacity.rs` + on the weaker version of the same argument. + **One question the checklist did not anticipate: what "empty" means for arming.** `len` and "would `pop` + find something" disagree over a slot a producer has claimed but not published, and arming on `len` is + safe but spins until that producer is rescheduled. Arming asks the readiness question instead, which is + also what puts D-9's `SeqCst` pair on the right two locations for this shape + ([D-14](crates/windows-waitable-queues/DESIGN-NOTES.md#d-14)). + 120 unit tests and 4 doctests, the whole suite in 0.31s. Nine new sabotage entries, one of them a + control. - [ ] **M31.2** -- Overflow policy, which is more than "return `Err`". Ship fail-fast plus a `reserve` that guarantees a slot for a message that must not be lost, following diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 69453f5c..3c0929a3 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -1,8 +1,9 @@ # Design notes: windows-waitable-queues (Tier 1) -This crate is a skeleton. This file records the decisions its code will be built against, taken during -the 2026-08-30 design session and transcribed here so they steer the work rather than sitting in a -session record nothing is obliged to read. The work itself is tracked in +This file records the decisions this crate's code is built against. D-1 to D-9 were taken during the +2026-08-30 design session and transcribed here so they steer the work rather than sitting in a session +record nothing is obliged to read; D-10 onwards were taken while building the shapes those decisions +called for, and record what the building settled or corrected. The work itself is tracked in [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) at the workspace root, because it spans several components. @@ -32,6 +33,11 @@ preferred. | D-6 | **Overflow fails or reserves, and never overwrites.** For telemetry an overwritten entry is a lost sample; for an I/O submission it is a lost operation, and the two must not share a policy knob. | | D-7 | **Shapes are plain modules, not Cargo features, until compile time justifies otherwise.** Two features are four configurations to test, against a benefit dead-code elimination already provides. | | D-8 | **Published, and the obligation is accepted deliberately.** Unlike `windows-guard-alloc`, this is general-purpose and its first consumer is not its only plausible one. | +| D-10 | **The multi-producer shape is Vyukov's bounded array queue: a sequence number per slot, claimed by a compare-and-swap on the tail and published by a release store.** The sequence is what lets the consumer tell a *claimed* slot from a *written* one, which a plain fetch-and-add cannot. Lock-free rather than wait-free, bounded by construction, and no allocation after the constructor. | +| D-11 | **The capability traits shipped with this second shape, and the signatures `spsc` wrote down in advance held unchanged.** That is [D-3](#d-3)'s check actually being run rather than assumed. The load-bearing choice was `push(&self)`: `&mut self` would have been sound for one producer and would have made the trait unimplementable by this one. | +| D-12 | **A shape's *minimum* capacity belongs to the shape, not to the crate, and `mpsc`'s is two.** One slot cannot encode three states when the lap stride is the capacity, so "published at `p`" and "free again at `p + capacity`" collide. Reported through `CapacityError` rather than worked around, because every available workaround puts a load back on the producer's hot path for every queue in order to serve a capacity of one. | +| D-13 | **The arming protocol is written once, in `blocking.rs`, and a shape binds to it by implementing a crate-private `Parked` trait.** The blocking receive loop *is* [D-9](#d-9), not glue around it; a second shape spelling it out again would be a second copy of a rule -- the exact mistake this crate has already paid for once. | +| D-14 | **`mpsc`'s arming asks "would `pop` find something", not "is `len` zero".** The two disagree over a slot a producer has claimed but not published, and only the first answer lets the consumer park on it instead of spinning until that producer is rescheduled. | ## D-2: capabilities are sliced, not gathered @@ -258,3 +264,127 @@ It is accepted because this crate is general-purpose in a way `windows-guard-all anything but a test binary. These queues carry no such trap, the first consumer is not the only plausible one, and a Windows Rust program that wants to wait on a queue and a kernel object together currently has to write this itself. + +## D-10: the MPSC shape is Vyukov's bounded array queue + +The obvious multi-producer array queue claims an index with a fetch-and-add, writes the slot, and lets the +consumer read it. It does not work, and the reason is worth stating because it is the whole justification +for the extra machinery: **the consumer cannot tell a slot that has been claimed from one that has been +written.** A producer preempted between the two leaves a hole, and a consumer reading through the hole +reads uninitialized memory. + +A sequence number per slot carries both facts at once. Slot `i` starts at `i`; a producer may claim +position `pos` only when the slot reads `pos`, and publishes by storing `pos + 1`; the consumer takes the +slot only when it reads exactly `pos + 1`, and frees it by storing `pos + capacity`, the position the next +lap will claim it at. A claimed-but-unwritten slot is therefore invisible to the consumer, and there is no +hole to read through. + +What this buys, and what it costs: + +- **Bounded by construction, so backpressure is free.** A full queue is a slot whose sequence has not come + round, which costs one load to discover. There is no separate count to maintain, no allocation to fail, + and no policy knob -- the refusal *is* the backpressure, which is [D-6](#d-6) in its cheapest form. +- **No allocation after the constructor**, which is what makes it usable on an I/O submission path. +- **Lock-free, not wait-free.** A producer that loses its compare-and-swap retries, with no bound on how + many times it may lose. What is guaranteed is that some producer always makes progress, and -- the + property that actually matters here -- that a producer suspended by the scheduler blocks no other + producer. It blocks only the consumer's view of the items queued behind it, and only until it resumes. +- **Order is claim order, not publication order.** If producer A claims position 5 and producer B claims + 6 and publishes first, the consumer must wait for A. This is not a defect to engineer around: it is what + makes the queue a FIFO at all. B's signal wakes a parked consumer that then finds nothing, which is a + spurious wakeup the protocol already tolerates, and A's own signal follows when it publishes. + +The head and the tail are padded onto separate cache lines. The padding is load-bearing and looks like +waste, which is why it is commented at both fields rather than at one: every successful push writes the +tail and every successful pop writes the head, so adjacent they would false-share, and each write would +invalidate the other side's copy of a value it only reads. That cost has no symptom other than being +slow, which is exactly the kind that survives a code review. + +## D-11: the traits shipped here, and the check D-3 demanded was actually run + +[D-3](#d-3) said no trait ships until a second implementation exists to validate it, and that the trait +*shape* would be fixed in advance so the concrete types could not diverge. `spsc` accordingly wrote its +intended signatures into its module documentation before its types existed. This milestone is where that +promissory note came due. + +**The signatures held unchanged.** `push`, `pop`, `is_disconnected`, `capacity`, `len`, `is_empty` are +what [`traits.rs`](src/traits.rs) says now and what that comment said then. The check is not rhetorical: +`mpsc` is a lock-free array queue with a per-slot state machine and no structural resemblance to a +two-position ring, so a signature fitted to the first shape would have failed here rather than in a +consumer's code. + +**One choice turned out to be the load-bearing one, and it is worth naming.** `push(&self)` rather than +`push(&mut self)`. `&mut self` would have been perfectly sound for a single producer, is what several SPSC +crates use, and would have made this trait *unimplementable* by a shape whose whole point is several +threads pushing at once. It was chosen in advance on the argument that one spelling has to serve every +shape; this shape is the evidence that the argument was right. + +Two smaller decisions recorded so they are not re-litigated: + +- **The traits are also the names of the concrete handles.** `Producer` and `Consumer` are both a trait + and, in each shape's module, a type. That is deliberate -- the trait is named for the role, the handle + is named for the role, and the handle plays the role -- and `std` does the same with `fmt::Write` and + `io::Write`. A caller wanting only the methods imports them anonymously (`Consumer as _`). +- **`Reserving`, `LossReporting` and `Observable` from [D-2](#d-2)'s table are deliberately still absent.** + They belong to work that has not happened (M31.2, M31.4), and shipping an empty trait now would be the + design-in-a-vacuum D-3 forbids, one level up. + +## D-12: the minimum capacity belongs to the shape, and mpsc's is two + +`spsc` accepts a capacity of one. `mpsc` cannot, and the reason is arithmetic rather than taste. Its slot +sequence distinguishes three states by counting -- `pos` is free, `pos + 1` is published, `pos + capacity` +is free again on the next lap -- and when `capacity == 1` the second and third are the *same number*. A +producer would read the sequence of the item it had just pushed, conclude the slot was free, and overwrite +an item the consumer had not read. + +**It is reported, not worked around.** The obvious workaround -- allocate two slots and refuse the second +-- reintroduces a load of the consumer's position on the producer's hot path, which is precisely the cost +the sequence protocol exists to avoid, and it would impose that cost on *every* queue in order to serve a +capacity of one. A caller that genuinely wants a one-item handoff wants `spsc`, which represents it +exactly. + +The consequence for the error type is small and was anticipated: `CapacityError` already carried a +`max_valid` on the argument that a bound "follows from how a shape represents its positions", and it now +carries a `min_valid` for the same reason. The suggestion methods respect it, so `bounded::(1)` on an +`mpsc` reports `next_valid() == Some(2)` rather than a correction that would itself be refused. + +Each shape names its own minimum as a documented constant next to the code that needs it, rather than +passing a bare literal, so the number is never separated from the reason for it. + +## D-13: the arming protocol is stated once, and shapes bind to it + +The blocking receive loop is not glue around [D-9](#d-9) -- it *is* D-9, executed: drain, arm, check for +disconnection, and wait only if arming blessed it. Every step is load-bearing and the order is the whole +correctness argument. + +So it lives in [`blocking.rs`](src/blocking.rs), and a shape gains `recv` and `recv_timeout` by +implementing a crate-private `Parked` trait. A second shape spelling the loop out again would be a second +copy of a rule, free to drift, and -- the failure mode that actually bites -- free to *look* verified while +only the copy was tested. This crate has already paid for that once: the first lost-wakeup proof exercised +a hand-written duplicate of `Consumer::arm` and was structurally incapable of noticing the real `arm` +being reversed. The `ARM_RACE` hook is shared for the same reason. + +`Parked` is deliberately *not* one of the public capability traits. The public traits say what a caller may +ask of a queue; `Parked` says what the blocking loop needs from one, and the difference shows in `finish`, +whose contract is a precondition no external caller can check. + +## D-14: mpsc arms on readiness, not on emptiness + +`Consumer::arm` must answer "is it safe to park?", and for `mpsc` that is not the same question as "is the +queue empty". They disagree over a slot a producer has claimed but not yet published, and the disagreement +matters in both directions: + +- **`len` says non-empty**, because it counts the claim. Arming on that would refuse to bless the wait, and + the consumer would spin -- calling `pop`, getting `None`, re-arming, getting `false` -- until the + producer was rescheduled. Correct, and a burnt core. +- **Readiness says nothing is takeable**, so the consumer parks. That is safe precisely because the + producer's publishing release store is followed by a signal, so the wakeup is guaranteed to arrive. + +Arming therefore asks `Shared::has_ready_item`, which is the exact question `pop` answers: is the slot at +the head position published? `len` keeps its cheaper definition and its documented over-count, because it +is a metric rather than a control-flow input. + +This also places the `SeqCst` pairing from D-9 correctly for this shape: the producer stores the slot's +sequence and then loads the doorbell state, while the consumer stores the doorbell state and then loads +that same sequence. It is the same store-buffer shape, over the same two fences, with a different pair of +locations. diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 10aa0e73..fc51566a 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -5,10 +5,17 @@ Bounded producer/consumer queues whose readiness is a waitable Windows `HANDLE`. **Windows only.** Every public item is behind `cfg(windows)`; the crate builds to an empty shell on other platforms. -**Status: skeleton.** The shapes are not implemented yet. The decisions they will -be built against are in [DESIGN-NOTES.md](DESIGN-NOTES.md), and the work is -tracked in [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) at the -workspace root. +**Status: two shapes, both waitable.** `spsc` is a bounded ring with no +compare-and-swap on either side; `mpsc` is a bounded array queue using Vyukov's +sequence protocol, so any number of producers may push without a lock. Either can +be polled with no kernel object at all, blocked on directly, or waited on +alongside other handles. The capability traits over them -- +`Producer`, `Consumer`, `Bounded`, `Waitable` -- ship with the second shape, +which is what validated them. + +The decisions all of this was built against are in +[DESIGN-NOTES.md](DESIGN-NOTES.md), and the remaining work is tracked in +[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) at the workspace root. ## Why @@ -43,14 +50,20 @@ shape it wants. Each shape splits into a **producer handle** and a **consumer handle**, and cardinality is carried by whether those handles are `Clone`: -| Shape | Producer | Consumer | -|---|---|---| -| SPSC | not `Clone` | not `Clone` | -| MPSC | `Clone` | not `Clone` | -| MPMC | `Clone` | `Clone` | +| Shape | Producer | Consumer | Shipped | +|---|---|---|---| +| SPSC | not `Clone` | not `Clone` | yes | +| MPSC | `Clone` | not `Clone` | yes | +| MPMC | `Clone` | `Clone` | not yet | So "single producer" is a fact the compiler enforces, not a sentence in a doc -comment. +comment: the handles are also not `Sync`, so a handle that cannot be cloned and +cannot be shared is held by exactly one thread. + +The two shapes also disagree about their smallest usable capacity, and the error +says so rather than the documentation: `spsc` accepts one slot, and `mpsc` needs +two, because its per-slot sequence cannot distinguish "just published" from "free +again next lap" in a one-slot ring. ## What it will not do @@ -62,6 +75,9 @@ comment. construction. - **It will not create a kernel object you never use.** The doorbell is created lazily, so a consumer that only polls allocates none. +- **It will not round your capacity.** A capacity that a shape cannot represent + is refused, with the nearest valid neighbours on the error, rather than + silently turned into one the caller did not choose. ## Licence diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json index 444de268..3116cd15 100644 --- a/crates/windows-waitable-queues/sabotage.json +++ b/crates/windows-waitable-queues/sabotage.json @@ -1,6 +1,6 @@ { "package": "windows-waitable-queues", - "description": "Sabotages for the SPSC ring and its doorbell. Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format and for why the results are read the way they are.", + "description": "Sabotages for the SPSC ring, the MPSC array queue, the doorbell they share, and the blocking receive loop they share. Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format and for why the results are read the way they are.", "notCoveredHere": "The two SeqCst fences in doorbell.rs are deliberately ABSENT from this manifest, and their absence is not an oversight. Removing either leaves every test green, because the defect they prevent is a store-buffer reordering that no amount of stress testing reliably produces -- it is a fact about the memory model, not about any interleaving a scheduler will hand you. Adding them here with expect:'caught' would fail the sweep; adding them with expect:'survives' would assert they are harmless, which is false and far worse. They are verifiable only under a model checker, and are the named target of checklist item M31.6.", "sabotages": [ { @@ -43,14 +43,14 @@ "find": [ " self.shared.doorbell.clear();", " #[cfg(test)]", - " run_arm_race_hook();", + " crate::arm_race::run();", " Ok(self.is_empty())" ], "replace": [ " let empty = self.is_empty();", " self.shared.doorbell.clear();", " #[cfg(test)]", - " run_arm_race_hook();", + " crate::arm_race::run();", " Ok(empty)" ] }, @@ -135,9 +135,9 @@ }, { "name": "recv_timeout computes its deadline with the panicking add", - "file": "src/spsc.rs", + "file": "src/blocking.rs", "expect": "caught", - "why": "`Instant + Duration` panics when the sum is not representable, and Duration::MAX is an ordinary way to spell 'effectively forever'. The rest of the function is careful about exactly this class of problem, which is what made the panicking operator easy to miss.", + "why": "`Instant + Duration` panics when the sum is not representable, and Duration::MAX is an ordinary way to spell 'effectively forever'. The rest of the function is careful about exactly this class of problem, which is what made the panicking operator easy to miss. Moved here from src/spsc.rs when the blocking receive loop was extracted so both shapes bind to one copy of the arming protocol; there is now exactly one site, and both shapes' suites indict it.", "find": [ " let Some(deadline) = Instant::now().checked_add(timeout) else {" ], @@ -181,6 +181,138 @@ " self.signalled.store(true, Ordering::Release);", " if false {" ] + }, + { + "name": "mpsc push does not signal the doorbell", + "file": "src/mpsc.rs", + "expect": "caught", + "why": "A producer that never rings the bell leaves a parked consumer asleep on a queue with items in it. Caught as a hang, which is the correct shape for this defect.", + "find": [ + " self.shared.doorbell.signal();", + " Ok(())" + ], + "replace": [ + " Ok(())" + ] + }, + { + "name": "mpsc: the last producer's drop does not signal", + "file": "src/mpsc.rs", + "expect": "caught", + "why": "Disconnection is a wakeup, and the only one no other party can deliver. Without it a blocked consumer waits forever for an item that can no longer be sent. NOTE the shape of this patch -- it deletes the live call rather than inserting unreachable code beside it, because a sabotage that does not sabotage retires a question that was never asked.", + "find": [ + " self.shared.doorbell.signal();", + " }", + "}", + "", + "/// The reading half" + ], + "replace": [ + " }", + "}", + "", + "/// The reading half" + ] + }, + { + "name": "CONTROL: mpsc signals on every producer's departure, not only the last", + "file": "src/mpsc.rs", + "expect": "survives", + "why": "A control, not a defect. Ringing when a non-final producer leaves is a SPURIOUS wakeup: the consumer wakes, finds nothing, sees producers still alive, and parks again. The contract says a wakeup may be spurious, so the suite MUST stay green. If this is ever reported as caught, a test has started asserting that no extra wakeups occur -- which is asserting the implementation -- and that test is the thing to fix. Note this is NOT the same as the entry above: that one deletes the last producer's signal, which is a lost wakeup and a hang.", + "find": [ + " if self.shared.producers.fetch_sub(1, Ordering::AcqRel) != 1 {", + " return;", + " }" + ], + "replace": [ + " let _ = self.shared.producers.fetch_sub(1, Ordering::AcqRel);" + ] + }, + { + "name": "mpsc arm checks readiness before clearing", + "file": "src/mpsc.rs", + "expect": "caught", + "why": "The lost wakeup itself, in the second shape. A push landing between the check and the clear both signals and has its signal erased, so the consumer sleeps on a queue that is not empty and will never be signalled again. Driven deterministically through the REAL arm by the shared ARM_RACE hook rather than through a copy of it, which is what makes this caught every run rather than one in three.", + "find": [ + " self.shared.doorbell.clear();", + " #[cfg(test)]", + " crate::arm_race::run();", + " // Deliberately not `is_empty`. The question is whether `pop` would find", + " // something, and a slot that a producer has claimed but not published", + " // is not something `pop` can find -- see `Shared::has_ready_item`.", + " Ok(!self.shared.has_ready_item())" + ], + "replace": [ + " let ready = self.shared.has_ready_item();", + " self.shared.doorbell.clear();", + " #[cfg(test)]", + " crate::arm_race::run();", + " Ok(!ready)" + ] + }, + { + "name": "mpsc arm does not create the doorbell before checking", + "file": "src/mpsc.rs", + "expect": "caught", + "why": "Lazy creation is the same hazard a third time: a producer running while no event exists skips signalling, so the readiness check has to come after the event exists to catch what that skip left behind.", + "find": [ + " self.shared.doorbell.handle()?;", + " self.shared.doorbell.clear();" + ], + "replace": [ + " self.shared.doorbell.clear();" + ] + }, + { + "name": "mpsc frees a slot one short of the next lap", + "file": "src/mpsc.rs", + "expect": "caught", + "why": "The sequence protocol's whole arithmetic in one line. A slot freed at `pos + capacity - 1` is never equal to the position that next claims it, so every producer reads a negative difference and reports Full for ever: the queue works for exactly one lap and then wedges. An off-by-one here is invisible to any test that never wraps, which is why the wrap tests run a thousand rounds through four slots.", + "find": [ + " position.wrapping_add(self.shared.capacity)," + ], + "replace": [ + " position.wrapping_add(self.shared.capacity - 1)," + ] + }, + { + "name": "mpsc cloning a producer does not count it", + "file": "src/mpsc.rs", + "expect": "caught", + "why": "The count is what makes multi-producer disconnection work, and a clone that does not register makes the FIRST departure look like the last. The consumer then ends the stream while producers are still pushing into it.", + "find": [ + " self.shared.producers.fetch_add(1, Ordering::Relaxed);" + ], + "replace": [ + "" + ] + }, + { + "name": "mpsc accepts a capacity of one", + "file": "src/mpsc.rs", + "expect": "caught", + "why": "With one slot, 'published at position p' and 'free again at position p + capacity' are the SAME number, so a producer reads the sequence of the item it just pushed, concludes the slot is free, and overwrites an item the consumer has not read. spsc accepts one, which is exactly why the minimum belongs to the shape rather than to the crate -- and why it is asserted rather than assumed.", + "find": [ + "const MIN_CAPACITY: usize = 2;" + ], + "replace": [ + "const MIN_CAPACITY: usize = 1;" + ] + }, + { + "name": "mpsc reports Full for a full queue whose consumer is gone", + "file": "src/mpsc.rs", + "expect": "caught", + "why": "Full invites a retry and Disconnected does not, and a full queue with no consumer will never drain -- so reporting Full here is telling the caller to spin forever. The preference has to be stated at the fullness branch specifically, because that branch returns before the general disconnection check below it is ever reached.", + "find": [ + " if !self.shared.consumer_live.load(Ordering::Acquire) {", + " return Err(PushError::Disconnected(item));", + " }", + " return Err(PushError::Full(item));" + ], + "replace": [ + " return Err(PushError::Full(item));" + ] } ] } diff --git a/crates/windows-waitable-queues/src/arm_race.rs b/crates/windows-waitable-queues/src/arm_race.rs new file mode 100644 index 00000000..46b10c9f --- /dev/null +++ b/crates/windows-waitable-queues/src/arm_race.rs @@ -0,0 +1,56 @@ +// Copyright (c) Mike Grier. + +//! Test-only: the hook that drives `arm` through its own race window. +//! +//! `Consumer::arm` clears the doorbell and *then* checks whether anything is +//! takeable. The reverse order reads more naturally and is a permanent hang, +//! so the correct order has to be proven rather than asserted -- which means a +//! test must place a push inside the window between those two statements. +//! +//! # Why a hook rather than a hand-written copy of `arm` +//! +//! The first attempt at this proof was a duplicate of `arm` with the two +//! statements swapped, driven deterministically. It could only ever show that +//! *a* reversed order is wrong; it was structurally incapable of noticing the +//! **real** `arm` being reversed, which left that case covered only by whatever +//! interleavings the scheduler happened to produce. Measured: sabotaging the +//! real `arm` was then caught in one run out of three, because detection +//! depended on two threads meeting inside a window tens of nanoseconds wide. +//! +//! A second copy of a rule checks the copy, not the rule. So the real `arm` +//! carries this hook, and a test drives the real code through the exact window +//! on one thread. +//! +//! # Why it is shared between the shapes +//! +//! Both bounded shapes implement the same protocol, and each needs the same +//! proof. Giving each its own hook would reintroduce the duplication this file +//! exists to avoid, one layer down. The hook is thread-local, so two shapes' +//! tests running concurrently in one process cannot see each other's. + +use core::cell::RefCell; + +thread_local! { + static HOOK: RefCell>> = const { RefCell::new(None) }; +} + +/// Runs the installed hook, if any. Called from inside `arm`. +pub(crate) fn run() { + HOOK.with(|hook| { + // Taken out for the call rather than held borrowed across it, so a hook + // that touches the queue cannot trip a `RefCell` re-entrancy panic. + let taken = hook.borrow_mut().take(); + if let Some(mut race) = taken { + race(); + *hook.borrow_mut() = Some(race); + } + }); +} + +/// Installs a hook for the duration of a closure. +pub(crate) fn with(race: impl FnMut() + 'static, body: impl FnOnce() -> R) -> R { + HOOK.with(|hook| *hook.borrow_mut() = Some(Box::new(race))); + let result = body(); + HOOK.with(|hook| *hook.borrow_mut() = None); + result +} diff --git a/crates/windows-waitable-queues/src/blocking.rs b/crates/windows-waitable-queues/src/blocking.rs new file mode 100644 index 00000000..20a28a23 --- /dev/null +++ b/crates/windows-waitable-queues/src/blocking.rs @@ -0,0 +1,155 @@ +// Copyright (c) Mike Grier. + +//! The blocking receive loop, written once for every shape that has one. +//! +//! # Why this is not simply copied into each shape +//! +//! The loop below is not glue -- it *is* the arming protocol, the contract +//! recorded as [D-9](../../DESIGN-NOTES.md#d-9): drain, arm, and wait only if +//! arming blessed it, with the disconnection check placed between the arming +//! and the wait so a producer that vanished cannot leave a consumer parked. +//! Every step is load-bearing and the order is the whole correctness argument. +//! +//! A second shape spelling that sequence out again would be a second copy of a +//! rule, free to drift from the first and, worse, free to *look* verified while +//! only the copy was tested. This crate has already paid for that mistake once, +//! in a lost-wakeup test that exercised a hand-written duplicate of +//! `Consumer::arm` rather than the real one and so could not have noticed the +//! real one being reversed. So the protocol is stated here, and a shape binds +//! to it by implementing [`Parked`]. +//! +//! # Why [`Parked`] is not one of the public capability traits +//! +//! The public traits describe what a caller may *ask of* a queue. [`Parked`] +//! describes what this module needs *from* a queue in order to park on it, and +//! the difference shows in [`Parked::finish`], which no caller should ever +//! reach for: it is meaningful only after disconnection has already been +//! observed, and the public [`Consumer`](crate::Consumer) surface deliberately +//! does not offer a method whose contract is a precondition nobody can check +//! from outside. + +use std::io; +use std::os::windows::io::{AsRawHandle, BorrowedHandle}; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::System::Threading::{INFINITE, WaitForSingleObject}; + +use crate::error::{RecvError, RecvTimeoutError}; + +/// What a shape must offer for [`recv`] and [`recv_timeout`] to park on it. +pub(crate) trait Parked { + /// The item type the shape carries. + type Item; + + /// Takes the oldest item, or `None` if there is none right now. + fn pop(&self) -> Option; + + /// The last take before the end of the stream is reported. + /// + /// Separate from [`Parked::pop`] so a shape can name the step and a test + /// can call it directly. It guards a real and narrow race: a producer may + /// push *and then* drop in the window between this loop's first `pop` and + /// its disconnection check, and reporting the disconnection without one + /// last take would silently discard an item that was successfully sent. + fn finish(&self) -> Option; + + /// Clears the doorbell and reports whether waiting on it is safe. + /// + /// # Errors + /// + /// Whatever creating the doorbell reports. + fn arm(&self) -> io::Result; + + /// Whether every producer is gone. + fn is_disconnected(&self) -> bool; + + /// The doorbell to park on. + /// + /// # Errors + /// + /// Whatever creating the doorbell reports. + fn doorbell(&self) -> io::Result>; +} + +/// Takes the oldest item, blocking until one arrives. +/// +/// # Errors +/// +/// [`RecvError::Disconnected`] once every producer is gone *and* the queue is +/// drained -- items pushed before the last producer dropped are still +/// delivered. [`RecvError::Io`] if the doorbell cannot be created or waited on. +pub(crate) fn recv(consumer: &C) -> Result { + loop { + if let Some(item) = consumer.pop() { + return Ok(item); + } + if !consumer.arm()? { + continue; + } + if consumer.is_disconnected() { + return consumer.finish().ok_or(RecvError::Disconnected); + } + wait(consumer.doorbell()?, INFINITE)?; + } +} + +/// Takes the oldest item, blocking until one arrives or the deadline passes. +/// +/// The timeout bounds the whole call, not each individual wait: a consumer +/// woken spuriously does not get a fresh budget. +/// +/// # Errors +/// +/// [`RecvTimeoutError::Timeout`] if the deadline passes with the queue still +/// empty, which is not a malfunction. Otherwise as [`recv`]. +pub(crate) fn recv_timeout( + consumer: &C, + timeout: Duration, +) -> Result { + // `Instant + Duration` panics when the sum is not representable, and + // `Duration::MAX` is a perfectly ordinary way to spell "effectively + // forever". A library that panics on that is worse than one that blocks, so + // an unrepresentable deadline degrades to the untimed wait it was asking + // for rather than aborting the caller. + let Some(deadline) = Instant::now().checked_add(timeout) else { + return recv(consumer).map_err(|error| match error { + RecvError::Disconnected => RecvTimeoutError::Disconnected, + RecvError::Io(io) => RecvTimeoutError::Io(io), + }); + }; + loop { + if let Some(item) = consumer.pop() { + return Ok(item); + } + if !consumer.arm()? { + continue; + } + if consumer.is_disconnected() { + return consumer.finish().ok_or(RecvTimeoutError::Disconnected); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(RecvTimeoutError::Timeout); + } + // Saturating rather than wrapping: a duration longer than a `u32` of + // milliseconds is roughly 49 days, and clamping it to that is a longer + // wait than any caller meant, where truncating it would be a far + // shorter one. The loop re-arms and waits again, so clamping costs an + // extra turn and nothing else. + let millis = u32::try_from(remaining.as_millis()).unwrap_or(u32::MAX); + wait(consumer.doorbell()?, millis)?; + } +} + +/// Block on a doorbell handle, translating the Win32 result. +fn wait(handle: BorrowedHandle<'_>, millis: u32) -> io::Result<()> { + // SAFETY: a live event handle borrowed for the duration of the call. + let result = unsafe { WaitForSingleObject(handle.as_raw_handle(), millis) }; + match result { + // A timeout is not an error here: the caller's loop re-checks its own + // deadline and decides what a timeout means. + WAIT_OBJECT_0 | WAIT_TIMEOUT => Ok(()), + _ => Err(io::Error::last_os_error()), + } +} diff --git a/crates/windows-waitable-queues/src/capacity.rs b/crates/windows-waitable-queues/src/capacity.rs new file mode 100644 index 00000000..21da9842 --- /dev/null +++ b/crates/windows-waitable-queues/src/capacity.rs @@ -0,0 +1,75 @@ +// Copyright (c) Mike Grier. + +//! The capacity rule, stated once for every bounded shape. +//! +//! It lives here rather than inside a shape's module because both bounded +//! shapes enforce the same rule for the same reason, and a second copy of a +//! rule is free to drift from the first. A test that wants to check a suggested +//! capacity asks [`validate_capacity`] rather than re-encoding the three +//! conditions, which is the difference between checking the rule and checking a +//! paraphrase of it. +//! +//! The bounds are carried on [`CapacityError`] rather than assumed to be +//! crate-wide constants, because they follow from how a shape represents its +//! positions -- and the two shapes shipped so far already disagree about the +//! lower one. `spsc` accepts a capacity of one; `mpsc` cannot, because its slot +//! state machine encodes "published" as one past the claim position and "free +//! again" as one lap past it, and with a single slot those are the same number. +//! So each shape supplies its own minimum and this module applies it, which is +//! the arrangement the error type was already shaped for. + +use crate::error::CapacityError; + +/// The largest capacity that keeps a wrapping position difference unambiguous. +/// +/// Positions are monotonic and wrap with the integer, so both shapes need the +/// difference between two of them to be readable as a signed quantity: +/// +/// - `spsc` computes the number of items held as `tail.wrapping_sub(head)`, +/// which is the true difference only while that difference cannot exceed half +/// the range. +/// - `mpsc` compares a slot's sequence number against a position by +/// interpreting `sequence.wrapping_sub(position)` as an [`isize`], which is +/// the same requirement written a different way. +pub(crate) const MAX_CAPACITY: usize = usize::MAX / 2; + +/// Whether a bounded shape will accept a capacity, and why not if it will not. +/// +/// A power of two is required so a position can be reduced to a slot index with +/// a mask rather than a division, and the requested number is the exact number +/// of items the queue holds -- not a hint, and not rounded. See +/// [`CapacityError`] for why a rejection is preferred to silently rounding. +/// +/// `min_valid` is the calling shape's own smallest usable capacity. It is a +/// parameter rather than a constant because it is a property of the shape's +/// slot representation, and the two shapes do not agree on it. Each shape names +/// its own and says why, so the number is never a bare literal at a call site. +/// +/// Separated from each shape's constructor so the rule can be *asked* rather +/// than restated. A test that wants to check a suggested capacity is acceptable +/// would otherwise have to either re-encode these conditions -- a second copy +/// of a rule, free to drift from this one -- or call the constructor, which for +/// a capacity near the bound means trying to allocate half the address space. +pub(crate) fn validate_capacity(capacity: usize, min_valid: usize) -> Result<(), CapacityError> { + debug_assert!( + min_valid.is_power_of_two(), + "a shape's minimum is suggested to callers verbatim, so it must itself be valid" + ); + if capacity == 0 { + return Err(CapacityError::zero(min_valid, MAX_CAPACITY)); + } + if !capacity.is_power_of_two() { + return Err(CapacityError::not_power_of_two( + capacity, + min_valid, + MAX_CAPACITY, + )); + } + if capacity < min_valid { + return Err(CapacityError::too_small(capacity, min_valid, MAX_CAPACITY)); + } + if capacity > MAX_CAPACITY { + return Err(CapacityError::too_large(capacity, min_valid, MAX_CAPACITY)); + } + Ok(()) +} diff --git a/crates/windows-waitable-queues/src/error.rs b/crates/windows-waitable-queues/src/error.rs index f46aaaea..8fa99609 100644 --- a/crates/windows-waitable-queues/src/error.rs +++ b/crates/windows-waitable-queues/src/error.rs @@ -18,6 +18,13 @@ use std::io; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CapacityError { requested: usize, + /// The smallest capacity the rejecting shape accepts. + /// + /// Carried for the same reason as [`Self::max_valid`], and it is not always + /// one: `mpsc` cannot represent a capacity below two, because its slot + /// state machine reuses a sequence number one lap later and a one-slot ring + /// would make "published" and "free again" the same value. + min_valid: usize, /// The largest capacity the rejecting shape accepts. /// /// Carried on the error rather than assumed to be a crate-wide constant: @@ -33,34 +40,53 @@ pub struct CapacityError { enum CapacityErrorKind { Zero, NotPowerOfTwo, + TooSmall, TooLarge, } impl CapacityError { - pub(crate) fn zero(max_valid: usize) -> Self { + pub(crate) fn zero(min_valid: usize, max_valid: usize) -> Self { Self { requested: 0, + min_valid, max_valid, kind: CapacityErrorKind::Zero, } } - pub(crate) fn not_power_of_two(requested: usize, max_valid: usize) -> Self { + pub(crate) fn not_power_of_two(requested: usize, min_valid: usize, max_valid: usize) -> Self { Self { requested, + min_valid, max_valid, kind: CapacityErrorKind::NotPowerOfTwo, } } - pub(crate) fn too_large(requested: usize, max_valid: usize) -> Self { + pub(crate) fn too_small(requested: usize, min_valid: usize, max_valid: usize) -> Self { + Self { + requested, + min_valid, + max_valid, + kind: CapacityErrorKind::TooSmall, + } + } + + pub(crate) fn too_large(requested: usize, min_valid: usize, max_valid: usize) -> Self { Self { requested, + min_valid, max_valid, kind: CapacityErrorKind::TooLarge, } } + /// The smallest capacity the shape that rejected this request will accept. + #[must_use] + pub fn min_valid(&self) -> usize { + self.min_valid + } + /// The largest capacity the shape that rejected this request will accept. #[must_use] pub fn max_valid(&self) -> usize { @@ -84,15 +110,19 @@ impl CapacityError { /// down to the nearest power of two is not sufficient on its own: the /// nearest power of two below `usize::MAX` is 2^63, which exceeds the /// largest representable capacity, so the answer is clamped to - /// [`Self::max_valid`]. A suggestion that is itself refused would be worse - /// than none, because a caller acts on it and gets a second error. + /// [`Self::max_valid`], and a result below [`Self::min_valid`] is reported + /// as no suggestion at all. A suggestion that is itself refused would be + /// worse than none, because a caller acts on it and gets a second error. #[must_use] pub fn previous_valid(&self) -> Option { match self.kind { - CapacityErrorKind::Zero => None, + // Nothing valid lies below either of these: a request that was + // already too small has only larger answers, and zero has none. + CapacityErrorKind::Zero | CapacityErrorKind::TooSmall => None, CapacityErrorKind::NotPowerOfTwo | CapacityErrorKind::TooLarge => { let rounded = 1_usize << (usize::BITS - 1 - self.requested.leading_zeros()); - Some(rounded.min(self.largest_power_of_two_within_bound())) + let clamped = rounded.min(self.largest_power_of_two_within_bound()); + (clamped >= self.min_valid).then_some(clamped) } } } @@ -123,11 +153,14 @@ impl CapacityError { #[must_use] pub fn next_valid(&self) -> Option { let rounded = match self.kind { - CapacityErrorKind::Zero => Some(1), + // The shape's own minimum, not one: a shape whose slot state + // machine needs two slots would reject a suggestion of one, and a + // suggestion that is itself refused is worse than none. + CapacityErrorKind::Zero | CapacityErrorKind::TooSmall => Some(self.min_valid), CapacityErrorKind::NotPowerOfTwo => self.requested.checked_next_power_of_two(), CapacityErrorKind::TooLarge => None, }?; - (rounded <= self.max_valid).then_some(rounded) + (rounded >= self.min_valid && rounded <= self.max_valid).then_some(rounded) } } @@ -145,6 +178,11 @@ impl fmt::Display for CapacityError { self.requested, lo, hi ) } + CapacityErrorKind::TooSmall => write!( + f, + "capacity {} is below the smallest this queue shape can represent, which is {}", + self.requested, self.min_valid + ), CapacityErrorKind::TooLarge => write!( f, "capacity {} is too large; it must not exceed half of usize::MAX, so that the \ diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 3f1849ff..28efd04e 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -41,23 +41,35 @@ //! "single producer" is a fact the compiler enforces rather than a sentence in //! a doc comment. //! +//! What the shapes have in common is described by the [capability +//! traits](traits) -- [`Producer`], [`Consumer`], [`Bounded`], [`Waitable`] -- +//! each naming one thing a queue can do, so a caller can be generic over +//! exactly what it needs and nothing more. +//! //! # Status //! -//! [`spsc`] is implemented, with its doorbell: it can be polled with no kernel -//! object at all, blocked on directly, or waited on alongside other handles. -//! The remaining shapes land in the milestones tracked by -//! `CHECKLIST-io-domains.md` at the workspace root; the decisions they are +//! [`spsc`] and [`mpsc`] are implemented, both with their doorbell: either can +//! be polled with no kernel object at all, blocked on directly, or waited on +//! alongside other handles. The remaining shapes land in the milestones tracked +//! by `CHECKLIST-io-domains.md` at the workspace root; the decisions they are //! built against are recorded in `DESIGN-NOTES.md` beside this file. #![cfg_attr(docsrs, feature(doc_cfg))] #![warn(missing_docs)] #![warn(unsafe_op_in_unsafe_fn)] +#[cfg(test)] +mod arm_race; +mod blocking; +mod capacity; mod doorbell; mod error; +pub mod mpsc; pub mod spsc; +pub mod traits; pub use error::{CapacityError, PushError, RecvError, RecvTimeoutError}; +pub use traits::{Bounded, Consumer, Drain, Producer, Waitable}; /// Pads and aligns a value onto its own cache line. /// diff --git a/crates/windows-waitable-queues/src/mpsc.rs b/crates/windows-waitable-queues/src/mpsc.rs new file mode 100644 index 00000000..91dda378 --- /dev/null +++ b/crates/windows-waitable-queues/src/mpsc.rs @@ -0,0 +1,858 @@ +// Copyright (c) Mike Grier. + +//! The multi-producer, single-consumer bounded array queue. +//! +//! Any number of producers, one consumer, a fixed number of slots, and no +//! allocation after construction. It is the submission direction of a two-layer +//! ring, where many threads offer work and one domain thread takes it. +//! +//! # Vyukov's sequence protocol +//! +//! The obvious multi-producer array queue -- claim an index with a +//! fetch-and-add, write the slot, and let the consumer read it -- does not +//! work, because the consumer has no way to tell a slot that has been *claimed* +//! from one that has been *written*. A producer preempted between the two +//! leaves a hole, and a consumer reading through the hole reads uninitialized +//! memory. +//! +//! The remedy is a sequence number per slot, which carries both facts at once. +//! Slot `i` starts at sequence `i`, and thereafter: +//! +//! | Slot sequence, relative to a position `pos` | Meaning | +//! |---|---| +//! | `sequence == pos` | free, and this producer may claim it | +//! | `sequence == pos + 1` | written and published; the consumer may take it | +//! | `sequence < pos` | the queue is full at this position | +//! | `sequence > pos` | another producer got here first; re-read the tail | +//! +//! A producer claims a slot by advancing the shared tail with a +//! compare-and-swap, writes the item, and *publishes* it by storing +//! `pos + 1` into the slot's sequence with a release. The consumer takes a slot +//! only when it sees exactly that value, so a claimed-but-unwritten slot is +//! invisible to it. Taking an item frees the slot by storing +//! `pos + capacity`, which is the position the next lap will claim it at. +//! +//! **Lock-free, not wait-free.** A producer that loses its compare-and-swap +//! retries, and there is no bound on how many times it may lose. What is +//! guaranteed is that some producer always makes progress, and -- the property +//! that matters for an I/O submission path -- that a producer suspended by the +//! scheduler at any point blocks nobody but the consumer's view of the items +//! behind it, and never the other producers. +//! +//! **Bounded by construction, so backpressure is free.** A full queue is a slot +//! whose sequence has not come round, which costs one load to discover. There +//! is no separate count to maintain, no allocation to fail, and no policy knob: +//! the refusal *is* the backpressure. +//! +//! # The signatures, and what this shape validates +//! +//! [`spsc`](crate::spsc) wrote its intended trait signatures into its +//! documentation before its types existed, so that a second shape could be +//! checked against them rather than the traits being retrofitted to whichever +//! spelling came first. This is that second shape, and it matches: `push` and +//! `pop` take `&self`, the handles are split, and the error type is the shared +//! one. The traits themselves therefore ship with this module -- see +//! [`crate::traits`] and [D-3](../../DESIGN-NOTES.md#d-3). +//! +//! Exactly one cell of `spsc`'s auto-trait table changes, which is what "the +//! multi-producer shape relaxes exactly one cell" was written to predict: +//! +//! | | [`Clone`] | [`Send`] | [`Sync`] | +//! |---|---|---|---| +//! | [`Producer`] | **yes** | yes, if `T: Send` | no | +//! | [`Consumer`] | no | yes, if `T: Send` | no | +//! +//! Producers multiply by cloning, not by sharing: each thread owns its own +//! handle. Keeping the handle `!Sync` is not a leftover from `spsc` -- it means +//! a producer handle is never touched by two threads at once, so nothing about +//! this queue's cardinality has to be remembered rather than checked. + +use core::cell::{Cell, UnsafeCell}; +use core::fmt; +use core::marker::PhantomData; +use core::mem::MaybeUninit; +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::io; +use std::os::windows::io::{BorrowedHandle, OwnedHandle}; +use std::sync::Arc; +use std::time::Duration; + +use crate::CacheAligned; +use crate::blocking::{self, Parked}; +use crate::capacity::validate_capacity; +use crate::doorbell::Doorbell; +use crate::error::{CapacityError, PushError, RecvError, RecvTimeoutError}; + +/// The smallest capacity this shape can represent. +/// +/// **Two, and it is a property of the sequence protocol rather than a taste.** +/// A slot's sequence has to distinguish three states, and it does so by +/// counting: `pos` means free, `pos + 1` means published, and the consumer +/// frees it again by storing `pos + capacity`, the position the next lap will +/// claim it at. With one slot, `capacity == 1`, so "published at `pos`" and +/// "free at `pos + 1`" are the *same number* -- a producer would read the +/// sequence of the item it just pushed, conclude the slot was free, and +/// overwrite an item the consumer had not read. +/// +/// It is reported rather than worked around. The obvious workaround -- +/// allocating two slots and refusing the second -- would put a load of the +/// consumer's position back on the producer's hot path, which is exactly the +/// cost this protocol exists to avoid, and it would do so for every queue in +/// order to serve a capacity of one. A caller that genuinely wants a one-item +/// handoff wants [`spsc`](crate::spsc), which represents it exactly. +const MIN_CAPACITY: usize = 2; + +/// Creates a multi-producer, single-consumer bounded array queue. +/// +/// One producer handle is returned; further producers are made by cloning it, +/// and the queue is disconnected when the last of them is dropped. +/// +/// `capacity` must be a power of two of at least two, and is the exact number +/// of items the queue holds -- not a hint, and not rounded. See +/// [`CapacityError`] for why a rejection is preferred to rounding, and +/// [`MIN_CAPACITY`] for why one slot is not enough for this shape when it is +/// enough for [`spsc`](crate::spsc). +/// +/// # Errors +/// +/// Returns [`CapacityError`] if `capacity` is zero, is not a power of two, is +/// less than two, or exceeds [`usize::MAX`] / 2. +/// +/// # Examples +/// +/// ``` +/// use windows_waitable_queues::mpsc; +/// +/// let (tx, rx) = mpsc::bounded::(4)?; +/// let second = tx.clone(); +/// +/// tx.push(1).expect("a fresh queue has room"); +/// second.push(2).expect("a fresh queue has room"); +/// +/// assert_eq!(rx.pop(), Some(1)); +/// assert_eq!(rx.pop(), Some(2)); +/// assert_eq!(rx.pop(), None); +/// # Ok::<(), windows_waitable_queues::CapacityError>(()) +/// ``` +pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { + validate_capacity(capacity, MIN_CAPACITY)?; + + let mut slots = Vec::with_capacity(capacity); + for index in 0..capacity { + slots.push(Slot { + sequence: AtomicUsize::new(index), + value: UnsafeCell::new(MaybeUninit::uninit()), + }); + } + + let shared = Arc::new(Shared { + slots: slots.into_boxed_slice(), + mask: capacity - 1, + capacity, + head: CacheAligned(AtomicUsize::new(0)), + tail: CacheAligned(AtomicUsize::new(0)), + producers: AtomicUsize::new(1), + consumer_live: AtomicBool::new(true), + doorbell: Doorbell::new(), + }); + + Ok(( + Producer { + shared: Arc::clone(&shared), + not_sync: PhantomData, + }, + Consumer { + shared, + not_sync: PhantomData, + }, + )) +} + +/// One cell of the ring: an item, and a sequence number that says what state +/// the cell is in. +struct Slot { + /// The state machine described in the [module documentation](self). + /// + /// Deliberately *inside* the slot rather than gathered into a separate + /// array. The producer that publishes a slot and the consumer that takes it + /// touch the sequence and the item together, so keeping them adjacent costs + /// one cache line instead of two. Slots do share lines with their + /// neighbours, and that is intended: the contention this shape must avoid + /// is on the two *positions*, which are padded apart below, not on the + /// slots, which different producers touch at different indices anyway. + sequence: AtomicUsize, + value: UnsafeCell>, +} + +struct Shared { + slots: Box<[Slot]>, + mask: usize, + capacity: usize, + /// Where the consumer will next read. Written only by the consumer. + /// + /// Padded onto its own cache line by [`CacheAligned`], and the padding is + /// load-bearing rather than waste. Every successful push writes `tail` and + /// every successful pop writes `head`. Adjacent, they would share a line, + /// and each write would invalidate the other side's copy of a value it only + /// ever reads -- false sharing, which turns an uncontended queue into a + /// contended one while every individual load and store stays correct. That + /// is a cost with no symptom other than being slow, which is exactly the + /// kind that survives a code review. + head: CacheAligned, + /// The claim counter. Advanced by a compare-and-swap, by any producer. + /// + /// Padded for the reason given on [`Shared::head`], and it matters more + /// here than it does in `spsc`: this line is already the contended one, and + /// letting the consumer's writes land on it too would add the consumer to + /// the set of threads fighting over it. + tail: CacheAligned, + /// How many producer handles are alive. + /// + /// Reaching zero is the disconnection, and it is a count rather than a flag + /// because producers multiply by cloning. Not padded: it changes only when + /// a handle is created or destroyed, which is not a hot path. + producers: AtomicUsize, + consumer_live: AtomicBool, + /// Readiness as a waitable `HANDLE`. Costs nothing until somebody asks for + /// the handle, so a polling consumer never allocates a kernel object. + doorbell: Doorbell, +} + +// SAFETY: a slot is written by exactly one producer -- the one whose +// compare-and-swap claimed that position -- and read by exactly one consumer, +// which reads it only after observing the release store of `pos + 1` that +// publishes it. The write of the item therefore happens-before the read, and no +// two threads ever touch the same slot's contents at the same time. `T: Send` +// is required and sufficient because an item is moved between threads and never +// referenced from both. +unsafe impl Sync for Shared {} +// SAFETY: as above; sending the shared state is sending the items it holds. +unsafe impl Send for Shared {} + +impl Shared { + /// Items currently held, as a snapshot. + /// + /// **Counts slots a producer has claimed but not yet finished writing.** + /// The alternative -- counting only published items -- would need a walk of + /// the ring, and this number exists for metrics rather than for control + /// flow. It never under-reports, so it is safe in the direction that + /// matters for a backpressure gauge, and it is never used to decide whether + /// to wait: [`Consumer::arm`] asks [`Shared::has_ready_item`] instead, + /// which is the exact question `pop` answers. + fn len(&self) -> usize { + let tail = self.tail.0.load(Ordering::Acquire); + let head = self.head.0.load(Ordering::Acquire); + tail.wrapping_sub(head) + } + + /// Whether the consumer would find an item right now. + /// + /// The emptiness half of the arming protocol, and it asks precisely what + /// [`Consumer::pop`] asks: is the slot at the head position published? A + /// claimed-but-unpublished slot answers `false`, which is the right answer + /// -- the consumer may safely park on it, because the producer's publishing + /// release store is followed by a signal that will wake it. Using + /// [`Shared::len`] here instead would answer `true` and send the consumer + /// round a spin loop until that producer got scheduled again. + /// + /// The `Acquire` load is one half of the pair described on + /// [`Doorbell::signal`](crate::doorbell::Doorbell::signal): the producer + /// stores this sequence and then loads the doorbell state, while the + /// consumer stores the doorbell state and then loads this sequence. The + /// sequentially consistent fences on both sides are what stop both loads + /// from returning stale values. + fn has_ready_item(&self) -> bool { + let position = self.head.0.load(Ordering::Relaxed); + let slot = &self.slots[position & self.mask]; + slot.sequence.load(Ordering::Acquire) == position.wrapping_add(1) + } +} + +impl Drop for Shared { + fn drop(&mut self) { + // Every handle is gone, so no synchronization is needed and the + // positions can be read directly. A slot between the two positions + // still holds an item nobody took, and dropping the queue must drop + // those rather than leak them. + // + // The sequence is consulted per slot rather than assuming every + // position in the range holds an item. A producer cannot be mid-push + // here -- it would have to hold a handle, and there are none -- so in + // practice every one of them does; the check states the invariant the + // read depends on instead of leaving it to that argument. + let mask = self.mask; + let head = *self.head.0.get_mut(); + let tail = *self.tail.0.get_mut(); + let mut position = head; + while position != tail { + let published = position.wrapping_add(1); + let slot = &mut self.slots[position & mask]; + if *slot.sequence.get_mut() == published { + // SAFETY: the slot's sequence says the producer finished + // writing it and the consumer never took it, so it holds an + // initialized item. It is dropped exactly once, because + // `position` advances every iteration. + unsafe { + slot.value.get_mut().assume_init_drop(); + } + } + position = position.wrapping_add(1); + } + } +} + +/// A writing half of an [`mpsc`](self) queue. +/// +/// [`Clone`], and that is the only difference from `spsc`'s producer: cloning +/// is how a second producer comes into existence, and the queue is disconnected +/// when the last clone is dropped. +/// +/// Not [`Sync`], so a handle is used by one thread at a time. Give each thread +/// its own clone rather than sharing one behind a reference. +pub struct Producer { + shared: Arc>, + /// Removes [`Sync`] without removing [`Send`]. A [`Cell`] is exactly that + /// shape, and no value of it is ever created. + not_sync: PhantomData>, +} + +impl Producer { + /// Appends an item. + /// + /// # Errors + /// + /// [`PushError::Full`] when the queue is at capacity, which is the + /// backpressure signal rather than a malfunction, and + /// [`PushError::Disconnected`] when the consumer is gone. Either way the + /// item comes back, so nothing is lost by the refusal. + pub fn push(&self, item: T) -> Result<(), PushError> { + // Relaxed: this load only proposes a position. The compare-and-swap + // below is what makes the claim, and it fails if the proposal was + // stale, so a stale read costs a retry rather than correctness. + let mut position = self.shared.tail.0.load(Ordering::Relaxed); + loop { + let slot = &self.shared.slots[position & self.shared.mask]; + // Acquire: pairs with the consumer's release store when it frees a + // slot, so a slot it has finished with is visible as free here. + let sequence = slot.sequence.load(Ordering::Acquire); + // Signed, which is why the capacity is capped at half the range: + // both positions wrap, and only a difference smaller than half the + // range can be told apart from its complement. + let difference = sequence.wrapping_sub(position) as isize; + + if difference < 0 { + // The slot has not come round: the queue is full here, and + // because positions are claimed in order it is full outright. + // + // Report disconnection in preference to fullness: a full queue + // whose consumer is gone will never drain, and telling the + // caller to retry would be telling it to spin forever. + if !self.shared.consumer_live.load(Ordering::Acquire) { + return Err(PushError::Disconnected(item)); + } + return Err(PushError::Full(item)); + } + if difference > 0 { + // Another producer claimed this position between the load of + // the tail and now. Re-read rather than incrementing blindly: + // several producers may have got in. + position = self.shared.tail.0.load(Ordering::Relaxed); + continue; + } + if !self.shared.consumer_live.load(Ordering::Acquire) { + return Err(PushError::Disconnected(item)); + } + + // Relaxed on both sides is sufficient: this exchange orders nothing + // but the claim itself. The item's visibility comes from the + // release store that publishes the slot below, and the freedom to + // write the slot comes from the acquire load above. + match self.shared.tail.0.compare_exchange_weak( + position, + position.wrapping_add(1), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(actual) => position = actual, + } + } + + let slot = &self.shared.slots[position & self.shared.mask]; + // SAFETY: this thread's compare-and-swap claimed `position`, and a + // position is claimed by exactly one producer. The consumer will not + // read the slot until the release store below publishes it, and the + // slot's sequence said it was free, so no initialized item is + // overwritten. + unsafe { + (*slot.value.get()).write(item); + } + + // Release, and this is the publication: it must come after the write, + // and this is what forbids the compiler and the processor from moving + // it earlier. Until it lands, the consumer sees the slot as + // claimed-but-empty and skips it. + slot.sequence + .store(position.wrapping_add(1), Ordering::Release); + + // After the publication, never before: the doorbell says "there is + // something to take", and that must not become true before the item is + // actually takeable. A consumer woken early would find nothing, clear + // the doorbell, and go back to sleep on an item that is about to exist + // -- a lost wakeup manufactured by signalling too eagerly. + // + // Note that a producer may signal while an *earlier* position is still + // unpublished, so the consumer wakes and finds nothing. That is a + // spurious wakeup, which the protocol tolerates by construction: the + // producer holding the earlier slot signals in its turn. + self.shared.doorbell.signal(); + Ok(()) + } + + /// The exact number of items this queue holds when full. + #[must_use] + pub fn capacity(&self) -> usize { + self.shared.capacity + } + + /// Items currently held, as a snapshot. + /// + /// Includes slots claimed by a producer that has not finished writing, so + /// it never under-reports. See [`Shared::len`]. + #[must_use] + pub fn len(&self) -> usize { + self.shared.len() + } + + /// Whether the queue holds nothing, as a snapshot. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Whether the next push would be refused for want of room, as a snapshot. + /// + /// Advisory only, and more advisory here than in `spsc`: another producer + /// may take the last slot between this call and the push. Nothing is gained + /// by testing it beforehand, since [`Self::push`] reports the same + /// condition without the window; it is offered for metrics. + #[must_use] + pub fn is_full(&self) -> bool { + self.len() >= self.shared.capacity + } + + /// Whether the consumer has been dropped. + #[must_use] + pub fn is_disconnected(&self) -> bool { + !self.shared.consumer_live.load(Ordering::Acquire) + } +} + +impl Clone for Producer { + fn clone(&self) -> Self { + // Relaxed: the thread doing the cloning already holds a live handle, so + // the count cannot reach zero during this call and no other thread's + // decision depends on when this increment becomes visible. The + // `Release`/`Acquire` pairing that matters is in `Drop`, where the + // count reaching zero publishes everything every producer pushed. + self.shared.producers.fetch_add(1, Ordering::Relaxed); + Self { + shared: Arc::clone(&self.shared), + not_sync: PhantomData, + } + } +} + +// Hand-written rather than derived: deriving would demand `T: Debug`, which +// would make a handle to a queue of non-`Debug` items un-printable for no +// reason. The item type is not the handle's business, so the handle reports the +// queue's state instead. +impl fmt::Debug for Producer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("mpsc::Producer") + .field("capacity", &self.capacity()) + .field("len", &self.len()) + .field("producers", &self.shared.producers.load(Ordering::Relaxed)) + .field("disconnected", &self.is_disconnected()) + .finish() + } +} + +impl Drop for Producer { + fn drop(&mut self) { + // `AcqRel` carries both halves of what this decrement has to do. The + // release half publishes everything this producer pushed to whichever + // thread observes the count reaching zero, so a consumer that sees the + // disconnection can trust that draining to empty really has drained + // everything. The acquire half makes *this* thread -- when it is the + // one that drives the count to zero -- see the other producers' + // pushes, which is what makes the signal below meaningful. + if self.shared.producers.fetch_sub(1, Ordering::AcqRel) != 1 { + return; + } + + // Disconnection is a wakeup like any other, and the only one nobody + // else can deliver. A consumer blocked on the doorbell would otherwise + // wait forever for an item that can no longer be sent -- the queue + // would be correct and the program would still hang. + // + // Only the *last* producer rings: an earlier one leaving changes + // nothing a consumer could act on, and waking it to discover that would + // be a spurious wakeup per departing thread. + self.shared.doorbell.signal(); + } +} + +/// The reading half of an [`mpsc`](self) queue. +/// +/// Neither [`Clone`] nor [`Sync`], which is what makes "single consumer" a fact +/// the compiler checks rather than a rule to remember. +pub struct Consumer { + shared: Arc>, + /// See [`Producer::not_sync`]. + not_sync: PhantomData>, +} + +impl Consumer { + /// Takes the oldest item, or `None` if there is none right now. + /// + /// `None` does not mean the queue is finished, and here it does not even + /// mean the queue is empty: a producer may have claimed the next position + /// and not yet published it, in which case the items behind it are not + /// takeable either. Order is claim order, so waiting is the only correct + /// answer -- and the producer signals the doorbell when it publishes, so + /// waiting is not a gamble. + /// + /// Pair it with [`Self::is_disconnected`] to distinguish "empty for now" + /// from "empty for good"; the order matters, and [`Self::is_disconnected`] + /// documents which way round. + pub fn pop(&self) -> Option { + // Relaxed: this thread is the only writer of `head`. + let position = self.shared.head.0.load(Ordering::Relaxed); + let slot = &self.shared.slots[position & self.shared.mask]; + // Acquire: pairs with the producer's release store, so an item it + // published is visible here. + let sequence = slot.sequence.load(Ordering::Acquire); + + // Anything other than "published at this position" means there is + // nothing to take: a lower sequence is a slot from the previous lap + // that nobody has claimed yet, and a claimed-but-unpublished slot + // carries the previous lap's sequence too. + if sequence != position.wrapping_add(1) { + return None; + } + + // SAFETY: the sequence says the producer that claimed this position + // finished writing it, and the release/acquire pair above makes that + // write visible here. This is the only consumer, and the slot is freed + // below, so the item is read exactly once. + let item = unsafe { (*slot.value.get()).assume_init_read() }; + + // The head moves before the slot is freed, and not after. A producer + // that sees the freed slot may push immediately; if it did so while + // `head` still named the old position, `len` would briefly report more + // items than the queue can hold. Both stores are `Release`, so neither + // may be reordered before the read of the item above, and the first may + // not be reordered after the second. + self.shared + .head + .0 + .store(position.wrapping_add(1), Ordering::Release); + + // Freeing the slot is a store of the position the *next* lap will claim + // it at, which is one whole capacity further on. Release, because it + // must not become visible before the item has been read out: a producer + // that saw it early would overwrite an item this thread had not + // finished taking. + slot.sequence.store( + position.wrapping_add(self.shared.capacity), + Ordering::Release, + ); + Some(item) + } + + /// The exact number of items this queue holds when full. + #[must_use] + pub fn capacity(&self) -> usize { + self.shared.capacity + } + + /// Items currently held, as a snapshot. + /// + /// Includes slots claimed by a producer that has not finished writing, so + /// it never under-reports. See [`Shared::len`]. + #[must_use] + pub fn len(&self) -> usize { + self.shared.len() + } + + /// Whether the queue holds nothing, as a snapshot. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Whether every producer has been dropped. + /// + /// **Check this only after [`Self::pop`] has returned `None`.** A producer + /// may push and then drop, so a queue can be disconnected and still hold + /// items; testing this first would discard them. Draining to empty and then + /// finding the producers gone is the only order that cannot lose an item, + /// and the release in the last producer's `Drop` is what makes every + /// producer's preceding pushes visible to a consumer that observes it. + #[must_use] + pub fn is_disconnected(&self) -> bool { + self.shared.producers.load(Ordering::Acquire) == 0 + } + + /// Borrows the queue's readiness as a waitable `HANDLE`. + /// + /// This is the point of the crate. The handle is a manual-reset event that + /// is signalled while the queue has something to take, so it can go into + /// `WaitForMultipleObjects` beside an I/O completion, a shutdown event, or + /// a timer -- a wait that no queue with a private parking primitive can + /// join. + /// + /// The event is created on the first call, so a consumer that only ever + /// polls with [`Self::pop`] is charged for no kernel object. + /// + /// The borrow is deliberate: the event belongs to the queue and must not be + /// closed. Use [`Self::doorbell_owned`] where ownership is required. + /// + /// # Waiting on it correctly + /// + /// **Do not simply wait and then drain.** Use [`Self::arm`] to decide + /// whether waiting is safe, or the wait can miss an item and block forever: + /// + /// ```no_run + /// # use windows_waitable_queues::mpsc; + /// # use windows_sys::Win32::System::Threading::{WaitForSingleObject, INFINITE}; + /// # use std::os::windows::io::AsRawHandle; + /// # fn demo(rx: &mpsc::Consumer) -> std::io::Result<()> { + /// loop { + /// while let Some(item) = rx.pop() { + /// let _ = item; + /// } + /// if !rx.arm()? { + /// continue; // Something arrived; waiting now would be wrong. + /// } + /// let handle = rx.doorbell()?; + /// // SAFETY: a live event handle borrowed for the call. + /// unsafe { WaitForSingleObject(handle.as_raw_handle(), INFINITE) }; + /// } + /// # } + /// ``` + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` on the first call. + pub fn doorbell(&self) -> io::Result> { + self.shared.doorbell.handle() + } + + /// A duplicate of [`Self::doorbell`] that the caller owns. + /// + /// The duplicate names the same event, so signalling reaches both, and the + /// caller may close its copy whenever it likes. This is the form a + /// `ThreadpoolWait` needs, since arming one takes ownership of its target. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` or `DuplicateHandle`. + pub fn doorbell_owned(&self) -> io::Result { + self.shared.doorbell.owned() + } + + /// Clears the doorbell and reports whether it is safe to wait on it. + /// + /// `true` means the queue had nothing takeable after the doorbell was + /// cleared, so any later push is guaranteed to signal and a wait cannot be + /// missed. `false` means something arrived in the meantime: take it instead + /// of waiting. + /// + /// The order inside this method is the whole correctness argument, and it + /// is the reverse of the one that reads naturally. Clearing *first* and + /// checking *second* is what makes a lost wakeup impossible: an item that + /// arrives before the clear is found by the check, and an item that arrives + /// after the clear signals a doorbell that is no longer about to be reset. + /// Checking first would leave a window in which a push both signals and has + /// its signal erased, and the consumer would sleep on a queue that is not + /// empty and will never be signalled again. + /// + /// This also creates the doorbell if it does not exist, which must happen + /// before the check for the same reason: a producer running while there is + /// no event skips signalling, so the check has to come after the event + /// exists to catch what that skip left behind. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` on the first call. + pub fn arm(&self) -> io::Result { + // Before the clear, and so before the check: see above. + self.shared.doorbell.handle()?; + self.shared.doorbell.clear(); + #[cfg(test)] + crate::arm_race::run(); + // Deliberately not `is_empty`. The question is whether `pop` would find + // something, and a slot that a producer has claimed but not published + // is not something `pop` can find -- see `Shared::has_ready_item`. + Ok(!self.shared.has_ready_item()) + } + + /// The last take before reporting the end of the stream. + /// + /// Called only after [`Self::is_disconnected`] has returned `true`, which + /// makes the answer final rather than a snapshot: no producer remains to + /// add anything, so `None` here means empty forever. + /// + /// This exists as a named step, rather than as a bare `pop` inlined into + /// each caller, because it guards a race that is real and narrow: a + /// producer may push *and then* drop in the window between a receive's + /// first `pop` and its disconnection check. Reporting the disconnection + /// without this final take would silently discard an item that was + /// successfully sent. Being a separate function is what lets a test reach + /// it directly instead of hoping to schedule that window. + fn finish(&self) -> Option { + self.pop() + } + + /// Takes the oldest item, blocking until one arrives. + /// + /// Parks on the doorbell rather than spinning, so a consumer with nothing + /// to do costs nothing. + /// + /// # Errors + /// + /// [`RecvError::Disconnected`] once every producer is gone *and* the queue + /// is drained -- items pushed before the last producer dropped are still + /// delivered. [`RecvError::Io`] if the doorbell cannot be created or waited + /// on. + pub fn recv(&self) -> Result { + blocking::recv(self) + } + + /// Takes the oldest item, blocking until one arrives or the deadline + /// passes. + /// + /// The timeout bounds the whole call, not each individual wait: a consumer + /// woken spuriously does not get a fresh budget. + /// + /// # Errors + /// + /// [`RecvTimeoutError::Timeout`] if the deadline passes with the queue + /// still empty, which is not a malfunction. Otherwise as [`Self::recv`]. + pub fn recv_timeout(&self, timeout: Duration) -> Result { + blocking::recv_timeout(self, timeout) + } +} + +impl Parked for Consumer { + type Item = T; + + fn pop(&self) -> Option { + Self::pop(self) + } + + fn finish(&self) -> Option { + Self::finish(self) + } + + fn arm(&self) -> io::Result { + Self::arm(self) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } + + fn doorbell(&self) -> io::Result> { + Self::doorbell(self) + } +} + +/// See [`Producer`]'s impl for why this is hand-written. +impl fmt::Debug for Consumer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("mpsc::Consumer") + .field("capacity", &self.capacity()) + .field("len", &self.len()) + .field("producers", &self.shared.producers.load(Ordering::Relaxed)) + .field("disconnected", &self.is_disconnected()) + .finish() + } +} + +impl Drop for Consumer { + fn drop(&mut self) { + self.shared.consumer_live.store(false, Ordering::Release); + } +} + +impl crate::Producer for Producer { + type Item = T; + + fn push(&self, item: T) -> Result<(), PushError> { + Self::push(self, item) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } +} + +impl crate::Consumer for Consumer { + type Item = T; + + fn pop(&self) -> Option { + Self::pop(self) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } +} + +impl crate::Bounded for Producer { + fn capacity(&self) -> usize { + Self::capacity(self) + } + + fn len(&self) -> usize { + Self::len(self) + } + + fn is_empty(&self) -> bool { + Self::is_empty(self) + } +} + +impl crate::Bounded for Consumer { + fn capacity(&self) -> usize { + Self::capacity(self) + } + + fn len(&self) -> usize { + Self::len(self) + } + + fn is_empty(&self) -> bool { + Self::is_empty(self) + } +} + +impl crate::Waitable for Consumer { + fn doorbell(&self) -> io::Result> { + Self::doorbell(self) + } + + fn doorbell_owned(&self) -> io::Result { + Self::doorbell_owned(self) + } + + fn arm(&self) -> io::Result { + Self::arm(self) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/mpsc/tests.rs b/crates/windows-waitable-queues/src/mpsc/tests.rs new file mode 100644 index 00000000..742ceb9a --- /dev/null +++ b/crates/windows-waitable-queues/src/mpsc/tests.rs @@ -0,0 +1,977 @@ +// Copyright (c) Mike Grier. + +//! Tests for the MPSC bounded array queue. +//! +//! Every one runs in memory, and the whole file finishes in well under a +//! second. The multi-producer cases join every thread before asserting, so the +//! assertion runs after the peers have finished rather than after a guess about +//! how long they take. +//! +//! **They assert what the shape actually guarantees, and not more.** A +//! multi-producer queue promises that every item arrives exactly once and that +//! one producer's items keep that producer's order. It does *not* promise a +//! global interleaving, and a test that pinned one down would be asserting the +//! scheduler rather than the queue -- green today, red on a different machine, +//! and evidence of nothing either way. + +use super::{Consumer, MIN_CAPACITY, Producer, bounded, validate_capacity}; +use crate::arm_race; +use crate::{PushError, RecvError, RecvTimeoutError}; +use std::collections::BTreeMap; +use std::os::windows::io::AsRawHandle; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::System::Threading::WaitForSingleObject; + +/// Counts its own drops, so a test can prove an item was destroyed rather than +/// leaked. `Arc` rather than a `static`, so tests that run +/// concurrently in one process cannot see each other's counts. +#[derive(Debug)] +struct DropCounter(Arc); + +impl Drop for DropCounter { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +/// Pushes with a spin on a full queue. +/// +/// Spinning rather than sleeping is right here: the consumer is draining +/// concurrently, so a full queue clears in nanoseconds, and a sleep would turn +/// a microsecond test into a millisecond one. +fn push_spinning(producer: &Producer, mut item: T) { + loop { + match producer.push(item) { + Ok(()) => return, + Err(PushError::Full(returned)) => { + item = returned; + std::hint::spin_loop(); + } + Err(PushError::Disconnected(_)) => panic!("the consumer is alive"), + } + } +} + +#[test] +fn a_pushed_item_comes_back_out() { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + tx.push(42).expect("a fresh queue has room"); + assert_eq!(rx.pop(), Some(42)); +} + +#[test] +fn an_empty_queue_pops_nothing() { + let (_tx, rx) = bounded::(4).expect("a power-of-two capacity"); + assert_eq!(rx.pop(), None); + assert!(rx.is_empty()); + assert_eq!(rx.len(), 0); +} + +#[test] +fn items_come_out_in_the_order_they_went_in() { + let (tx, rx) = bounded::(8).expect("a power-of-two capacity"); + for value in 0..8 { + tx.push(value).expect("room for eight"); + } + let drained: Vec = std::iter::from_fn(|| rx.pop()).collect(); + assert_eq!(drained, (0..8).collect::>()); +} + +#[test] +fn a_full_queue_refuses_and_hands_the_item_back() { + let (tx, rx) = bounded::(2).expect("a power-of-two capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + assert!(tx.is_full()); + + match tx.push(3) { + Err(PushError::Full(returned)) => assert_eq!( + returned, 3, + "the refused item must come back, or a caller cannot retry it" + ), + other => panic!("expected Full, got {other:?}"), + } + + // And the refusal did not disturb what was already there. + assert_eq!(rx.pop(), Some(1)); + assert_eq!(rx.pop(), Some(2)); +} + +#[test] +fn the_smallest_capacity_holds_exactly_two() { + // Two is this shape's floor rather than one, so the two-slot ring is the + // edge case that `spsc`'s one-slot ring is: every push after the first two + // is a refusal, and every pop frees exactly one slot. + let (tx, rx) = bounded::(MIN_CAPACITY).expect("the shape's own minimum must be accepted"); + tx.push(1).expect("room for two"); + tx.push(2).expect("room for two"); + assert!(matches!(tx.push(3), Err(PushError::Full(3)))); + + assert_eq!(rx.pop(), Some(1)); + tx.push(3).expect("the slot was freed"); + assert_eq!(rx.pop(), Some(2)); + assert_eq!(rx.pop(), Some(3)); + assert_eq!(rx.pop(), None); +} + +#[test] +fn the_ring_wraps_many_times_without_losing_order() { + // Far more operations than slots, so every slot is reused repeatedly. This + // is the test that indicts the sequence arithmetic: a slot freed with the + // wrong number is either claimed a lap early -- overwriting a live item -- + // or never claimed again, and both show up here as a wrong value or a + // refusal rather than as a crash. + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + for round in 0..1000 { + tx.push(round).expect("the previous item was taken"); + assert_eq!(rx.pop(), Some(round)); + } + assert!(rx.is_empty()); +} + +#[test] +fn a_partly_full_ring_wraps_correctly() { + // Keeps two items resident while cycling, so the head and the tail are + // never equal and never a whole lap apart -- the case a simple "empty when + // equal" test never reaches. + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + tx.push(0).expect("room"); + tx.push(1).expect("room"); + for round in 2..500 { + tx.push(round) + .expect("room, because one is taken each round"); + assert_eq!(rx.pop(), Some(round - 2)); + assert_eq!(rx.len(), 2); + } +} + +#[test] +fn len_tracks_pushes_and_pops() { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + assert_eq!(tx.len(), 0); + tx.push(1).expect("room"); + assert_eq!(tx.len(), 1); + assert_eq!(rx.len(), 1, "both handles report the same queue"); + tx.push(2).expect("room"); + assert_eq!(tx.len(), 2); + rx.pop().expect("an item"); + assert_eq!(rx.len(), 1); + rx.pop().expect("an item"); + assert!(rx.is_empty()); +} + +#[test] +fn zero_sized_items_round_trip() { + // A ZST exercises the slot arithmetic with no bytes to copy, so a mistake + // cannot hide behind a memcpy that happens to do the right thing. + let (tx, rx) = bounded::<()>(2).expect("a power-of-two capacity"); + tx.push(()).expect("room"); + tx.push(()).expect("room"); + assert!(matches!(tx.push(()), Err(PushError::Full(())))); + assert_eq!(rx.pop(), Some(())); + assert_eq!(rx.pop(), Some(())); + assert_eq!(rx.pop(), None); +} + +#[test] +fn dropping_the_queue_drops_the_items_it_still_holds() { + let drops = Arc::new(AtomicUsize::new(0)); + { + let (tx, _rx) = bounded::(8).expect("a power-of-two capacity"); + for _ in 0..5 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + } + assert_eq!(drops.load(Ordering::Relaxed), 0, "nothing dropped yet"); + } + assert_eq!( + drops.load(Ordering::Relaxed), + 5, + "every undrained item must be dropped, not leaked" + ); +} + +#[test] +fn dropping_the_queue_after_a_wrap_drops_only_what_is_resident() { + // The interesting case for the drop loop: both positions are far from zero + // and the live range straddles the end of the slot array, so a drop that + // iterated `0..len` instead of `head..tail` would destroy the wrong slots + // -- and would drop uninitialized memory. + let drops = Arc::new(AtomicUsize::new(0)); + { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + for _ in 0..6 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + rx.pop().expect("an item"); + } + assert_eq!( + drops.load(Ordering::Relaxed), + 6, + "the six taken were dropped" + ); + + // Now leave three resident, starting from a wrapped position. + for _ in 0..3 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + } + } + assert_eq!( + drops.load(Ordering::Relaxed), + 9, + "the three still resident must also be dropped" + ); +} + +// --------------------------------------------------------------------------- +// Capacity: the rule, and this shape's own floor. +// --------------------------------------------------------------------------- + +#[test] +fn a_zero_capacity_is_refused_because_it_could_never_accept_anything() { + let error = bounded::(0).expect_err("zero is not a usable capacity"); + assert_eq!(error.requested(), 0); + assert_eq!( + error.next_valid(), + Some(MIN_CAPACITY), + "the suggestion must be this shape's own floor, not a crate-wide one" + ); +} + +#[test] +fn a_capacity_of_one_is_refused_because_the_sequence_protocol_cannot_encode_it() { + // Not a taste, and not a copy of `spsc`'s rule: with one slot, "published + // at position p" and "free again at position p + capacity" are the same + // number, so a producer would read the sequence of the item it had just + // pushed, conclude the slot was free, and overwrite an unread item. + // + // `spsc` accepts one, which is why the minimum belongs to the shape rather + // than to the crate. Asserted here so that shipping a one-slot MPSC would + // be a deliberate change to this test rather than a silent regression. + let error = bounded::(1).expect_err("one slot cannot carry three states"); + assert_eq!(error.requested(), 1); + assert_eq!(error.min_valid(), 2); + assert_eq!( + error.next_valid(), + Some(2), + "the correction must be offered, since one is an entirely reasonable ask" + ); + assert_eq!( + error.previous_valid(), + None, + "and there is nothing valid below it to suggest" + ); +} + +#[test] +fn a_non_power_of_two_capacity_is_refused_with_both_neighbours() { + let error = bounded::(100).expect_err("100 is not a power of two"); + assert_eq!(error.requested(), 100); + assert_eq!( + (error.previous_valid(), error.next_valid()), + (Some(64), Some(128)), + "the error should make the correction obvious without arithmetic" + ); +} + +#[test] +fn every_power_of_two_capacity_from_the_floor_up_is_accepted() { + for shift in 1..16 { + let capacity = 1_usize << shift; + let (tx, rx) = bounded::(capacity).expect("a power of two at or above the floor"); + assert_eq!(tx.capacity(), capacity); + assert_eq!(rx.capacity(), capacity, "both handles agree"); + tx.push(shift).expect("a fresh queue has room"); + assert_eq!(rx.pop(), Some(shift)); + } +} + +#[test] +fn a_capacity_above_half_the_address_space_is_refused() { + // Not because the allocation would fail first, but because the position + // arithmetic would become ambiguous across wraparound. Checked explicitly + // so the reason survives even though no machine could allocate it. + let error = bounded::(1_usize << (usize::BITS - 1)).expect_err("too large"); + assert!( + error.next_valid().is_none(), + "there is nothing larger to suggest" + ); +} + +#[test] +fn a_suggested_capacity_is_one_the_constructor_would_accept() { + // The suggestion exists so a caller can correct the call. One that is + // itself refused is worse than none, because the caller acts on it. + // + // Asks `validate_capacity` rather than `bounded`, and rather than + // re-listing the rules here. Calling `bounded` would be a truer test of the + // real path, but a suggestion near the bound is 2^62, and constructing that + // queue means asking for half the address space. + for requested in [0_usize, 1, 3, 100, 1000, usize::MAX / 2, usize::MAX] { + let Err(error) = validate_capacity(requested, MIN_CAPACITY) else { + continue; + }; + if let Some(previous) = error.previous_valid() { + assert!( + validate_capacity(previous, MIN_CAPACITY).is_ok(), + "previous_valid() for {requested} suggested {previous}, which is itself rejected" + ); + } + if let Some(next) = error.next_valid() { + assert!( + validate_capacity(next, MIN_CAPACITY).is_ok(), + "next_valid() for {requested} suggested {next}, which is itself rejected" + ); + } + } +} + +// --------------------------------------------------------------------------- +// Disconnection, in both directions. +// --------------------------------------------------------------------------- + +#[test] +fn a_consumer_that_is_gone_turns_a_push_into_a_disconnect() { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + assert!(!tx.is_disconnected()); + drop(rx); + assert!(tx.is_disconnected()); + + match tx.push(1) { + Err(PushError::Disconnected(returned)) => assert_eq!(returned, 1), + other => panic!("expected Disconnected, got {other:?}"), + } +} + +#[test] +fn a_full_queue_whose_consumer_is_gone_reports_disconnected_not_full() { + // The distinction is the whole point of having two variants: Full invites a + // retry, and retrying this one would spin for ever. + let (tx, rx) = bounded::(2).expect("a power-of-two capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + drop(rx); + + match tx.push(3) { + Err(PushError::Disconnected(_)) => {} + Err(PushError::Full(_)) => { + panic!("a full queue with no consumer will never drain, so Full would invite a spin") + } + Ok(()) => panic!("the queue was full"), + } +} + +#[test] +fn the_queue_is_disconnected_only_when_the_last_producer_goes() { + // The one place where multi-producer disconnection is genuinely different + // from single-producer disconnection, and where a flag rather than a count + // would be wrong: the first producer to leave must not end the stream for + // the others. + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + let second = tx.clone(); + let third = second.clone(); + assert!(!rx.is_disconnected()); + + drop(tx); + assert!(!rx.is_disconnected(), "two producers remain"); + drop(second); + assert!(!rx.is_disconnected(), "one producer remains"); + drop(third); + assert!( + rx.is_disconnected(), + "and only now is the stream genuinely over" + ); +} + +#[test] +fn a_producer_that_is_gone_leaves_the_queued_items_takeable() { + // Disconnection must not discard what was already pushed, which is why the + // documented order is drain first and check afterwards. The clone matters: + // the items were pushed through a handle that no longer exists by the time + // the consumer looks. + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + let second = tx.clone(); + tx.push(1).expect("room"); + second.push(2).expect("room"); + drop(tx); + drop(second); + + assert!(rx.is_disconnected()); + assert_eq!(rx.pop(), Some(1), "a dropped producer does not discard"); + assert_eq!(rx.pop(), Some(2)); + assert_eq!(rx.pop(), None); +} + +#[test] +fn the_final_drain_returns_an_item_that_raced_the_disconnection() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + + // The race `Consumer::finish` guards, reconstructed rather than waited for: + // a producer pushed and then dropped in the window between a receive's + // first `pop` and its disconnection check. At this point the queue reports + // disconnected *and* holds an item. + tx.push(1).expect("there is room"); + drop(tx); + assert!(rx.is_disconnected(), "the last producer is gone"); + + assert_eq!( + rx.finish(), + Some(1), + "the end of the stream must not discard an item that was sent before it" + ); + assert_eq!( + rx.finish(), + None, + "and once genuinely drained, the answer is final" + ); +} + +#[test] +fn the_final_drain_is_empty_when_nothing_was_sent() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + drop(tx); + assert_eq!( + rx.finish(), + None, + "nothing was ever sent, so nothing is owed" + ); +} + +// --------------------------------------------------------------------------- +// Many producers, which is what this shape exists for. +// --------------------------------------------------------------------------- + +/// How many producer threads the concurrent tests use. +/// +/// Fixed rather than derived from the machine's core count, so a failure +/// reproduces on the machine that reported it. Four is enough to make the +/// compare-and-swap on the tail genuinely contended even on a two-core box, +/// because more threads than cores is exactly the case that interleaves a +/// producer between its claim and its publish. +const PRODUCERS: usize = 4; + +/// How many items each producer sends in the concurrent tests. +const PER_PRODUCER: usize = 500; + +/// Runs `PRODUCERS` threads against one queue and returns everything the +/// consumer saw, in arrival order, as `(producer, sequence)` pairs. +fn run_producers(capacity: usize) -> Vec<(usize, usize)> { + let (tx, rx) = bounded::<(usize, usize)>(capacity).expect("a valid capacity"); + + let threads: Vec<_> = (0..PRODUCERS) + .map(|producer| { + let handle = tx.clone(); + thread::spawn(move || { + for sequence in 0..PER_PRODUCER { + push_spinning(&handle, (producer, sequence)); + } + }) + }) + .collect(); + // The original handle would otherwise keep the queue connected for ever. + drop(tx); + + let mut received = Vec::with_capacity(PRODUCERS * PER_PRODUCER); + // Drains concurrently rather than after the join, which is the point: with + // a capacity far below the run length the producers block on a full queue + // and the consumer on an empty one, repeatedly and in both directions. + while let Ok(item) = rx.recv() { + received.push(item); + } + for thread in threads { + thread.join().expect("no producer may panic"); + } + received +} + +#[test] +fn a_clone_pushes_into_the_same_queue() { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + let second = tx.clone(); + tx.push(1).expect("room"); + second.push(2).expect("room"); + + assert_eq!(rx.len(), 2, "one queue, not two"); + assert_eq!(rx.pop(), Some(1)); + assert_eq!(rx.pop(), Some(2)); +} + +#[test] +fn many_producers_deliver_every_item_exactly_once() { + let received = run_producers(64); + + assert_eq!( + received.len(), + PRODUCERS * PER_PRODUCER, + "no item may be lost, and none may be delivered twice" + ); + + let mut seen: BTreeMap> = BTreeMap::new(); + for (producer, sequence) in received { + seen.entry(producer).or_default().push(sequence); + } + assert_eq!(seen.len(), PRODUCERS, "every producer must be represented"); + for (producer, sequences) in seen { + assert_eq!( + sequences, + (0..PER_PRODUCER).collect::>(), + "producer {producer} must have every one of its items, exactly once, in its own order" + ); + } +} + +#[test] +fn many_producers_against_the_smallest_queue_still_deliver_everything() { + // The same run through a two-slot ring, so nearly every push is refused at + // least once and the tail's compare-and-swap is contended continuously. + // This is where a mis-ordered claim or a slot freed at the wrong sequence + // stops being theoretical. + let received = run_producers(MIN_CAPACITY); + + assert_eq!(received.len(), PRODUCERS * PER_PRODUCER); + let mut per_producer = [0_usize; PRODUCERS]; + for (producer, sequence) in received { + assert_eq!( + sequence, per_producer[producer], + "a producer's own items must arrive in that producer's order" + ); + per_producer[producer] += 1; + } + assert!(per_producer.iter().all(|count| *count == PER_PRODUCER)); +} + +#[test] +fn a_producer_can_be_moved_to_another_thread_and_cloned() { + // `Send` is what makes the split useful, and `Clone` is what makes this + // shape multi-producer. `!Sync` is asserted by the absence of any test that + // shares one handle across threads: the compiler refuses to write it. + fn assert_send() {} + fn assert_clone() {} + assert_send::>(); + assert_send::>(); + assert_clone::>(); + + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + let second = tx.clone(); + thread::spawn(move || { + second.push(7).expect("room"); + }) + .join() + .expect("the pushing thread"); + tx.push(8).expect("room"); + + assert_eq!(rx.pop(), Some(7)); + assert_eq!(rx.pop(), Some(8)); +} + +#[test] +fn items_cross_a_thread_boundary_intact() { + // The real test of the memory ordering. Boxed, so each item is a heap + // pointer the consumer must observe fully initialized -- a missing release + // on the publishing store would surface as a corrupt pointer rather than as + // a wrong integer. + const COUNT: usize = 20_000; + let (tx, rx) = bounded::>(64).expect("a power-of-two capacity"); + + let producer = thread::spawn(move || { + for value in 0..COUNT { + push_spinning(&tx, Box::new(value)); + } + }); + + let mut received = 0_usize; + while received < COUNT { + if let Some(item) = rx.pop() { + assert_eq!(*item, received, "items must arrive in order and intact"); + received += 1; + } else { + std::hint::spin_loop(); + } + } + + producer.join().expect("the producer thread"); + assert_eq!(rx.pop(), None); +} + +// --------------------------------------------------------------------------- +// The doorbell, joined to the queue. +// +// The tests below are about the *pairing* of the two; the doorbell's own +// behaviour as a kernel object is covered in `crate::doorbell`'s suite. +// --------------------------------------------------------------------------- + +/// Whether the queue's doorbell is signalled right now, asked of the kernel +/// rather than of the mirror flag. +/// +/// Uses a zero timeout, so it does not block, and the event is manual-reset, so +/// asking does not consume the answer. +fn doorbell_is_lit(consumer: &Consumer) -> bool { + let handle = consumer.doorbell().expect("the doorbell must be creatable"); + // SAFETY: a live event handle borrowed for the call; a zero timeout returns + // immediately and has no other precondition. + let result = unsafe { WaitForSingleObject(handle.as_raw_handle(), 0) }; + assert!( + result == WAIT_OBJECT_0 || result == WAIT_TIMEOUT, + "the wait must resolve to signalled or not, got {result:#x}" + ); + result == WAIT_OBJECT_0 +} + +#[test] +fn polling_never_creates_a_kernel_object() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + + // The laziness claim, asserted rather than assumed: a consumer that only + // ever polls must not be charged for an event it never waits on. + let second = tx.clone(); + for value in 0..4 { + if value % 2 == 0 { + tx.push(value).expect("there is room"); + } else { + second.push(value).expect("there is room"); + } + } + while rx.pop().is_some() {} + drop(tx); + drop(second); + while rx.pop().is_some() {} + + assert!( + !rx.shared.doorbell.is_armed(), + "a poll-only consumer must allocate no kernel object, even when a producer disconnects" + ); +} + +#[test] +fn a_push_lights_the_doorbell() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + assert!( + !doorbell_is_lit(&rx), + "an empty queue must not claim readiness" + ); + tx.push(1).expect("there is room"); + assert!(doorbell_is_lit(&rx), "a pushed item must be announced"); +} + +#[test] +fn the_doorbell_stays_lit_across_repeated_observation() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + // Created before the push, which is not incidental: a push that runs while + // no event exists signals nothing, as + // `an_item_pushed_before_the_doorbell_existed_is_still_found` asserts. This + // test is about the level, so it starts from an armed doorbell. + rx.doorbell().expect("the doorbell must be creatable"); + tx.push(1).expect("there is room"); + + // A level, not an edge. An auto-reset event would fail the second pass, and + // a consumer sharing the wait with other handles would lose the queue. + for observation in 1..=3 { + assert!( + doorbell_is_lit(&rx), + "observation {observation} must still see the level" + ); + } +} + +#[test] +fn arm_reports_unsafe_to_wait_while_items_remain() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("there is room"); + + assert!( + !rx.arm().expect("arming must succeed"), + "arming must refuse to bless a wait while an item is sitting there" + ); +} + +#[test] +fn arm_reports_safe_to_wait_when_empty() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + // Created before the push, and that is the whole test. Without it the push + // takes `signal`'s "no event yet" path, the doorbell is never lit, and the + // assertion below that arming CLEARS it would hold trivially. + rx.doorbell().expect("the doorbell must be creatable"); + tx.push(1).expect("there is room"); + assert!( + doorbell_is_lit(&rx), + "the doorbell must be lit before a test of clearing it can mean anything" + ); + assert_eq!(rx.pop(), Some(1)); + + assert!( + rx.arm().expect("arming must succeed"), + "a drained queue is safe to wait on" + ); + assert!( + !doorbell_is_lit(&rx), + "arming must clear the doorbell, or the next wait returns at once forever" + ); +} + +#[test] +fn arm_relights_the_doorbell_for_a_later_push() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + // As above: the first push must actually SET the mirror flag, or the claim + // that `clear` cleared it is a claim about a flag that was never set. + rx.doorbell().expect("the doorbell must be creatable"); + tx.push(1).expect("there is room"); + assert!(doorbell_is_lit(&rx), "the first push must light it"); + assert_eq!(rx.pop(), Some(1)); + assert!(rx.arm().expect("arming must succeed")); + + // The signal that must never be skipped: the doorbell was cleared, so the + // producer's mirror flag has to have been cleared with it. Pushed through a + // *different* handle, because the flag belongs to the queue rather than to + // whichever producer last rang. + let second = tx.clone(); + second.push(2).expect("there is room"); + assert!( + doorbell_is_lit(&rx), + "the first push after a clear must light the doorbell again" + ); +} + +#[test] +fn an_item_pushed_before_the_doorbell_existed_is_still_found() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + + // The lazy-creation hole. This push signals nothing, because there is no + // event yet to signal -- `crate::doorbell`'s suite asserts that directly. + tx.push(1).expect("there is room"); + assert!(!rx.shared.doorbell.is_armed(), "no event exists yet"); + + // Arming creates the event and only then checks, so the item is found + // instead of waited on. Had the check come first, this would report "safe + // to wait" and the consumer would block on an item already queued. + assert!( + !rx.arm().expect("arming must succeed"), + "arming must not bless a wait over an item that predates the doorbell" + ); +} + +#[test] +fn the_real_arm_finds_an_item_that_lands_inside_its_window() { + // The deterministic indictment of the reversed order, driven through the + // REAL `Consumer::arm` rather than through a copy of it. + // + // The hook fires between `arm`'s clear and its readiness check -- precisely + // the window a producer must hit for the hazard to bite. With the correct + // order the check follows the push and finds it, so arming refuses to bless + // a wait. With the two statements swapped the check has already happened, + // arming returns "safe to wait", and the consumer parks on a queue holding + // an item whose signal the clear erased. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + // The hook owns the producer outright. Sharing one behind an `Arc` would be + // pointless: a `Producer` is deliberately `!Sync`. + let safe_to_wait = arm_race::with( + move || { + tx.push(1).expect("there is room"); + }, + || rx.arm().expect("arming must succeed"), + ); + + assert!( + !safe_to_wait, + "an item landing between the clear and the check must be found, not waited past" + ); +} + +#[test] +fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { + // The complement, so the test above cannot pass by `arm` simply never + // blessing anything. + let (_tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + let safe_to_wait = arm_race::with(|| {}, || rx.arm().expect("arming must succeed")); + assert!( + safe_to_wait, + "nothing arrived, so waiting is exactly what the consumer should do" + ); +} + +#[test] +fn the_owned_doorbell_outlives_the_consumers_use_of_it() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let owned = rx.doorbell_owned().expect("duplication must succeed"); + tx.push(1).expect("there is room"); + + // SAFETY: `owned` is a live event handle; a zero timeout returns at once. + let result = unsafe { WaitForSingleObject(owned.as_raw_handle(), 0) }; + assert_eq!( + result, WAIT_OBJECT_0, + "a caller holding its own duplicate must see the queue's signals" + ); +} + +// --------------------------------------------------------------------------- +// Blocking receive. +// --------------------------------------------------------------------------- + +#[test] +fn recv_returns_an_item_already_queued() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(7).expect("there is room"); + assert_eq!(rx.recv().expect("an item is queued"), 7); +} + +#[test] +fn recv_blocks_until_a_push_arrives() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let producer = thread::spawn(move || { + // A short sleep so the consumer is genuinely parked rather than racing + // to the first `pop`. Correctness does not depend on winning that race + // -- it depends on the wakeup arriving either way. + thread::sleep(Duration::from_millis(50)); + tx.push(99).expect("there is room"); + }); + + assert_eq!(rx.recv().expect("the producer pushes"), 99); + producer.join().expect("the producer must not panic"); +} + +#[test] +fn recv_reports_disconnection_once_drained() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + drop(tx); + assert!( + matches!(rx.recv(), Err(RecvError::Disconnected)), + "an empty queue with no producer is finished" + ); +} + +#[test] +fn recv_delivers_items_pushed_before_the_last_producer_dropped() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let second = tx.clone(); + tx.push(1).expect("there is room"); + second.push(2).expect("there is room"); + drop(tx); + drop(second); + + // Disconnection must not discard what was already sent. Testing the flag + // before draining is the mistake this guards. + assert_eq!(rx.recv().expect("item one is owed"), 1); + assert_eq!(rx.recv().expect("item two is owed"), 2); + assert!( + matches!(rx.recv(), Err(RecvError::Disconnected)), + "and only then is it finished" + ); +} + +#[test] +fn a_blocked_recv_is_released_only_by_the_last_producer_dropping() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let second = tx.clone(); + + let producers = thread::spawn(move || { + // The first departure must NOT release the consumer, and the second + // must. Without a signal in the last producer's `Drop` this test hangs + // forever: the queue would be correct and the program still wedged. + thread::sleep(Duration::from_millis(30)); + drop(tx); + thread::sleep(Duration::from_millis(30)); + drop(second); + }); + + let started = Instant::now(); + assert!( + matches!(rx.recv(), Err(RecvError::Disconnected)), + "the last producer leaving must wake a parked consumer" + ); + assert!( + started.elapsed() >= Duration::from_millis(50), + "and the FIRST producer leaving must not have released it" + ); + producers.join().expect("the producers must not panic"); +} + +#[test] +fn recv_timeout_gives_up_on_an_empty_live_queue() { + let (_tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let started = Instant::now(); + let result = rx.recv_timeout(Duration::from_millis(60)); + + assert!( + matches!(result, Err(RecvTimeoutError::Timeout)), + "an empty queue with a live producer times out rather than ending" + ); + assert!( + result.is_err_and(|error| error.is_retryable()), + "and a timeout is worth retrying, unlike the other two variants" + ); + assert!( + started.elapsed() >= Duration::from_millis(50), + "it must actually have waited rather than returned at once" + ); +} + +#[test] +fn recv_timeout_returns_an_item_that_arrives_in_time() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let producer = thread::spawn(move || { + thread::sleep(Duration::from_millis(30)); + tx.push(5).expect("there is room"); + }); + + assert_eq!( + rx.recv_timeout(Duration::from_secs(5)) + .expect("the push lands well inside the deadline"), + 5 + ); + producer.join().expect("the producer must not panic"); +} + +#[test] +fn recv_timeout_reports_disconnection_rather_than_waiting_out_the_clock() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + drop(tx); + + let started = Instant::now(); + let result = rx.recv_timeout(Duration::from_secs(30)); + + assert!( + matches!(result, Err(RecvTimeoutError::Disconnected)), + "a finished queue is finished, deadline or not" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "and it must be reported at once rather than after the deadline" + ); +} + +#[test] +fn recv_timeout_does_not_panic_on_an_unrepresentable_deadline() { + // `Instant + Duration` panics when the sum is not representable, and + // `Duration::MAX` is an ordinary way to spell "effectively forever". The + // queue is disconnected up front so the call has a reason to return at all; + // the assertion is that it returns rather than aborting the process. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + drop(tx); + + assert!( + matches!( + rx.recv_timeout(Duration::MAX), + Err(RecvTimeoutError::Disconnected) + ), + "an unrepresentable deadline must degrade to the untimed wait it asked for" + ); +} + +#[test] +fn a_blocking_consumer_receives_every_item_from_every_producer() { + // The whole mechanism under load, through the blocking path rather than by + // polling: a capacity far smaller than the run, so the producers block on a + // full queue and the consumer parks on an empty one, repeatedly. + let received = run_producers(16); + assert_eq!( + received.len(), + PRODUCERS * PER_PRODUCER, + "a parked consumer must miss nothing" + ); +} diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 7d8d7bfb..3fa7b298 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -35,8 +35,12 @@ //! } //! ``` //! -//! The traits themselves are deliberately absent until a second shape exists to -//! validate them. +//! **They have since shipped, and they kept those signatures.** +//! [`mpsc`](crate::mpsc) was written against this sketch and matched it, which +//! is the validation [D-3](../../DESIGN-NOTES.md#d-3) demanded before any trait +//! was allowed to exist. The sketch is left here because it is the artefact +//! that made the check possible: what [`crate::traits`] says now is what this +//! comment said before either type existed. //! //! # Why the operations take `&self` //! @@ -65,24 +69,24 @@ use core::marker::PhantomData; use core::mem::MaybeUninit; use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::io; -use std::os::windows::io::{AsRawHandle, BorrowedHandle, OwnedHandle}; +use std::os::windows::io::{BorrowedHandle, OwnedHandle}; use std::sync::Arc; -use std::time::{Duration, Instant}; - -use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; -use windows_sys::Win32::System::Threading::{INFINITE, WaitForSingleObject}; +use std::time::Duration; use crate::CacheAligned; +use crate::blocking::{self, Parked}; +use crate::capacity::validate_capacity; use crate::doorbell::Doorbell; use crate::error::{CapacityError, PushError, RecvError, RecvTimeoutError}; -/// The largest capacity that keeps the producer-minus-consumer difference -/// unambiguous once the positions wrap. +/// The smallest capacity this shape can represent. /// -/// Positions are monotonic and wrap with the integer, so the number of items -/// held is `tail.wrapping_sub(head)`. That is correct across wraparound only -/// while the true difference cannot exceed half the range. -const MAX_CAPACITY: usize = usize::MAX / 2; +/// One, and there is nothing to work around: a single slot is either inside +/// `[head, tail)` or outside it, and those are the only two states this shape's +/// positions have to distinguish. [`mpsc`](crate::mpsc) needs two, because its +/// slots carry a third state, and that difference is why each shape names its +/// own minimum rather than sharing one. +const MIN_CAPACITY: usize = 1; /// Creates a single-producer, single-consumer bounded ring. /// @@ -107,7 +111,7 @@ const MAX_CAPACITY: usize = usize::MAX / 2; /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { - validate_capacity(capacity)?; + validate_capacity(capacity, MIN_CAPACITY)?; let mut slots = Vec::with_capacity(capacity); slots.resize_with(capacity, || UnsafeCell::new(MaybeUninit::uninit())); @@ -135,26 +139,6 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit )) } -/// Whether this shape will accept a capacity, and why not if it will not. -/// -/// Separated from [`bounded`] so the rule can be *asked* rather than restated. -/// A test that wanted to check a suggested capacity is acceptable would -/// otherwise have to either re-encode these three conditions -- a second copy -/// of a rule, free to drift from this one -- or call `bounded`, which for a -/// capacity near the bound means trying to allocate half the address space. -fn validate_capacity(capacity: usize) -> Result<(), CapacityError> { - if capacity == 0 { - return Err(CapacityError::zero(MAX_CAPACITY)); - } - if !capacity.is_power_of_two() { - return Err(CapacityError::not_power_of_two(capacity, MAX_CAPACITY)); - } - if capacity > MAX_CAPACITY { - return Err(CapacityError::too_large(capacity, MAX_CAPACITY)); - } - Ok(()) -} - struct Shared { slots: Box<[UnsafeCell>]>, mask: usize, @@ -511,7 +495,7 @@ impl Consumer { self.shared.doorbell.handle()?; self.shared.doorbell.clear(); #[cfg(test)] - run_arm_race_hook(); + crate::arm_race::run(); Ok(self.is_empty()) } @@ -543,18 +527,7 @@ impl Consumer { /// drained -- items pushed before the producer dropped are still delivered. /// [`RecvError::Io`] if the doorbell cannot be created or waited on. pub fn recv(&self) -> Result { - loop { - if let Some(item) = self.pop() { - return Ok(item); - } - if !self.arm()? { - continue; - } - if self.is_disconnected() { - return self.finish().ok_or(RecvError::Disconnected); - } - wait(self.doorbell()?, INFINITE)?; - } + blocking::recv(self) } /// Takes the oldest item, blocking until one arrives or the deadline @@ -568,92 +541,31 @@ impl Consumer { /// [`RecvTimeoutError::Timeout`] if the deadline passes with the queue /// still empty, which is not a malfunction. Otherwise as [`Self::recv`]. pub fn recv_timeout(&self, timeout: Duration) -> Result { - // `Instant + Duration` panics when the sum is not representable, and - // `Duration::MAX` is a perfectly ordinary way to spell "effectively - // forever". A library that panics on that is worse than one that - // blocks, so an unrepresentable deadline degrades to the untimed wait - // it was asking for rather than aborting the caller. - let Some(deadline) = Instant::now().checked_add(timeout) else { - return self.recv().map_err(|error| match error { - RecvError::Disconnected => RecvTimeoutError::Disconnected, - RecvError::Io(io) => RecvTimeoutError::Io(io), - }); - }; - loop { - if let Some(item) = self.pop() { - return Ok(item); - } - if !self.arm()? { - continue; - } - if self.is_disconnected() { - return self.finish().ok_or(RecvTimeoutError::Disconnected); - } - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err(RecvTimeoutError::Timeout); - } - // Saturating rather than wrapping: a duration longer than a `u32` - // of milliseconds is roughly 49 days, and clamping it to that is a - // longer wait than any caller meant, where truncating it would be a - // far shorter one. The loop re-arms and waits again, so clamping - // costs an extra turn and nothing else. - let millis = u32::try_from(remaining.as_millis()).unwrap_or(u32::MAX); - wait(self.doorbell()?, millis)?; - } + blocking::recv_timeout(self, timeout) } } -/// Test-only: runs inside [`Consumer::arm`], between the clear and the -/// emptiness check. -/// -/// This exists so a test can drive the *real* `arm` through the exact race the -/// clear-then-check order defends against, deterministically and on one thread. -/// -/// It replaces a hand-written copy of `arm` with the two statements swapped. -/// That copy could only ever demonstrate that *a* reversed order is wrong; it -/// could not detect the real `arm` being reversed, because it was not the real -/// `arm`. Measured: with the copy as the only deterministic test, sabotaging -/// the real `arm` was caught in one run out of three -- detection relied on two -/// threads happening to interleave inside a window tens of nanoseconds wide. -/// A second copy of a rule is a check of the copy, not of the rule. -#[cfg(test)] -fn run_arm_race_hook() { - ARM_RACE_HOOK.with(|hook| { - // Taken out for the call rather than held borrowed across it, so a hook - // that touches the queue cannot trip a `RefCell` re-entrancy panic. - let taken = hook.borrow_mut().take(); - if let Some(mut race) = taken { - race(); - *hook.borrow_mut() = Some(race); - } - }); -} +impl Parked for Consumer { + type Item = T; -#[cfg(test)] -thread_local! { - static ARM_RACE_HOOK: core::cell::RefCell>> = - const { core::cell::RefCell::new(None) }; -} + fn pop(&self) -> Option { + Self::pop(self) + } -/// Test-only: installs a hook for the duration of a closure. -#[cfg(test)] -pub(crate) fn with_arm_race(race: impl FnMut() + 'static, body: impl FnOnce() -> R) -> R { - ARM_RACE_HOOK.with(|hook| *hook.borrow_mut() = Some(Box::new(race))); - let result = body(); - ARM_RACE_HOOK.with(|hook| *hook.borrow_mut() = None); - result -} + fn finish(&self) -> Option { + Self::finish(self) + } + + fn arm(&self) -> io::Result { + Self::arm(self) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } -/// Block on a doorbell handle, translating the Win32 result. -fn wait(handle: BorrowedHandle<'_>, millis: u32) -> io::Result<()> { - // SAFETY: a live event handle borrowed for the duration of the call. - let result = unsafe { WaitForSingleObject(handle.as_raw_handle(), millis) }; - match result { - // A timeout is not an error here: the caller's loop re-checks its own - // deadline and decides what a timeout means. - WAIT_OBJECT_0 | WAIT_TIMEOUT => Ok(()), - _ => Err(io::Error::last_os_error()), + fn doorbell(&self) -> io::Result> { + Self::doorbell(self) } } @@ -674,5 +586,71 @@ impl Drop for Consumer { } } +impl crate::Producer for Producer { + type Item = T; + + fn push(&self, item: T) -> Result<(), PushError> { + Self::push(self, item) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } +} + +impl crate::Consumer for Consumer { + type Item = T; + + fn pop(&self) -> Option { + Self::pop(self) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } +} + +impl crate::Bounded for Producer { + fn capacity(&self) -> usize { + Self::capacity(self) + } + + fn len(&self) -> usize { + Self::len(self) + } + + fn is_empty(&self) -> bool { + Self::is_empty(self) + } +} + +impl crate::Bounded for Consumer { + fn capacity(&self) -> usize { + Self::capacity(self) + } + + fn len(&self) -> usize { + Self::len(self) + } + + fn is_empty(&self) -> bool { + Self::is_empty(self) + } +} + +impl crate::Waitable for Consumer { + fn doorbell(&self) -> io::Result> { + Self::doorbell(self) + } + + fn doorbell_owned(&self) -> io::Result { + Self::doorbell_owned(self) + } + + fn arm(&self) -> io::Result { + Self::arm(self) + } +} + #[cfg(test)] mod tests; diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index fcbe654c..c09ec029 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -6,7 +6,8 @@ //! joined thread rather than a sleep, so they are deterministic: the assertion //! runs after the peer has finished, not after a guess about how long it takes. -use super::{Consumer, Producer, bounded, validate_capacity}; +use super::{Consumer, MIN_CAPACITY, Producer, bounded, validate_capacity}; +use crate::arm_race; use crate::{PushError, RecvError, RecvTimeoutError}; use std::os::windows::io::AsRawHandle; use std::sync::Arc; @@ -851,18 +852,18 @@ fn a_suggested_capacity_is_one_the_constructor_would_accept() { // queue means asking for half the address space -- the first version of // this test aborted the process with a four-exabyte allocation failure. for requested in [1_usize, 3, 100, 1000, 0, usize::MAX / 2, usize::MAX] { - let Err(error) = validate_capacity(requested) else { + let Err(error) = validate_capacity(requested, MIN_CAPACITY) else { continue; }; if let Some(previous) = error.previous_valid() { assert!( - validate_capacity(previous).is_ok(), + validate_capacity(previous, MIN_CAPACITY).is_ok(), "previous_valid() for {requested} suggested {previous}, which is itself rejected" ); } if let Some(next) = error.next_valid() { assert!( - validate_capacity(next).is_ok(), + validate_capacity(next, MIN_CAPACITY).is_ok(), "next_valid() for {requested} suggested {next}, which is itself rejected" ); } @@ -874,7 +875,8 @@ fn the_largest_request_is_clamped_rather_than_rounded() { // Rounding `usize::MAX` down to the nearest power of two gives 2^63, which // is larger than the largest representable capacity. Before this was fixed // the suggestion was exactly that unusable value. - let error = validate_capacity(usize::MAX).expect_err("usize::MAX is not a valid capacity"); + let error = validate_capacity(usize::MAX, MIN_CAPACITY) + .expect_err("usize::MAX is not a valid capacity"); let previous = error .previous_valid() .expect("there is a valid capacity below usize::MAX"); @@ -888,7 +890,10 @@ fn the_largest_request_is_clamped_rather_than_rounded() { previous.is_power_of_two(), "and it must still be a power of two" ); - assert!(validate_capacity(previous).is_ok(), "and must be accepted"); + assert!( + validate_capacity(previous, MIN_CAPACITY).is_ok(), + "and must be accepted" + ); } #[test] @@ -908,7 +913,7 @@ fn the_real_arm_finds_an_item_that_lands_inside_its_window() { // The hook owns the producer outright. An `Arc` would be pointless here and // clippy says so: a `Producer` is deliberately `!Sync`, so sharing one is // exactly what the type system is built to prevent. - let safe_to_wait = super::with_arm_race( + let safe_to_wait = arm_race::with( move || { tx.push(1).expect("there is room"); }, @@ -929,7 +934,7 @@ fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { rx.doorbell().expect("the doorbell must be creatable"); drop(tx); - let safe_to_wait = super::with_arm_race(|| {}, || rx.arm().expect("arming must succeed")); + let safe_to_wait = arm_race::with(|| {}, || rx.arm().expect("arming must succeed")); assert!( safe_to_wait, diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs new file mode 100644 index 00000000..9727e542 --- /dev/null +++ b/crates/windows-waitable-queues/src/traits.rs @@ -0,0 +1,209 @@ +// Copyright (c) Mike Grier. + +//! The capability traits, each naming one thing a queue can do. +//! +//! # Narrow, on the `std::io` model +//! +//! There is deliberately no single `WaitableQueue` trait. `std::io` does not +//! have one `Io` trait either; it has [`Read`](std::io::Read), +//! [`Write`](std::io::Write) and [`Seek`](std::io::Seek), and a type implements +//! the subset it genuinely has. The same choice is forced here rather than +//! merely preferred, because a fat trait is *unimplementable* by shapes this +//! crate plans to ship: a queue that is never waited on has no doorbell to +//! return, and an unbounded one has no capacity to report. Recorded as +//! [D-2](../../DESIGN-NOTES.md#d-2). +//! +//! What that buys is a consumer generic over exactly what it needs. A drainer +//! that parks on a queue asks for [`Consumer`] and [`Waitable`], and stays +//! usable against a shape that has never heard of reservation or loss +//! reporting. +//! +//! # Why they arrive with the second shape and not the first +//! +//! A trait written against one implementation designs in a vacuum: every +//! signature that type happens to have looks like a requirement, and nothing +//! tests whether the abstraction is the right one. So the trait *shape* was +//! fixed in prose when `spsc` was written -- the signatures were spelled out in +//! its module documentation before the type existed -- and the traits +//! themselves waited for `mpsc` to exist to be checked against. That is +//! [D-3](../../DESIGN-NOTES.md#d-3), and the check it demands is not rhetorical: +//! `mpsc` is a lock-free multi-producer array queue with no structural +//! resemblance to `spsc` beyond its interface, so a signature that fitted only +//! the first shape would have failed here rather than in a consumer's code. +//! +//! # The name a trait shares with a handle +//! +//! [`Producer`] and [`Consumer`] are also the names of the concrete handles in +//! [`spsc`](crate::spsc) and [`mpsc`](crate::mpsc). That is deliberate: the +//! trait is named for the role, the handle is named for the role, and the +//! handle plays the role. `std` does the same thing with `fmt::Write` and +//! `io::Write`, and the module path disambiguates. Importing the traits +//! anonymously -- `use windows_waitable_queues::{Bounded as _, Consumer as _}` +//! -- avoids the question entirely when only the methods are wanted. + +use std::io; +use std::os::windows::io::{BorrowedHandle, OwnedHandle}; + +use crate::error::PushError; + +/// The writing end of a queue. +pub trait Producer { + /// What this queue carries. + type Item; + + /// Appends an item. + /// + /// Takes `&self` rather than `&mut self`, which is what lets a + /// multi-producer shape share one handle's operation across threads. A + /// single-producer shape gets its guarantee from not being [`Sync`] + /// instead, so nothing is given up by the weaker receiver. + /// + /// # Errors + /// + /// [`PushError::Full`] when the queue is at capacity, which is the + /// backpressure signal rather than a malfunction, and + /// [`PushError::Disconnected`] when every consumer is gone. Either way the + /// item comes back, so nothing is lost by the refusal. + fn push(&self, item: Self::Item) -> Result<(), PushError>; + + /// Whether every consumer is gone, so nothing will ever take an item again. + fn is_disconnected(&self) -> bool; +} + +/// The reading end of a queue. +pub trait Consumer { + /// What this queue carries. + type Item; + + /// Takes the oldest item, or `None` if there is none right now. + /// + /// `None` does not mean the queue is finished. Pair it with + /// [`Consumer::is_disconnected`], and in that order: a producer may push + /// and then drop, so a queue can be disconnected and still hold items. + fn pop(&self) -> Option; + + /// Whether every producer is gone. + /// + /// **Ask only after [`Consumer::pop`] has returned `None`.** Draining to + /// empty and then finding the producers gone is the only order that cannot + /// lose an item. + fn is_disconnected(&self) -> bool; + + /// Takes items until the queue is momentarily empty. + /// + /// Ends when a [`Consumer::pop`] returns `None`, which is a statement about + /// this instant and not about the stream: a producer may push again + /// immediately afterwards. It is the "take everything available" step of + /// the arming protocol, not a way to consume a queue to its end. + fn drain(&self) -> Drain<'_, Self> + where + Self: Sized, + { + Drain { consumer: self } + } +} + +/// Takes items from a [`Consumer`] until it is momentarily empty. +/// +/// Created by [`Consumer::drain`]. +#[derive(Debug)] +pub struct Drain<'a, C> { + consumer: &'a C, +} + +impl Iterator for Drain<'_, C> { + type Item = C::Item; + + fn next(&mut self) -> Option { + self.consumer.pop() + } +} + +/// A queue that holds a fixed number of items and says how many. +/// +/// Implemented by both ends, because both have a use for it: a producer reads +/// it to report backpressure, and a consumer to report depth. +pub trait Bounded { + /// The exact number of items this queue holds when full. + /// + /// Not a hint and not rounded -- it is the number the caller asked for. + fn capacity(&self) -> usize; + + /// Items currently held, as a snapshot. + /// + /// A snapshot the moment it is returned: the other end may push or pop + /// immediately afterwards, which is why nothing here invites a + /// check-then-act. Use it for metrics, not for control flow. + fn len(&self) -> usize; + + /// Whether the queue holds nothing, as a snapshot. + fn is_empty(&self) -> bool; + + /// How many more items would fit, as a snapshot. + /// + /// Saturating rather than wrapping, because a shape may count a slot that a + /// producer has claimed but not yet finished writing, and a momentary + /// overshoot should read as "no room" rather than as a very large number. + fn remaining(&self) -> usize { + self.capacity().saturating_sub(self.len()) + } +} + +/// A queue whose readiness can be waited on as a Windows `HANDLE`. +/// +/// This is the capability the crate is named for, and the reason it exists +/// rather than deferring to an established concurrent-queue crate: a `HANDLE` +/// goes into `WaitForMultipleObjects` beside an I/O completion, a timer, or a +/// shutdown event, and a private parking primitive goes nowhere. +/// +/// **Not necessarily queue-specific.** "Hands out a `HANDLE` you can wait on" +/// is equally a property of an event, a timer, or a completion port. If a +/// second kind of thing wants to implement it, this trait moves to a lower +/// crate and this one depends on it; that move is planned rather than a +/// surprise, which is why it is said here. +pub trait Waitable { + /// Borrows the queue's readiness as a waitable `HANDLE`. + /// + /// The event is created on the first call, so a consumer that only ever + /// polls is charged for no kernel object. + /// + /// The borrow is deliberate: the event belongs to the queue and must not be + /// closed. Use [`Waitable::doorbell_owned`] where ownership is required. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` on the first call. + fn doorbell(&self) -> io::Result>; + + /// A duplicate of [`Waitable::doorbell`] that the caller owns. + /// + /// The duplicate names the same event, so signalling reaches both. This is + /// the form a `ThreadpoolWait` needs, since arming one takes ownership of + /// its target. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` or `DuplicateHandle`. + fn doorbell_owned(&self) -> io::Result; + + /// Clears the doorbell and reports whether it is safe to wait on it. + /// + /// `true` means the queue had nothing to take *after* the doorbell was + /// cleared, so any later push is guaranteed to signal and a wait cannot be + /// missed. `false` means something arrived in the meantime: take it instead + /// of waiting. + /// + /// **Waiting without arming is a permanent hang, not an occasional missed + /// wakeup.** The full argument is in + /// [D-9](../../DESIGN-NOTES.md#d-9); the short form is that clearing must come + /// before the emptiness check, which is the reverse of the order that reads + /// naturally. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` on the first call. + fn arm(&self) -> io::Result; +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/traits/tests.rs b/crates/windows-waitable-queues/src/traits/tests.rs new file mode 100644 index 00000000..9fcd9592 --- /dev/null +++ b/crates/windows-waitable-queues/src/traits/tests.rs @@ -0,0 +1,183 @@ +// Copyright (c) Mike Grier. + +//! Tests for the capability traits. +//! +//! # What these are actually for +//! +//! Not to re-test the shapes -- each shape's own suite does that, through its +//! inherent methods, and repeating it here would only assert that a delegating +//! trait impl delegates. What is tested here is the claim +//! [D-3](../../DESIGN-NOTES.md#d-3) makes: that the traits are a real +//! abstraction over more than one implementation, so that a caller can be +//! written against them without knowing which shape it has. +//! +//! The evidence is a set of generic functions with no knowledge of either +//! shape, exercised against both. If a trait were shaped around one of them -- +//! the failure D-3 exists to prevent -- these would not compile against the +//! other, which is a stronger check than any assertion in the bodies. + +use crate::{Bounded, Consumer, Producer, PushError, Waitable, mpsc, spsc}; + +/// Fills a queue through nothing but the [`Producer`] and [`Bounded`] traits, +/// and reports what the refusal said. +/// +/// Deliberately generic over two unrelated types with two unrelated internal +/// protocols. Both `where` bounds are load-bearing: this is a caller that needs +/// to push *and* to know the bound, and D-2's whole argument is that it should +/// be able to ask for exactly those two things. +fn fill_to_capacity

(producer: &P) -> PushError +where + P: Producer + Bounded, +{ + assert!(producer.is_empty(), "a fresh queue holds nothing"); + assert_eq!( + producer.remaining(), + producer.capacity(), + "and all of its room is available" + ); + + for value in 0..producer.capacity() { + let value = u32::try_from(value).expect("the test capacities are small"); + producer.push(value).expect("there is room"); + } + + assert_eq!(producer.len(), producer.capacity()); + assert_eq!( + producer.remaining(), + 0, + "a full queue has no room, which is what the default method must compute" + ); + producer + .push(u32::MAX) + .expect_err("a full queue must refuse") +} + +/// Drains a queue through nothing but the [`Consumer`] trait, using the +/// provided `drain` method rather than a hand-written `while let` loop. +fn drain_all(consumer: &C) -> Vec +where + C: Consumer, +{ + let drained: Vec = consumer.drain().collect(); + assert!( + consumer.drain().next().is_none(), + "draining must leave the queue empty" + ); + drained +} + +/// Parks-or-proceeds through nothing but the [`Waitable`] trait. +/// +/// This is the arming protocol as a *consumer* would write it, which is the +/// case D-2 names: a drainer needs `Consumer` and `Waitable` and nothing else, +/// and must not be coupled to reservation or loss reporting to get them. +fn arm_and_report(consumer: &C) -> bool +where + C: Consumer + Waitable, +{ + consumer.doorbell().expect("the doorbell must be creatable"); + consumer + .doorbell_owned() + .expect("the duplicate must be creatable"); + consumer.arm().expect("arming must succeed") +} + +#[test] +fn both_shapes_satisfy_the_producer_and_bounded_traits() { + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid for both shapes"); + let (mpsc_tx, mpsc_rx) = mpsc::bounded::(4).expect("4 is valid for both shapes"); + + assert!( + matches!(fill_to_capacity(&spsc_tx), PushError::Full(u32::MAX)), + "the generic filler must work against the ring" + ); + assert!( + matches!(fill_to_capacity(&mpsc_tx), PushError::Full(u32::MAX)), + "and against the sequence-protocol queue, unchanged" + ); + + assert_eq!(drain_all(&spsc_rx), vec![0, 1, 2, 3]); + assert_eq!(drain_all(&mpsc_rx), vec![0, 1, 2, 3]); +} + +#[test] +fn both_shapes_report_disconnection_through_the_traits() { + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid for both shapes"); + let (mpsc_tx, mpsc_rx) = mpsc::bounded::(4).expect("4 is valid for both shapes"); + + fn producer_sees_it>(producer: &P) -> bool { + producer.is_disconnected() + } + fn consumer_sees_it>(consumer: &C) -> bool { + consumer.is_disconnected() + } + + assert!(!producer_sees_it(&spsc_tx)); + assert!(!producer_sees_it(&mpsc_tx)); + assert!(!consumer_sees_it(&spsc_rx)); + assert!(!consumer_sees_it(&mpsc_rx)); + + drop(spsc_rx); + drop(mpsc_rx); + assert!(producer_sees_it(&spsc_tx)); + assert!( + producer_sees_it(&mpsc_tx), + "one consumer gone is every consumer gone, for both shapes" + ); +} + +#[test] +fn both_shapes_satisfy_the_waitable_trait() { + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid for both shapes"); + let (mpsc_tx, mpsc_rx) = mpsc::bounded::(4).expect("4 is valid for both shapes"); + + assert!(arm_and_report(&spsc_rx), "an empty ring is safe to wait on"); + assert!( + arm_and_report(&mpsc_rx), + "and so is an empty sequence-protocol queue" + ); + + spsc_tx.push(1).expect("there is room"); + mpsc_tx.push(1).expect("there is room"); + assert!( + !arm_and_report(&spsc_rx), + "and neither blesses a wait over an item" + ); + assert!(!arm_and_report(&mpsc_rx)); +} + +#[test] +fn the_multi_producer_shape_is_usable_through_the_producer_trait_from_a_clone() { + // The trait was written against handles that are not `Clone` and handles + // that are, and `push` taking `&self` is what lets it span both. Had the + // first shape shipped `push(&mut self)` -- which single-producer soundness + // would have permitted -- this could not compile. + let (tx, rx) = mpsc::bounded::(4).expect("4 is a valid capacity"); + let second = tx.clone(); + + fn push_one>(producer: &P, value: u32) { + producer.push(value).expect("there is room"); + } + + push_one(&tx, 1); + push_one(&second, 2); + assert_eq!(drain_all(&rx), vec![1, 2]); +} + +#[test] +fn drain_stops_at_the_current_end_rather_than_at_the_end_of_the_stream() { + // `drain` is the "take everything available" step of the arming protocol, + // not a way to consume a queue to its end. A caller that read it as the + // latter would drop items pushed afterwards, so the distinction is asserted + // rather than left to the documentation. + let (tx, rx) = mpsc::bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("there is room"); + + assert_eq!(drain_all(&rx), vec![1]); + tx.push(2).expect("there is room"); + assert_eq!( + drain_all(&rx), + vec![2], + "the queue was momentarily empty, not finished" + ); +} From 5554b581eae1aee222d7137c712c2e64e38a4183 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sun, 30 Aug 2026 22:55:52 -0400 Subject: [PATCH 025/361] fix(waitable-queues): reset the doorbell event before clearing the flag that mirrors it A lost wakeup, found by a sabotage BASELINE hanging once in a run that was otherwise green six times over. Doorbell::clear stored signalled=false and then called ResetEvent. A producer signalling between those two lines found a clear flag, set it, and issued a real SetEvent; the ResetEvent that followed erased that signal and left the flag set. The doorbell was then dark while claiming to be lit, so every later signal skipped its syscall and a parked consumer never woke. The order survived review and a whole sabotage sweep because the argument for it looked airtight: the racing producer publishes before it signals, so the caller's re-check sees the item and does not wait. That is sound only for a queue whose re-check is guaranteed to see anything any producer published, and spsc -- one producer, one tail -- was the only shape that could not falsify it. mpsc's re-check asks whether the HEAD slot is published, so a producer publishing at a later position is invisible to it and the consumer parks in exactly the wedged state. Resetting the event first moves the guarantee from the caller to the type: once clear returns the flag is false, so the next signal cannot be skipped. No future shape has to have a re-check strong enough to cover the window. Asserted deterministically at the layer that owns the invariant. race_hooks generalises the arm-race hook facility to two named windows, and CLEAR fires inside the real clear between its two lines. The test signals from there and asserts what a consumer depends on next -- that signal can still ring -- since both orders leave the event dark immediately afterwards. Verified to fail every run with the lines reversed, with a sabotage entry to keep it that way, and a control with an empty window beside it. Swept the restatements: D-9's "there is no third case" is amended rather than left standing, and both shapes' arm documentation now says which case each of them actually has. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-waitable-queues/DESIGN-NOTES.md | 63 ++++++++++++++- crates/windows-waitable-queues/sabotage.json | 30 ++++++- .../windows-waitable-queues/src/arm_race.rs | 56 ------------- .../windows-waitable-queues/src/doorbell.rs | 66 +++++++++++---- .../src/doorbell/tests.rs | 66 +++++++++++++++ crates/windows-waitable-queues/src/lib.rs | 4 +- crates/windows-waitable-queues/src/mpsc.rs | 31 +++++-- .../windows-waitable-queues/src/mpsc/tests.rs | 6 +- .../windows-waitable-queues/src/race_hooks.rs | 80 +++++++++++++++++++ crates/windows-waitable-queues/src/spsc.rs | 17 ++-- .../windows-waitable-queues/src/spsc/tests.rs | 6 +- 11 files changed, 327 insertions(+), 98 deletions(-) delete mode 100644 crates/windows-waitable-queues/src/arm_race.rs create mode 100644 crates/windows-waitable-queues/src/race_hooks.rs diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 3c0929a3..191dc27b 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -38,6 +38,7 @@ preferred. | D-12 | **A shape's *minimum* capacity belongs to the shape, not to the crate, and `mpsc`'s is two.** One slot cannot encode three states when the lap stride is the capacity, so "published at `p`" and "free again at `p + capacity`" collide. Reported through `CapacityError` rather than worked around, because every available workaround puts a load back on the producer's hot path for every queue in order to serve a capacity of one. | | D-13 | **The arming protocol is written once, in `blocking.rs`, and a shape binds to it by implementing a crate-private `Parked` trait.** The blocking receive loop *is* [D-9](#d-9), not glue around it; a second shape spelling it out again would be a second copy of a rule -- the exact mistake this crate has already paid for once. | | D-14 | **`mpsc`'s arming asks "would `pop` find something", not "is `len` zero".** The two disagree over a slot a producer has claimed but not published, and only the first answer lets the consumer park on it instead of spinning until that producer is rescheduled. | +| D-15 | **`Doorbell::clear` resets the event *before* clearing the flag that mirrors it, and the original order was a lost wakeup.** A producer signalling between the two lines set the flag and issued a real `SetEvent`; the `ResetEvent` that followed erased the signal and left the flag set, wedging the doorbell dark while it claimed to be lit. **Amends [D-9](#d-9)**, whose "there is no third case" holds only for a queue whose emptiness is one position comparison. | ## D-2: capabilities are sliced, not gathered @@ -168,7 +169,14 @@ naturally: `Consumer::arm` is steps 2 and 3, and returns whether step 4 is safe. Step 3 is what carries the guarantee: an item arriving before the clear is found by the check, and an item arriving after the clear -signals a doorbell that is no longer about to be reset. There is no third case. +signals a doorbell that is no longer about to be reset. + +**"There is no third case" is what this decision originally said next, and it was wrong** -- see +[D-15](#d-15). It holds for `spsc`, where one producer and one position mean that *any* push before the +clear makes the check find something. It fails for `mpsc`, where the check asks whether the *head* slot +is published: a producer publishing at a later position before the clear is the third case, invisible to +the check. The remedy is in `Doorbell::clear` rather than here, because what that case needs is not a +better check but a doorbell that is guaranteed able to ring again once the clear returns. **Check-then-clear is the lost wakeup**, and it is the easier code to write: a push landing between the check and the clear both signals and has its signal erased, so the consumer sleeps on a queue that is @@ -388,3 +396,56 @@ This also places the `SeqCst` pairing from D-9 correctly for this shape: the pro sequence and then loads the doorbell state, while the consumer stores the doorbell state and then loads that same sequence. It is the same store-buffer shape, over the same two fences, with a different pair of locations. + +## D-15: the clear order, and the assumption that hid a lost wakeup + +`Doorbell::clear` has two lines: reset the kernel event, and clear the `AtomicBool` that mirrors it so a +redundant `signal` can skip its syscall. **They originally ran flag-first, and that order is a permanent +hang.** A producer signalling between them finds a clear flag, sets it, and issues a real `SetEvent`; the +`ResetEvent` that follows erases that signal and leaves the flag set. The doorbell is then dark while +claiming to be lit, so every later `signal` skips, and a consumer parked on it never wakes. + +The flag is allowed to lie in exactly one direction -- claiming lit while the `SetEvent` has not landed +yet, which costs a skipped *redundant* signal. The order above produced the opposite lie, which costs the +one signal that mattered. + +**Why it survived review and a sabotage sweep.** The original argument was explicit and looks airtight: +the racing producer publishes *before* it signals, so the caller's re-check sees the item and does not +wait. It is sound -- for a queue whose re-check is guaranteed to see anything any producer published. +`spsc` is such a queue: one producer, one tail, and `is_empty` covers every push. So the argument was +tested against the only shape that could not falsify it, and it was written down as a general rule. + +`mpsc` falsifies it. Its re-check asks whether the **head** slot is published ([D-14](#d-14)), so a +producer publishing at a later position is invisible to it. The consumer parks in exactly the wedged +state, the producer holding the head publishes, its `signal` is skipped, and the queue hangs with an item +sitting in it. + +**How it was found, which is the part worth keeping.** Not by review, and not by the test suite: the +suite passed 120 tests in 0.28 s, six runs in a row. It was found because the sabotage harness refuses to +sweep against a red baseline, and its *baseline* run -- the one that exists only to prove the suite is +green before any defect is injected -- hung once in +`mpsc::tests::many_producers_deliver_every_item_exactly_once`. A single unreproducible hang is exactly +the finding it is tempting to dismiss as a slow machine, and the crate's own sabotage documentation +already says not to: "a flaky sabotage is a finding, not noise". The same applies to a flaky baseline. + +**The fix moves the guarantee from the caller to the type.** With the event reset first, the invariant is +a property of the doorbell rather than an obligation on whoever calls it: *once `clear` returns, the flag +is false, so the next `signal` cannot be skipped.* A producer signalling inside the window may still be +skipped, but it published before it signalled and therefore before the flag store, so the caller's +re-check observes whatever that publication made observable; and any producer that publishes after the +re-check finds the flag already false and rings for real. No caller has to reason about it, which is the +point -- the previous arrangement required every future shape to have a re-check strong enough to cover +the window, and no signature said so. + +**It is asserted deterministically, at the layer that owns it.** `race_hooks::CLEAR` fires inside the +real `clear`, between its two lines, and a test signals from there on one thread. The assertion is not +about the state immediately afterwards -- both orders leave the event dark -- but about what a consumer +depends on next: `signal` must still be able to ring. Reversed, the test fails every run; a sabotage +entry keeps it that way. A control with an empty window sits beside it, so the test cannot pass by +`clear` simply never leaving the doorbell ringable. + +**Two temptations refused.** Making `mpsc` arm on `len` instead of readiness would also have masked this, +by restoring the property that any push makes the re-check find something -- but it would have left the +doorbell able to reach the inconsistent state, waiting for the next shape, and it would have cost the +consumer a spin whenever a claim was in flight. Adding a lock around the two lines would have fixed it +and thrown away the reason the flag exists. diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json index 3116cd15..8fbbba86 100644 --- a/crates/windows-waitable-queues/sabotage.json +++ b/crates/windows-waitable-queues/sabotage.json @@ -43,14 +43,14 @@ "find": [ " self.shared.doorbell.clear();", " #[cfg(test)]", - " crate::arm_race::run();", + " crate::race_hooks::ARM.run();", " Ok(self.is_empty())" ], "replace": [ " let empty = self.is_empty();", " self.shared.doorbell.clear();", " #[cfg(test)]", - " crate::arm_race::run();", + " crate::race_hooks::ARM.run();", " Ok(empty)" ] }, @@ -182,6 +182,28 @@ " if false {" ] }, + { + "name": "clear clears the mirror flag before resetting the event", + "file": "src/doorbell.rs", + "expect": "caught", + "why": "The historical order, and a lost wakeup. A producer signalling between the two lines finds a clear flag, sets it, and issues a real SetEvent; the ResetEvent that follows erases that signal and leaves the flag set, so the doorbell is dark while claiming to be lit and every later signal skips its syscall. It survived review and a whole sabotage sweep because the argument for it -- 'the caller's re-check sees the racing producer's item' -- is true for spsc and false for mpsc, whose re-check asks only whether the HEAD slot is published. Found by a sabotage BASELINE hanging once in a run that was otherwise green six times over. The race_hooks::CLEAR hook drives the real clear through its own window on one thread, so this is caught every run rather than occasionally.", + "find": [ + " unsafe {", + " ResetEvent(event.as_raw_handle());", + " }", + " #[cfg(test)]", + " crate::race_hooks::CLEAR.run();", + " self.signalled.store(false, Ordering::Release);" + ], + "replace": [ + " self.signalled.store(false, Ordering::Release);", + " #[cfg(test)]", + " crate::race_hooks::CLEAR.run();", + " unsafe {", + " ResetEvent(event.as_raw_handle());", + " }" + ] + }, { "name": "mpsc push does not signal the doorbell", "file": "src/mpsc.rs", @@ -236,7 +258,7 @@ "find": [ " self.shared.doorbell.clear();", " #[cfg(test)]", - " crate::arm_race::run();", + " crate::race_hooks::ARM.run();", " // Deliberately not `is_empty`. The question is whether `pop` would find", " // something, and a slot that a producer has claimed but not published", " // is not something `pop` can find -- see `Shared::has_ready_item`.", @@ -246,7 +268,7 @@ " let ready = self.shared.has_ready_item();", " self.shared.doorbell.clear();", " #[cfg(test)]", - " crate::arm_race::run();", + " crate::race_hooks::ARM.run();", " Ok(!ready)" ] }, diff --git a/crates/windows-waitable-queues/src/arm_race.rs b/crates/windows-waitable-queues/src/arm_race.rs deleted file mode 100644 index 46b10c9f..00000000 --- a/crates/windows-waitable-queues/src/arm_race.rs +++ /dev/null @@ -1,56 +0,0 @@ -// Copyright (c) Mike Grier. - -//! Test-only: the hook that drives `arm` through its own race window. -//! -//! `Consumer::arm` clears the doorbell and *then* checks whether anything is -//! takeable. The reverse order reads more naturally and is a permanent hang, -//! so the correct order has to be proven rather than asserted -- which means a -//! test must place a push inside the window between those two statements. -//! -//! # Why a hook rather than a hand-written copy of `arm` -//! -//! The first attempt at this proof was a duplicate of `arm` with the two -//! statements swapped, driven deterministically. It could only ever show that -//! *a* reversed order is wrong; it was structurally incapable of noticing the -//! **real** `arm` being reversed, which left that case covered only by whatever -//! interleavings the scheduler happened to produce. Measured: sabotaging the -//! real `arm` was then caught in one run out of three, because detection -//! depended on two threads meeting inside a window tens of nanoseconds wide. -//! -//! A second copy of a rule checks the copy, not the rule. So the real `arm` -//! carries this hook, and a test drives the real code through the exact window -//! on one thread. -//! -//! # Why it is shared between the shapes -//! -//! Both bounded shapes implement the same protocol, and each needs the same -//! proof. Giving each its own hook would reintroduce the duplication this file -//! exists to avoid, one layer down. The hook is thread-local, so two shapes' -//! tests running concurrently in one process cannot see each other's. - -use core::cell::RefCell; - -thread_local! { - static HOOK: RefCell>> = const { RefCell::new(None) }; -} - -/// Runs the installed hook, if any. Called from inside `arm`. -pub(crate) fn run() { - HOOK.with(|hook| { - // Taken out for the call rather than held borrowed across it, so a hook - // that touches the queue cannot trip a `RefCell` re-entrancy panic. - let taken = hook.borrow_mut().take(); - if let Some(mut race) = taken { - race(); - *hook.borrow_mut() = Some(race); - } - }); -} - -/// Installs a hook for the duration of a closure. -pub(crate) fn with(race: impl FnMut() + 'static, body: impl FnOnce() -> R) -> R { - HOOK.with(|hook| *hook.borrow_mut() = Some(Box::new(race))); - let result = body(); - HOOK.with(|hook| *hook.borrow_mut() = None); - result -} diff --git a/crates/windows-waitable-queues/src/doorbell.rs b/crates/windows-waitable-queues/src/doorbell.rs index 1289842f..8878717b 100644 --- a/crates/windows-waitable-queues/src/doorbell.rs +++ b/crates/windows-waitable-queues/src/doorbell.rs @@ -122,12 +122,42 @@ //! 7.2 ns for an uncontended atomic, so a backlogged producer that would //! otherwise pay a syscall per push pays roughly a tenth of one. //! +//! # The flag must never outlive the signal it mirrors +//! //! The flag is allowed to disagree with the event briefly, and that is sound in -//! exactly one direction: it may claim signalled while the `SetEvent` has not -//! landed yet, which costs a skipped redundant signal, never a skipped -//! necessary one. It is never permitted to claim clear while the event is -//! signalled in a way that matters, because [`Doorbell::clear`] writes the flag -//! before touching the event. +//! exactly one direction: it may claim **signalled while the `SetEvent` has not +//! landed yet**, which costs a skipped redundant signal, never a skipped +//! necessary one. The opposite disagreement -- the flag claiming signalled over +//! an event that is *dark* -- is fatal, because every later [`Doorbell::signal`] +//! then skips its syscall and the doorbell can never be lit again. +//! +//! **[`Doorbell::clear`] therefore resets the event first and clears the flag +//! second, and that order is load-bearing.** Written the other way round -- flag +//! first, `ResetEvent` second, which is how this shipped originally -- a +//! producer signalling between the two lines finds a clear flag, sets it, and +//! issues a real `SetEvent`; the `ResetEvent` that follows then erases that +//! signal while leaving the flag set. The doorbell is wedged dark with the flag +//! claiming otherwise, and the next producer to publish skips the one signal +//! that mattered. +//! +//! The original argument for the other order was that the caller's re-check +//! covers it: a producer racing the clear publishes *before* it signals, so the +//! re-check sees the item and the caller does not wait. **That argument is +//! sound only when the re-check is guaranteed to see anything that producer +//! published**, and it silently assumed a queue whose emptiness is a single +//! position comparison. `mpsc` broke the assumption -- its re-check asks whether +//! the *head* slot is published, so a producer publishing at a later position +//! is invisible to it, and the consumer parks in exactly the wedged state above. +//! The failure was a rare permanent hang, reproduced once in a sabotage +//! baseline and then not again in six runs. +//! +//! With the reset first, the invariant is a property of this type rather than a +//! property of its callers: **once `clear` returns, the flag is false, so the +//! next `signal` cannot be skipped.** A producer signalling inside the window +//! may still be skipped, but it published before it signalled and therefore +//! before the flag store, so the caller's re-check -- which follows -- observes +//! whatever that publication made observable, and any producer that publishes +//! *after* the re-check finds the flag already false and rings for real. use std::io; use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}; @@ -268,23 +298,27 @@ impl Doorbell { let Some(event) = self.event.get() else { return; }; - // Written before the event is reset, so a producer racing this call - // sees a clear flag and issues a real `SetEvent`. That signal may then - // be erased by the `ResetEvent` below -- which is precisely why the - // caller's re-check, and not this ordering, is what carries the - // guarantee. - self.signalled.store(false, Ordering::Release); + + // **The event is reset first and the flag second, and swapping these + // two lines is a permanent hang.** See "the flag must never outlive the + // signal it mirrors" in the module documentation; the short form is + // that a producer signalling between them must never be able to leave + // the flag claiming "lit" over an event this call is about to darken. + // // SAFETY: as in `signal`. unsafe { ResetEvent(event.as_raw_handle()); } + #[cfg(test)] + crate::race_hooks::CLEAR.run(); + self.signalled.store(false, Ordering::Release); // The other half of the pair described in `signal`. The caller's - // emptiness re-check is a LOAD of the queue's position, and it follows - // this store of `signalled`; without a sequentially consistent fence on - // both sides, that load and the producer's load of `signalled` may both - // observe stale values, which is the lost wakeup. `ResetEvent` above is - // very probably a barrier in its own right, but that is an incidental + // re-check is a LOAD of the queue's state, and it follows this store of + // `signalled`; without a sequentially consistent fence on both sides, + // that load and the producer's load of `signalled` may both observe + // stale values, which is the lost wakeup. `ResetEvent` above is very + // probably a barrier in its own right, but that is an incidental // property of an implementation rather than a documented guarantee, so // it is not what this relies on. fence(Ordering::SeqCst); diff --git a/crates/windows-waitable-queues/src/doorbell/tests.rs b/crates/windows-waitable-queues/src/doorbell/tests.rs index 27fa726b..ab58ec03 100644 --- a/crates/windows-waitable-queues/src/doorbell/tests.rs +++ b/crates/windows-waitable-queues/src/doorbell/tests.rs @@ -8,11 +8,13 @@ //! statement about a queue this type cannot see; that is `spsc`'s job. use std::os::windows::io::AsRawHandle; +use std::sync::Arc; use windows_sys::Win32::Foundation::{WAIT_OBJECT_0, WAIT_TIMEOUT}; use windows_sys::Win32::System::Threading::WaitForSingleObject; use super::Doorbell; +use crate::race_hooks; /// Whether the doorbell is signalled right now, by asking the kernel rather /// than by reading the mirror flag. @@ -313,3 +315,67 @@ fn a_waiting_thread_is_released_by_a_signal() { "a blocked waiter must be released by a signal, not by the timeout" ); } + +// --------------------------------------------------------------------------- +// The flag must never outlive the signal it mirrors. +// +// `clear` resets the event and *then* clears the flag. Written the other way +// round -- which is how this shipped originally -- a producer signalling +// between the two lines finds a clear flag, sets it, and issues a real +// `SetEvent`; the `ResetEvent` that follows erases that signal and leaves the +// flag set. The doorbell is then wedged dark while claiming to be lit, and +// every later `signal` skips its syscall. +// +// The window is two instructions wide, so the race is driven through the real +// `clear` by a hook rather than raced for on two threads: an interleaving that +// must be hit to prove a point is not one to leave to the scheduler. +// --------------------------------------------------------------------------- + +#[test] +fn a_signal_racing_a_clear_leaves_the_next_one_able_to_ring() { + // Shared rather than borrowed because the hook must be `'static`. One + // thread throughout -- the `Arc` is a lifetime device, not concurrency. + let doorbell = Arc::new(Doorbell::new()); + doorbell.handle().expect("the doorbell must be creatable"); + + // Start from the state that makes the wrong order fatal: already lit, so a + // producer racing the clear can find the flag either way depending on the + // order of the two lines. + doorbell.signal(); + assert!(is_signalled(&doorbell), "the setup must actually light it"); + + let racing = Arc::clone(&doorbell); + race_hooks::CLEAR.with(move || racing.signal(), || doorbell.clear()); + + assert!( + !is_signalled(&doorbell), + "clearing must darken the event even when a signal raced it" + ); + + // The assertion that matters, and the one the wrong order fails: whatever + // happened during the window, `clear` must leave the doorbell able to ring + // again. A queue's consumer parks immediately after this returns, and its + // wakeup is the next producer's `signal`. + doorbell.signal(); + assert!( + is_signalled(&doorbell), + "a signal racing a clear must not wedge the doorbell dark; the flag \ + would be claiming 'already lit' over an event nothing will ever set" + ); +} + +#[test] +fn a_clear_with_nothing_racing_it_still_re_arms() { + // The control for the test above: it must not pass merely because `clear` + // never leaves the doorbell ringable, so the same sequence is checked with + // an empty window. + let doorbell = Arc::new(Doorbell::new()); + doorbell.handle().expect("the doorbell must be creatable"); + doorbell.signal(); + + race_hooks::CLEAR.with(|| {}, || doorbell.clear()); + assert!(!is_signalled(&doorbell), "nothing raced it, so it is dark"); + + doorbell.signal(); + assert!(is_signalled(&doorbell), "and the next signal rings"); +} diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 28efd04e..5ec12b86 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -58,13 +58,13 @@ #![warn(missing_docs)] #![warn(unsafe_op_in_unsafe_fn)] -#[cfg(test)] -mod arm_race; mod blocking; mod capacity; mod doorbell; mod error; pub mod mpsc; +#[cfg(test)] +mod race_hooks; pub mod spsc; pub mod traits; diff --git a/crates/windows-waitable-queues/src/mpsc.rs b/crates/windows-waitable-queues/src/mpsc.rs index 91dda378..9c5b36f6 100644 --- a/crates/windows-waitable-queues/src/mpsc.rs +++ b/crates/windows-waitable-queues/src/mpsc.rs @@ -671,13 +671,28 @@ impl Consumer { /// of waiting. /// /// The order inside this method is the whole correctness argument, and it - /// is the reverse of the one that reads naturally. Clearing *first* and - /// checking *second* is what makes a lost wakeup impossible: an item that - /// arrives before the clear is found by the check, and an item that arrives - /// after the clear signals a doorbell that is no longer about to be reset. - /// Checking first would leave a window in which a push both signals and has - /// its signal erased, and the consumer would sleep on a queue that is not - /// empty and will never be signalled again. + /// is the reverse of the one that reads naturally. Checking first would + /// leave a window in which a push both signals and has its signal erased, + /// and the consumer would sleep on a queue that is not empty and will never + /// be signalled again. + /// + /// Clearing first splits every push into two cases, and this shape's + /// division is **not** the one `spsc` uses -- the difference is why + /// [`Doorbell::clear`](crate::doorbell::Doorbell::clear) had to be + /// corrected before this shape was sound: + /// + /// - **A push that publishes at the head before the clear** is found by the + /// check, so the caller does not wait. + /// - **Every other push** -- one that publishes after the clear, and one + /// that publishes at a *later position* before it -- leaves the check + /// finding nothing, and the caller waits. That is safe because the head + /// position is then still owed a publication, and `clear` guarantees the + /// doorbell can ring again when it comes. + /// + /// The second case is the one that has no counterpart in `spsc`, where any + /// push at all makes the check find something. It is why `clear` must reset + /// the event *before* clearing the flag that mirrors it, rather than + /// relying on this check to cover the window. /// /// This also creates the doorbell if it does not exist, which must happen /// before the check for the same reason: a producer running while there is @@ -692,7 +707,7 @@ impl Consumer { self.shared.doorbell.handle()?; self.shared.doorbell.clear(); #[cfg(test)] - crate::arm_race::run(); + crate::race_hooks::ARM.run(); // Deliberately not `is_empty`. The question is whether `pop` would find // something, and a slot that a producer has claimed but not published // is not something `pop` can find -- see `Shared::has_ready_item`. diff --git a/crates/windows-waitable-queues/src/mpsc/tests.rs b/crates/windows-waitable-queues/src/mpsc/tests.rs index 742ceb9a..e14f53c4 100644 --- a/crates/windows-waitable-queues/src/mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/mpsc/tests.rs @@ -15,7 +15,7 @@ //! and evidence of nothing either way. use super::{Consumer, MIN_CAPACITY, Producer, bounded, validate_capacity}; -use crate::arm_race; +use crate::race_hooks; use crate::{PushError, RecvError, RecvTimeoutError}; use std::collections::BTreeMap; use std::os::windows::io::AsRawHandle; @@ -768,7 +768,7 @@ fn the_real_arm_finds_an_item_that_lands_inside_its_window() { // The hook owns the producer outright. Sharing one behind an `Arc` would be // pointless: a `Producer` is deliberately `!Sync`. - let safe_to_wait = arm_race::with( + let safe_to_wait = race_hooks::ARM.with( move || { tx.push(1).expect("there is room"); }, @@ -788,7 +788,7 @@ fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { let (_tx, rx) = bounded::(4).expect("4 is a valid capacity"); rx.doorbell().expect("the doorbell must be creatable"); - let safe_to_wait = arm_race::with(|| {}, || rx.arm().expect("arming must succeed")); + let safe_to_wait = race_hooks::ARM.with(|| {}, || rx.arm().expect("arming must succeed")); assert!( safe_to_wait, "nothing arrived, so waiting is exactly what the consumer should do" diff --git a/crates/windows-waitable-queues/src/race_hooks.rs b/crates/windows-waitable-queues/src/race_hooks.rs new file mode 100644 index 00000000..c9b7a1a6 --- /dev/null +++ b/crates/windows-waitable-queues/src/race_hooks.rs @@ -0,0 +1,80 @@ +// Copyright (c) Mike Grier. + +//! Test-only: hooks that drive a two-statement sequence through its own race +//! window, deterministically and on one thread. +//! +//! # Why these exist at all +//! +//! Two places in this crate consist of two statements whose *order* is the +//! whole correctness argument, and whose wrong order is a permanent hang rather +//! than an occasional stall: `Consumer::arm` and [`Doorbell::clear`]. Proving +//! such an order is load-bearing means placing a racing operation strictly +//! between the two statements, and that is not an interleaving a scheduler can +//! be asked for -- the window is tens of nanoseconds wide. +//! +//! # Why a hook rather than a hand-written copy of the code +//! +//! The first attempt at proving `arm` was a duplicate of it with the two +//! statements swapped, driven deterministically. It could only ever show that +//! *a* reversed order is wrong; it was structurally incapable of noticing the +//! **real** `arm` being reversed, which left that case covered only by +//! whatever interleavings the scheduler happened to produce. Measured: +//! sabotaging the real `arm` was then caught in one run out of three. +//! +//! A second copy of a rule is a check of the copy, not of the rule. So the real +//! code carries the hook, and a test drives the real code through the exact +//! window. +//! +//! # Why one facility for both +//! +//! Giving each site its own thread-local would reintroduce, one layer down, the +//! duplication this file exists to avoid. The hooks are thread-local, so two +//! suites running concurrently in one process cannot see each other's. +//! +//! [`Doorbell::clear`]: crate::doorbell::Doorbell::clear + +use core::cell::RefCell; +use std::thread::LocalKey; + +type Slot = RefCell>>; + +thread_local! { + static ARM_HOOK: Slot = const { RefCell::new(None) }; + static CLEAR_HOOK: Slot = const { RefCell::new(None) }; +} + +/// One named race window. +pub(crate) struct Hook(&'static LocalKey); + +/// Fires inside `Consumer::arm`, between clearing the doorbell and checking +/// whether anything is takeable. +pub(crate) const ARM: Hook = Hook(&ARM_HOOK); + +/// Fires inside [`Doorbell::clear`](crate::doorbell::Doorbell::clear), between +/// resetting the event and clearing the flag that mirrors it. +pub(crate) const CLEAR: Hook = Hook(&CLEAR_HOOK); + +impl Hook { + /// Runs the installed hook, if any. Called from the code under test. + pub(crate) fn run(&self) { + self.0.with(|hook| { + // Taken out for the call rather than held borrowed across it, so a + // hook that re-enters this window cannot trip a `RefCell` + // re-entrancy panic. + let taken = hook.borrow_mut().take(); + if let Some(mut race) = taken { + race(); + *hook.borrow_mut() = Some(race); + } + }); + } + + /// Installs a hook for the duration of a closure. + pub(crate) fn with(&self, race: impl FnMut() + 'static, body: impl FnOnce() -> R) -> R { + self.0 + .with(|hook| *hook.borrow_mut() = Some(Box::new(race))); + let result = body(); + self.0.with(|hook| *hook.borrow_mut() = None); + result + } +} diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 3fa7b298..54751a89 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -477,10 +477,17 @@ impl Consumer { /// is the reverse of the one that reads naturally. Clearing *first* and /// checking emptiness *second* is what makes a lost wakeup impossible: an /// item that arrives before the clear is found by the check, and an item - /// that arrives after the clear signals a doorbell that is no longer about - /// to be reset. Checking first would leave a window in which a push both - /// signals and has its signal erased, and the consumer would sleep on a - /// queue that is not empty and will never be signalled again. + /// that arrives after the clear signals a doorbell that + /// [`clear`](crate::doorbell::Doorbell::clear) has left able to ring. + /// Checking first would leave a window in which a push both signals and has + /// its signal erased, and the consumer would sleep on a queue that is not + /// empty and will never be signalled again. + /// + /// The first of those two cases is stronger here than it is for + /// [`mpsc`](crate::mpsc): there is one producer and one position, so *any* + /// push before the clear makes this check find something. That is why this + /// shape never exhibited the doorbell defect `mpsc` exposed, and why the + /// fix for it belongs to the doorbell rather than to either caller. /// /// This also creates the doorbell if it does not exist, which must happen /// before the emptiness check for the same reason: a producer running while @@ -495,7 +502,7 @@ impl Consumer { self.shared.doorbell.handle()?; self.shared.doorbell.clear(); #[cfg(test)] - crate::arm_race::run(); + crate::race_hooks::ARM.run(); Ok(self.is_empty()) } diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index c09ec029..31cdd225 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -7,7 +7,7 @@ //! runs after the peer has finished, not after a guess about how long it takes. use super::{Consumer, MIN_CAPACITY, Producer, bounded, validate_capacity}; -use crate::arm_race; +use crate::race_hooks; use crate::{PushError, RecvError, RecvTimeoutError}; use std::os::windows::io::AsRawHandle; use std::sync::Arc; @@ -913,7 +913,7 @@ fn the_real_arm_finds_an_item_that_lands_inside_its_window() { // The hook owns the producer outright. An `Arc` would be pointless here and // clippy says so: a `Producer` is deliberately `!Sync`, so sharing one is // exactly what the type system is built to prevent. - let safe_to_wait = arm_race::with( + let safe_to_wait = race_hooks::ARM.with( move || { tx.push(1).expect("there is room"); }, @@ -934,7 +934,7 @@ fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { rx.doorbell().expect("the doorbell must be creatable"); drop(tx); - let safe_to_wait = arm_race::with(|| {}, || rx.arm().expect("arming must succeed")); + let safe_to_wait = race_hooks::ARM.with(|| {}, || rx.arm().expect("arming must succeed")); assert!( safe_to_wait, From 8dbefab35dc162317feaac43817b666cfbbd0f69 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sun, 30 Aug 2026 23:08:12 -0400 Subject: [PATCH 026/361] test(waitable-queues): stop the clear-race test asserting the signal-skip optimisation, and re-anchor three stale sabotages The sweep's own control caught the new test asserting the implementation: a signal that races a clear is entitled to leave the event lit, because that is a spurious wakeup and consumers tolerate those by contract. The assertion that carries the test is the one after it -- that the next signal can still ring -- and that one is what fails on the reversed order. Three anchors went stale under this milestone's own edits: two in error.rs from adding min_valid, and recv_timeout's from moving into blocking.rs, where it is a free function and so indented four spaces rather than eight. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-waitable-queues/sabotage.json | 9 +++++---- .../windows-waitable-queues/src/doorbell/tests.rs | 13 +++++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json index 8fbbba86..416925f7 100644 --- a/crates/windows-waitable-queues/sabotage.json +++ b/crates/windows-waitable-queues/sabotage.json @@ -115,7 +115,8 @@ "expect": "caught", "why": "Rounding a request down to the nearest power of two gives 2^63 for anything at or above it, which exceeds the largest representable capacity. The suggestion exists so a caller can correct the call, and one that is itself refused is worse than none.", "find": [ - " Some(rounded.min(self.largest_power_of_two_within_bound()))" + " let clamped = rounded.min(self.largest_power_of_two_within_bound());", + " (clamped >= self.min_valid).then_some(clamped)" ], "replace": [ " Some(rounded)" @@ -127,7 +128,7 @@ "expect": "caught", "why": "A request that is merely not a power of two can still sit between the largest valid power of two and the bound, and rounding it up overshoots. Found by the test written for previous_valid, which the review had not flagged -- the reviewer checked next_valid only on the TooLarge path.", "find": [ - " (rounded <= self.max_valid).then_some(rounded)" + " (rounded >= self.min_valid && rounded <= self.max_valid).then_some(rounded)" ], "replace": [ " Some(rounded)" @@ -139,10 +140,10 @@ "expect": "caught", "why": "`Instant + Duration` panics when the sum is not representable, and Duration::MAX is an ordinary way to spell 'effectively forever'. The rest of the function is careful about exactly this class of problem, which is what made the panicking operator easy to miss. Moved here from src/spsc.rs when the blocking receive loop was extracted so both shapes bind to one copy of the arming protocol; there is now exactly one site, and both shapes' suites indict it.", "find": [ - " let Some(deadline) = Instant::now().checked_add(timeout) else {" + " let Some(deadline) = Instant::now().checked_add(timeout) else {" ], "replace": [ - " let Some(deadline) = Some(Instant::now() + timeout) else {" + " let Some(deadline) = Some(Instant::now() + timeout) else {" ] }, { diff --git a/crates/windows-waitable-queues/src/doorbell/tests.rs b/crates/windows-waitable-queues/src/doorbell/tests.rs index ab58ec03..659a2bc5 100644 --- a/crates/windows-waitable-queues/src/doorbell/tests.rs +++ b/crates/windows-waitable-queues/src/doorbell/tests.rs @@ -347,10 +347,15 @@ fn a_signal_racing_a_clear_leaves_the_next_one_able_to_ring() { let racing = Arc::clone(&doorbell); race_hooks::CLEAR.with(move || racing.signal(), || doorbell.clear()); - assert!( - !is_signalled(&doorbell), - "clearing must darken the event even when a signal raced it" - ); + // Nothing is asserted about the event's state right here, and the omission + // is deliberate. Whether the racing signal survived the clear depends on + // whether it was skipped, which depends on the flag optimisation -- and a + // signal that races a clear is entitled to leave the event lit, because + // that is a spurious wakeup and consumers tolerate those by contract. An + // earlier version of this test did assert it, and the sabotage sweep's + // control -- "signal always syscalls, skipping the flag optimisation" -- + // caught it, which is exactly what that control exists to do: it reported + // this test as asserting the implementation instead of the contract. // The assertion that matters, and the one the wrong order fails: whatever // happened during the window, `clear` must leave the doorbell able to ring From 0214c3bb34fb809182af32f91b5914c768531252 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sun, 30 Aug 2026 23:19:12 -0400 Subject: [PATCH 027/361] docs: record the doorbell finding in M31.1's done-note and unstick PLANS.md The checklist is the record of what a milestone actually cost, and the doorbell lost wakeup is the most valuable thing this one produced -- including how it was found, which was a sabotage baseline hanging once rather than any test or review. PLANS.md also still said 'not started' for a checklist with five items done, and still described the doorbell invariant in the pre-D-9 wording that said the reset had to be atomic with the emptiness observation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 20 ++++++++++++++++++-- PLANS.md | 2 +- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 3a238ca8..76c27c95 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -214,8 +214,24 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m safe but spins until that producer is rescheduled. Arming asks the readiness question instead, which is also what puts D-9's `SeqCst` pair on the right two locations for this shape ([D-14](crates/windows-waitable-queues/DESIGN-NOTES.md#d-14)). - 120 unit tests and 4 doctests, the whole suite in 0.31s. Nine new sabotage entries, one of them a - control. + **This shape exposed a lost wakeup in the doorbell that `spsc` could not have found, and it was fixed + at the layer that owns it.** `Doorbell::clear` cleared its mirror flag and *then* reset the event; a + producer signalling between those two lines set the flag and issued a real `SetEvent`, and the + `ResetEvent` that followed erased the signal while leaving the flag set -- so the doorbell was dark + while claiming to be lit and every later signal skipped. The order had a written argument behind it + ("the caller's re-check sees the racing producer's item") that is **true for `spsc` and false for + `mpsc`**, whose re-check asks only whether the *head* slot is published. The fix moves the guarantee + from the caller to the type: once `clear` returns the flag is false, so no future shape has to have a + re-check strong enough to cover the window. [D-15](crates/windows-waitable-queues/DESIGN-NOTES.md#d-15), + which amends D-9 rather than being filed beside it. + **It was found by the sabotage harness refusing to sweep against a red baseline** -- the baseline run, + whose only job is to prove the suite is green before any defect is injected, hung once in a suite that + passed 120 tests in 0.28s six runs running. A single unreproducible hang is the finding it is tempting + to blame on a busy machine. + 122 unit tests and 4 doctests, the whole suite in 0.30s. Twenty-three sabotages, all behaving as + declared: ten new ones for this milestone, two of them controls. **One of those controls earned its + keep immediately** -- it caught the new doorbell test asserting the signal-skip *optimisation* rather + than the contract, which is precisely what a control is for. - [ ] **M31.2** -- Overflow policy, which is more than "return `Err`". Ship fail-fast plus a `reserve` that guarantees a slot for a message that must not be lost, following diff --git a/PLANS.md b/PLANS.md index ebb56f56..1b06d36d 100644 --- a/PLANS.md +++ b/PLANS.md @@ -17,7 +17,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| -| [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | not started | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset is atomic with the emptiness observation), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | +| [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | not started | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | From c007c57af30796825bfeca80d97f14734bcfd66b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sun, 30 Aug 2026 23:51:01 -0400 Subject: [PATCH 028/361] feat(waitable-queues): add reserve, and a reserving MPSC shape beside the plain one Overflow policy is more than returning Err. Fail-fast was already shipped; this adds the other half -- a slot claimed in advance, so a message that must not be lost cannot be refused for want of room. Reserved is guaranteed, unreserved is best-effort. The multi-producer case forced a design decision. Honouring a reservation means knowing how many slots remain, which costs a producer a read of the consumer's position on every push -- one line every thread touches. mpsc's push avoids that read by design: it asks the slot's own sequence "are you free", which is dispersed across the slot array. So mpsc genuinely cannot answer the question, and rather than make every caller pay for a capability not every caller wants, reserving_mpsc ships beside it as a peer. mpsc is untouched, and remains the baseline M31.5 measures. The reservation count and the claim position share one 64-bit word, and that is the correctness argument rather than tidiness. With the count in its own atomic, a pushing producer reads it and then writes the position while a reserving one writes it and then reads the position; each can miss the other, and the queue grants a slot that does not exist. SeqCst fences do not close it -- the Dekker argument needs store-then-load on both sides and the pusher is load-then-store. Two claimants on one resource must synchronise on one location. The 32/32 split is forced, not chosen: a position of b bits keeps a wrapping difference unambiguous to 2^(b-1), and the count needs b bits because it can reach the capacity. So this shape caps at 2^31 items, reported through the same per-shape bound D-12 introduced for the minimum. A 128-bit CAS would lift the cap and is refused: it does not remove the read that costs, needs a dependency this workspace does not have, is not in the x86-64 baseline, and is not even the same instruction on the ARM64 machine every measurement here is taken on. spsc gets reserve too, nearly free -- one producer means reserve and push are the same thread. Its reservation borrows the producer rather than owning it, because there the handle IS the single-producer guarantee and an owned reservation could outlive it on another thread. That difference is why Reserving's associated type is generic over a lifetime, and it is D-3's rule doing its job: the trait was shaped by two implementations rather than around one. Two defects the work surfaced, both caught rather than reasoned about: - Reservation::send used mem::forget to suppress the double-release, which leaks the Arc the reservation holds, so the shared state was never dropped and every item still in the ring leaked with it. Found by the drop-counting test. - The first const assertions guarding the packing were tautological -- asserting BOUNDS_MAX equals its own definition. Widening the position to 40 bits sailed past them while silently narrowing the reservation field to 24. Rewritten to assert the constraint that actually binds, and verified by sabotage: both a too-wide and a too-narrow split now fail the build with the right message. Completed item: M31.2: Overflow policy, which is more than "return `Err`". Ship fail-fast plus a `reserve` that guarantees a slot for a message that must not be lost, following queue.rs, which already carries three policies including a coalesced loss latch the consumer is guaranteed to observe. Never offer overwrite-oldest: for telemetry that is a lost sample, but for an I/O submission it is a lost operation, and the two must not share a policy knob. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 56 +- .../windows-waitable-queues/DESIGN-NOTES.md | 150 +++ crates/windows-waitable-queues/README.md | 30 +- crates/windows-waitable-queues/sabotage.json | 136 ++- .../windows-waitable-queues/src/capacity.rs | 92 +- crates/windows-waitable-queues/src/error.rs | 97 +- crates/windows-waitable-queues/src/lib.rs | 5 +- crates/windows-waitable-queues/src/mpsc.rs | 19 +- .../windows-waitable-queues/src/mpsc/tests.rs | 14 +- .../src/reserving_mpsc.rs | 1084 +++++++++++++++++ .../src/reserving_mpsc/tests.rs | 758 ++++++++++++ crates/windows-waitable-queues/src/spsc.rs | 269 +++- .../windows-waitable-queues/src/spsc/tests.rs | 14 +- crates/windows-waitable-queues/src/traits.rs | 77 ++ 14 files changed, 2653 insertions(+), 148 deletions(-) create mode 100644 crates/windows-waitable-queues/src/reserving_mpsc.rs create mode 100644 crates/windows-waitable-queues/src/reserving_mpsc/tests.rs diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 76c27c95..654d1fa5 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -233,12 +233,55 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m keep immediately** -- it caught the new doorbell test asserting the signal-skip *optimisation* rather than the contract, which is precisely what a control is for. -- [ ] **M31.2** -- Overflow policy, which is more than "return `Err`". Ship fail-fast plus a `reserve` +- [x] **M31.2** -- Overflow policy, which is more than "return `Err`". Ship fail-fast plus a `reserve` that guarantees a slot for a message that must not be lost, following [queue.rs](crates/windows-file-watcher/src/queue.rs), which already carries three policies including a **coalesced loss latch** the consumer is guaranteed to observe. **Never offer overwrite-oldest**: for telemetry that is a lost sample, but for an I/O submission it is a lost operation, and the two must not share a policy knob. + **Done, and the multi-producer case forced a decision the item did not anticipate.** Honouring a + reservation means knowing how many slots remain, which means reading the consumer's position -- one + line every thread touches -- on *every* push, including the pushes that never reserve anything. + `mpsc`'s producer avoids that read by design: it asks the slot's own sequence "are you free", and those + are dispersed across the slot array. So `mpsc` genuinely cannot answer the reservation question, and + rather than charge every caller for a capability not every caller wants, **`reserving_mpsc` ships as a + peer and `mpsc` is untouched** ([D-16](crates/windows-waitable-queues/DESIGN-NOTES.md#d-16)). The + engineer chose this split over the alternatives when it was raised. + **The reservation count and the claim position share one 64-bit word, and that is the correctness + argument rather than tidiness** ([D-17](crates/windows-waitable-queues/DESIGN-NOTES.md#d-17)). With the + count in its own atomic, a pushing producer reads it then writes the position while a reserving one + writes it then reads the position, and each can miss the other -- granting a slot that does not exist. + **`SeqCst` fences do not close this**, unlike the superficially identical hazard in D-9: the Dekker + argument needs store-then-load on both sides and the pusher is load-then-store, so both sides missing + each other is consistent with every total order. The 32/32 split is forced by the arithmetic and caps + the shape at 2^31 items, reported through the same per-shape bound D-12 introduced for the minimum. + Two consequences worth noting: redeeming is a single exchange that moves both halves, so + `occupied + reserved` is never momentarily wrong; and the producer stops needing the slot sequence for + the "free" direction, so this shape's `pop` is one store *shorter* than `mpsc`'s. + **A 128-bit compare-and-swap was raised and refused** + ([D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18)): it lifts the cap and nothing else, since + the consumer's position still has to be read, and it costs a new dependency, a target-feature floor not + in the x86-64 baseline, and a different instruction on the ARM64 machine every measurement here is + taken on. Recorded with the case that would revive it -- a tagged pointer, which is what M-inf.1's + linked and sharded shapes would need. + **`spsc` reserves too, nearly free**, since one producer means `reserve` and `push` are the same + thread. Its reservation *borrows* the producer where `reserving_mpsc`'s is owned and `Send`, because + there the handle **is** the single-producer guarantee and an owned reservation could outlive it on + another thread. That difference is why `Reserving`'s associated type is generic over a lifetime, and it + is D-3 working: the trait was shaped by two implementations rather than around one. + **The loss latch is deliberately not generalised, and the reason is recorded rather than skipped** + ([D-19](crates/windows-waitable-queues/DESIGN-NOTES.md#d-19)). Coalescing works in the file watcher + because a desync is *idempotent*; a queue of arbitrary `T` has no such property. What generalises is a + loss *count*, which is M31.4's observability rather than an overflow policy. + **Two defects surfaced, both caught rather than reasoned about.** `Reservation::send` used + `mem::forget` to suppress its double-release, which leaks the `Arc` the reservation holds -- so the + shared state was never dropped and every item still in the ring leaked with it; found by the + drop-counting test. And the first `const` assertions guarding the packing were **tautological**, + asserting that `BOUNDS_MAX` equalled its own definition; widening the position to 40 bits sailed past + them while silently narrowing the count's field to 24, which is how the packing actually breaks. Both + rewritten, and the assertions verified by sabotage -- a too-wide and a too-narrow split each now fail + the build with the right message. + 155 unit tests and 5 doctests, the whole suite in 0.31s. - [ ] **M31.3** -- Shutdown in both directions: the consumer learns when every producer is gone, and a producer learns when the consumer is gone and fails with a typed error. Descriptors in flight at @@ -258,6 +301,17 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m Record the result either way -- a measurement that says "the simple thing is fine" is worth as much as one that does not, and is the cheaper outcome to lose track of. + **Also measure `reserving_mpsc` against `mpsc`, and decide their merge-or-delete here.** M31.2 shipped + them as two shapes because reservation costs the producer a read of the consumer's position on every + push, and *how much* that costs was a judgement rather than a measurement + ([D-16](crates/windows-waitable-queues/DESIGN-NOTES.md#d-16)). This benchmark already stands up N + producers against a tail, so measuring both under the same harness is nearly free. + The decision it forces: if the shared-line read turns out to be cheap at realistic contention, the two + shapes **merge** and the non-reserving one goes; if it is expensive, both stay and the split is + vindicated. This item exists because a duplicated path silently becoming permanent -- because nobody + circled back -- is the failure mode the duplicate-then-decide rule actually warns about, and an + intention recorded only in a design note is not scheduled work. + - [ ] **M31.6** -- Verify the memory orderings with a model checker, because stress testing demonstrably cannot. **Measured, not assumed:** during M30.3's sabotage sweep, weakening the producer's `Acquire` load of `head` to `Relaxed` left all twenty tests green, while every *logic* defect injected alongside diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 191dc27b..7cdb1bc6 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -39,6 +39,10 @@ preferred. | D-13 | **The arming protocol is written once, in `blocking.rs`, and a shape binds to it by implementing a crate-private `Parked` trait.** The blocking receive loop *is* [D-9](#d-9), not glue around it; a second shape spelling it out again would be a second copy of a rule -- the exact mistake this crate has already paid for once. | | D-14 | **`mpsc`'s arming asks "would `pop` find something", not "is `len` zero".** The two disagree over a slot a producer has claimed but not published, and only the first answer lets the consumer park on it instead of spinning until that producer is rescheduled. | | D-15 | **`Doorbell::clear` resets the event *before* clearing the flag that mirrors it, and the original order was a lost wakeup.** A producer signalling between the two lines set the flag and issued a real `SetEvent`; the `ResetEvent` that followed erased the signal and left the flag set, wedging the doorbell dark while it claimed to be lit. **Amends [D-9](#d-9)**, whose "there is no third case" holds only for a queue whose emptiness is one position comparison. | +| D-16 | **Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `mpsc` rather than replacing it.** Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `mpsc`'s push deliberately never reads. Rather than charge every caller for a capability not every caller wants, both ship. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. || D-17 | **The reservation count and the claim position live in one word, because a check-and-claim over both must be a single atomic operation.** Two atomics cannot be made correct with any amount of fencing: the pushing producer is load-then-store and the reserving one store-then-load, so the Dekker argument does not apply and both can miss each other. The 32/32 split is forced by the arithmetic, and caps this shape at 2^31 items. | +| D-17 | **The reservation count and the claim position live in one word, because a check-and-claim over both must be a single atomic operation.** Two atomics cannot be made correct with any amount of fencing: the pushing producer is load-then-store and the reserving one store-then-load, so the Dekker argument does not apply and both can miss each other. The 32/32 split is forced by the arithmetic, and caps this shape at 2^31 items. | +| D-18 | **A 128-bit compare-and-swap is refused.** It would lift the 2^31 cap and nothing else -- the consumer's position still has to be read -- at the cost of a dependency, a target-feature floor not in the x86-64 baseline, and a different instruction on the ARM64 machine this workspace measures on. Revisit only for a tagged pointer, which is what [M-inf.1](../../CHECKLIST-io-domains.md)'s linked and sharded shapes would need. | +| D-19 | **The coalesced loss latch is deliberately not generalised from the file watcher.** Coalescing there is sound because a desync is *idempotent* -- two mean the same as one, and the answer to both is a re-scan. A queue of arbitrary `T` has no such property, so what generalises is a loss *count*, which is [M31.4](../../CHECKLIST-io-domains.md)'s observability rather than a policy. | ## D-2: capabilities are sliced, not gathered @@ -449,3 +453,149 @@ by restoring the property that any push makes the re-check find something -- but doorbell able to reach the inconsistent state, waiting for the next shape, and it would have cost the consumer a spin whenever a claim was in flight. Adding a lock around the two lines would have fixed it and thrown away the reason the flag exists. + +## D-16: reservation is a capability, so the reserving queue is a peer and not a replacement + +[D-6](#d-6) said overflow "fails or reserves, and never overwrites", and quietly assumed one queue would +carry both policies. Building the second one showed that assumption was wrong, and why. + +**Honouring a reservation costs the producer something on every push, including the pushes that never +reserve anything.** `mpsc`'s producer never reads the consumer's position: it asks the slot's own +sequence number "are you free?", and those are spread across the slot array, so producers working at +different positions touch different cache lines. Avoiding a single shared position is not incidental to +Vyukov's design; it is most of the point of it. + +A reservation cannot be answered from that question. "Is this slot free" does not say **how many** slots +remain, and withholding one from the best-effort path requires exactly that count -- which requires the +consumer's position, on one line every thread in the system touches. + +So the choice was: pay that on every `mpsc` push, or ship two shapes. Two shapes, for three reasons: + +- **The cost falls on the shape [M31.5](../../CHECKLIST-io-domains.md) exists to measure.** Degrading + `mpsc`'s push before the contention benchmark runs would corrupt the measurement that decides whether + the deferred shapes are needed at all. +- **The crate is built for this.** It is named in the plural, [D-7](#d-7) makes shapes plain modules, and + [D-4](#d-4) already has shapes differing in what they can do. A third one is the pattern working, not + an exception to it. +- **It is [D-2](#d-2)'s argument reaching its sharpest case.** `mpsc` does not implement `Reserving` + because it genuinely *cannot*, not because nobody got round to it -- which is exactly the situation + narrow traits were chosen for. A fat trait would have forced the cost on both shapes or excluded + reservation from the contract entirely. + +**The alternative shape considered and refused was a permit counter** -- one atomic that both producers +and the consumer read-modify-write, acquiring on push and releasing on pop. It is correct and simpler to +read, and it was rejected because it puts a second contended read-modify-write on the push path where the +packed word puts one shared *load*. Its only advantage is preserving the crate-wide capacity ceiling, and +[D-17](#d-17) explains why that ceiling is unreachable anyway. + +**The merge-or-delete decision is deferred to M31.5, deliberately and with a trigger.** If the benchmark +shows the shared-line read costs little at realistic contention, `mpsc` and `reserving_mpsc` should merge +and the plain one should go. If it shows the read is expensive, both stay. What must not happen is the +duplicated path becoming permanent because nobody circled back, so the decision is recorded as an item on +M31.5 rather than as an intention here. + +## D-17: the reservation count and the claim position share one word + +**The obvious implementation is broken, and it is worth writing down why, because the brokenness is not +visible from reading either side on its own.** With the count in its own atomic: + +1. A pushing producer reads the count, sees room, and claims the position. +2. A reserving producer increments the count, reads the position, sees room, and grants. + +Each read before the other's write. The queue now owes a slot that does not exist, and the guarantee the +whole feature rests on is gone. + +**Sequentially consistent fences do not close this**, which is the part that surprises -- they *do* close +the superficially identical hazard in [D-9](#d-9). The Dekker argument needs store-then-load on both +sides. Here the pushing producer is **load**-then-store: it reads the count and then writes the position. +Writing the four operations into a single total order, `L_push < S_reserve < L_reserve < S_push` is +consistent with every side's program order, so both sides missing each other is permitted and no fence +forbids it. + +Two independent claimants on one resource must synchronise on **one location**. So the count and the +position become one location: a single `AtomicU64`, low 32 bits the position, high 32 the count. Every +operation that changes either changes both, with one compare-and-swap. + +Three consequences fall out, and all three are improvements: + +- **Redeeming is one exchange** that decrements the count as it advances the position, so + `occupied + reserved` -- the quantity the invariant is about -- is never momentarily wrong. +- **A racing `reserve` and `push` cannot both win.** The loser's exchange fails and it re-reads, which is + the ordinary lock-free retry rather than a special case. +- **The producer stops needing the slot sequence for the "free" direction**, because it now reads the + consumer's position anyway. So `reserving_mpsc`'s `pop` is one store shorter than `mpsc`'s: nothing + writes a "free again" sequence. + +**The 32/32 split is forced, not chosen.** A position of `b` bits keeps a wrapping difference unambiguous +only up to `2^(b-1)`; the count can reach the capacity, so it needs `b` bits too; `b + b = 64` gives +`b = 32`. There is no cleverer division of the word, and the resulting ceiling is 2^31 items -- a ring +this shape allocates in full at construction, so at eight bytes an item it is already 17 GB. + +That ceiling is reported through `CapacityError`'s `max_valid`, which [D-12](#d-12) had already made a +property of the shape rather than of the crate. D-12 introduced that for the *minimum* and argued the +maximum worked the same way; this is that argument being cashed. + +**The invariants the packing depends on are `const` assertions, not tests**, because they are facts about +constants: a test can only report after the fact, on a build somebody chose to run. Worth recording that +the first version of those assertions was *tautological* -- it asserted that `BOUNDS_MAX` equalled its own +definition -- and widening the position to 40 bits sailed straight past it while silently narrowing the +count's field to 24 bits, which is the way the packing actually breaks. The assertions now name the +constraint that binds: the count's half must be wide enough to hold the whole capacity. + +## D-18: a 128-bit compare-and-swap is refused + +The natural question about [D-17](#d-17)'s packing is why not use `cmpxchg16b` (or `CASP` on aarch64) and +keep both halves full width. The answer has one decisive part and three supporting ones. + +**It does not remove the cost that matters.** The expense in this shape is the shared read of the +consumer's position, and free space is `capacity - (position - head) - reserved`. `head` belongs to the +consumer; no width of *producer-side* compare-and-swap makes it appear in the producer's word. So a +double-width exchange buys exactly one thing: lifting the ceiling from 2^31 to 2^63, on a ring that is +allocated in full at construction. + +The supporting reasons: + +- **It is not reachable from stable Rust without a new dependency.** There is no usable `AtomicU128`, and + `core::arch::x86_64::cmpxchg16b` is an unstable intrinsic; the toolchain is pinned to 1.98.0 stable. It + would mean adding `portable-atomic` to a workspace whose only third-party dependency is `windows-sys`, + on a crate that is [published](#d-8). +- **It is not in the x86-64 baseline.** `x86_64-pc-windows-msvc` does not enable the target feature by + default. Windows 8.1 and later require the instruction in hardware, so it is *present*, but the + compiler still will not emit it unless told -- so it is either raise the target-feature floor, which + narrows the platform, or pay runtime detection on the push path. +- **It is not even the same instruction on the machine this workspace measures on.** The reference machine + is `aarch64-pc-windows-msvc`; CI is x86-64. The measuring platform and the CI platform would exercise + different instructions with different cost profiles, and + [windows-platform-probes](../windows-platform-probes/DESIGN-NOTES.md) records what ARM64-only + measurement has already cost once. + +**Where it would genuinely earn its place is a tagged pointer**, which is what the linked and sharded +shapes parked in `M-inf.1` would need. Recorded there so the question does not have to be re-derived. + +## D-19: the coalesced loss latch does not generalise + +[windows-file-watcher's queue](../windows-file-watcher/src/queue.rs) carries a third policy beside +fail-fast and reserve: a failed enqueue latches the affected `WatchId` in a set held *outside* the bounded +queue, where it coalesces, and is drained back in at the next successful enqueue. It is a good design and +this crate deliberately does not copy it. + +**Coalescing is sound there because a desync is idempotent.** Two lost notifications for one subscription +mean the same thing as one -- the client must re-scan -- so collapsing them loses nothing, and that is +what makes the latch lossless despite being bounded by the number of subscriptions rather than by the +number of losses. + +A queue of arbitrary `T` has no such property. There is no general way to collapse two lost `T`s into +one, and no general way to say what a client should do about them. What *does* generalise is the part +that does not depend on the payload: **a count of what was refused**, so loss is measured rather than +silent. That is observability, and it belongs to [M31.4](../../CHECKLIST-io-domains.md) rather than to the +overflow policy. + +So this crate's answer to a full queue is: refuse and hand the item back, or hold a reservation so the +refusal cannot happen to the messages that cannot survive it. A caller whose payload *is* idempotent can +build the watcher's latch on top of the typed refusal, which is the right layer for a decision that +depends on what the payload means. + +**Overwrite-oldest remains refused outright**, as [D-6](#d-6) said. `crossbeam`'s `force_push` makes an +`ArrayQueue` usable as a ring buffer, which is right for telemetry where an overwritten entry is a lost +sample. Here an entry may be an I/O submission, where it is a lost *operation*. The two must not share a +knob, because a knob invites a caller to pick the wrong one. diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index fc51566a..1a5d325f 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -5,13 +5,14 @@ Bounded producer/consumer queues whose readiness is a waitable Windows `HANDLE`. **Windows only.** Every public item is behind `cfg(windows)`; the crate builds to an empty shell on other platforms. -**Status: two shapes, both waitable.** `spsc` is a bounded ring with no +**Status: three shapes, all waitable.** `spsc` is a bounded ring with no compare-and-swap on either side; `mpsc` is a bounded array queue using Vyukov's -sequence protocol, so any number of producers may push without a lock. Either can -be polled with no kernel object at all, blocked on directly, or waited on -alongside other handles. The capability traits over them -- -`Producer`, `Consumer`, `Bounded`, `Waitable` -- ship with the second shape, -which is what validated them. +sequence protocol, so any number of producers may push without a lock; and +`reserving_mpsc` is that queue plus the ability to claim a slot in advance. Any +of them can be polled with no kernel object at all, blocked on directly, or +waited on alongside other handles. The capability traits over them -- +`Producer`, `Consumer`, `Bounded`, `Waitable`, `Reserving` -- each ship with the +second implementation that validated them. The decisions all of this was built against are in [DESIGN-NOTES.md](DESIGN-NOTES.md), and the remaining work is tracked in @@ -50,11 +51,12 @@ shape it wants. Each shape splits into a **producer handle** and a **consumer handle**, and cardinality is carried by whether those handles are `Clone`: -| Shape | Producer | Consumer | Shipped | -|---|---|---|---| -| SPSC | not `Clone` | not `Clone` | yes | -| MPSC | `Clone` | not `Clone` | yes | -| MPMC | `Clone` | `Clone` | not yet | +| Shape | Producer | Consumer | Reserves | Shipped | +|---|---|---|---|---| +| `spsc` | not `Clone` | not `Clone` | yes | yes | +| `mpsc` | `Clone` | not `Clone` | **no** | yes | +| `reserving_mpsc` | `Clone` | not `Clone` | yes | yes | +| MPMC | `Clone` | `Clone` | -- | not yet | So "single producer" is a fact the compiler enforces, not a sentence in a doc comment: the handles are also not `Sync`, so a handle that cannot be cloned and @@ -71,6 +73,12 @@ again next lap" in a one-slot ring. slot. Overwrite-oldest is right for telemetry, where a lost entry is a lost sample; here an entry may be an I/O submission, where a lost entry is a lost operation. +- **It will not make you pay for reservation if you do not want it.** Honouring + a reservation means counting free slots, which costs a producer a read of the + consumer's position on every push. `mpsc` does not offer reservation and does + not pay; `reserving_mpsc` offers it and does. That is why `mpsc` does not + implement the `Reserving` trait -- it genuinely cannot, which is the whole + reason the traits are narrow. - **It will not allocate on push.** Bounded shapes allocate once, at construction. - **It will not create a kernel object you never use.** The doorbell is created diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json index 416925f7..229b683e 100644 --- a/crates/windows-waitable-queues/sabotage.json +++ b/crates/windows-waitable-queues/sabotage.json @@ -9,11 +9,17 @@ "expect": "caught", "why": "A producer that never rings the bell leaves a parked consumer asleep on a queue with items in it. Caught as a hang, which is the correct shape for this defect.", "find": [ - " self.shared.doorbell.signal();", - " Ok(())" + " self.doorbell.signal();", + " }", + "}", + "", + "impl Drop for Shared {" ], "replace": [ - " Ok(())" + " }", + "}", + "", + "impl Drop for Shared {" ] }, { @@ -26,13 +32,13 @@ " }", "}", "", - "/// The reading half" + "/// A slot claimed in advance" ], "replace": [ " }", "}", "", - "/// The reading half" + "/// A slot claimed in advance" ] }, { @@ -316,10 +322,126 @@ "expect": "caught", "why": "With one slot, 'published at position p' and 'free again at position p + capacity' are the SAME number, so a producer reads the sequence of the item it just pushed, concludes the slot is free, and overwrites an item the consumer has not read. spsc accepts one, which is exactly why the minimum belongs to the shape rather than to the crate -- and why it is asserted rather than assumed.", "find": [ - "const MIN_CAPACITY: usize = 2;" + "const BOUNDS: Bounds = Bounds {", + " min: 2,", + " max: WRAPPING_MAX_CAPACITY,", + "};" + ], + "replace": [ + "const BOUNDS: Bounds = Bounds {", + " min: 1,", + " max: WRAPPING_MAX_CAPACITY,", + "};" + ] + }, + { + "name": "reserving_mpsc: a best-effort push may take a reserved slot", + "file": "src/reserving_mpsc.rs", + "expect": "caught", + "why": "The reservation guarantee itself. If the best-effort path ignores the outstanding count, the slot a reservation was promised gets taken by an ordinary push and the redemption overwrites a live item. Note this sabotage keeps the room check but drops the reservations from it, which is exactly the mistake an optimiser-minded reader would make: the count looks like bookkeeping until you ask who is holding the slot it accounts for.", + "find": [ + " occupied < capacity - reserved" + ], + "replace": [ + " let _ = reserved;", + " occupied < capacity" + ] + }, + { + "name": "reserving_mpsc: reserve does not check for room", + "file": "src/reserving_mpsc.rs", + "expect": "caught", + "why": "A reservation handed out over a full queue is a promise that cannot be kept, which is worse than a refusal: the caller has already been told it may proceed. Reserve is the CHEAP place to fail -- no work has started -- and removing the check moves that failure to the one place the design exists to keep it away from.", + "find": [ + " if !self.shared.has_room_beyond_reservations(position, reserved) {", + " return None;", + " }" + ], + "replace": [ + "" + ] + }, + { + "name": "reserving_mpsc: redeeming does not release the reservation", + "file": "src/reserving_mpsc.rs", + "expect": "caught", + "why": "Redeeming must decrement the count as it advances the position, in ONE exchange. Leaving the count up permanently withdraws a slot from the best-effort path on every send, so the queue's usable capacity bleeds away to zero over time. Caught quickly because a capacity-2 queue stops accepting anything after its first reserved send.", + "find": [ + " claim_word(reserved - 1, position.wrapping_add(1))," + ], + "replace": [ + " claim_word(reserved, position.wrapping_add(1))," + ] + }, + { + "name": "reserving_mpsc: dropping a reservation does not return the slot", + "file": "src/reserving_mpsc.rs", + "expect": "caught", + "why": "An abandoned reservation must give its capacity back. Without this the queue leaks a slot per dropped reservation and eventually refuses everything, which is the same bleed as the entry above reached by the other path.", + "find": [ + " claim_word(reserved - 1, position_of(word))," + ], + "replace": [ + " claim_word(reserved, position_of(word))," + ] + }, + { + "name": "reserving_mpsc: an outstanding reservation does not hold the stream open", + "file": "src/reserving_mpsc.rs", + "expect": "caught", + "why": "A reservation is a promise of a message still to come, so it must count as a producer. Without this the consumer is told the stream ended while a reservation is outstanding, and then handed an item afterwards -- losing exactly the message the reservation existed to protect. This is the defect the feature is FOR, so a test suite that missed it would be asserting the mechanism and not the purpose.", + "find": [ + " self.shared.producers.fetch_add(1, Ordering::Relaxed);" + ], + "replace": [ + "" + ] + }, + { + "name": "reserving_mpsc: send leaks the shared state via mem::forget", + "file": "src/reserving_mpsc.rs", + "expect": "caught", + "why": "The real defect this shape shipped with for one test run. `mem::forget` suppresses the double-release correctly but also leaks the Arc the reservation holds, so the shared state is never dropped and every item still in the ring leaks with it. Caught by the drop-counting test rather than by review, which is why that test counts drops instead of merely checking the queue still works.", + "find": [ + " let this = core::mem::ManuallyDrop::new(self);", + " // SAFETY: `this` is a `ManuallyDrop`, so its own destructor never runs", + " // and the field is not read again after this move.", + " let shared = unsafe { core::ptr::read(&this.shared) };", + " shared.release_producer();" + ], + "replace": [ + " let shared = Arc::clone(&self.shared);", + " core::mem::forget(self);", + " shared.release_producer();" + ] + }, + { + "name": "spsc: a best-effort push may take a reserved slot", + "file": "src/spsc.rs", + "expect": "caught", + "why": "The same guarantee on the single-producer shape, where the mechanism is a plain counter rather than a packed word. Worth its own entry precisely because the two implementations share nothing: a test that only covered the mpsc path would leave this one unguarded.", + "find": [ + " if tail.wrapping_sub(head) + reserved >= self.shared.capacity {", + " // Report disconnection in preference to fullness" + ], + "replace": [ + " if tail.wrapping_sub(head) >= self.shared.capacity {", + " // Report disconnection in preference to fullness" + ] + }, + { + "name": "spsc: dropping a reservation does not return the slot", + "file": "src/spsc.rs", + "expect": "caught", + "why": "As for reserving_mpsc: an abandoned reservation must give its capacity back, or the queue bleeds a slot per drop until it refuses everything.", + "find": [ + " self.producer", + " .shared", + " .reserved", + " .store(reserved - 1, Ordering::Relaxed);" ], "replace": [ - "const MIN_CAPACITY: usize = 1;" + " let _ = reserved;" ] }, { diff --git a/crates/windows-waitable-queues/src/capacity.rs b/crates/windows-waitable-queues/src/capacity.rs index 21da9842..0efe6f88 100644 --- a/crates/windows-waitable-queues/src/capacity.rs +++ b/crates/windows-waitable-queues/src/capacity.rs @@ -2,27 +2,37 @@ //! The capacity rule, stated once for every bounded shape. //! -//! It lives here rather than inside a shape's module because both bounded -//! shapes enforce the same rule for the same reason, and a second copy of a +//! It lives here rather than inside a shape's module because every bounded +//! shape enforces the same rule for the same reason, and a second copy of a //! rule is free to drift from the first. A test that wants to check a suggested -//! capacity asks [`validate_capacity`] rather than re-encoding the three -//! conditions, which is the difference between checking the rule and checking a -//! paraphrase of it. +//! capacity asks [`validate_capacity`] rather than re-encoding the conditions, +//! which is the difference between checking the rule and checking a paraphrase +//! of it. //! -//! The bounds are carried on [`CapacityError`] rather than assumed to be -//! crate-wide constants, because they follow from how a shape represents its -//! positions -- and the two shapes shipped so far already disagree about the -//! lower one. `spsc` accepts a capacity of one; `mpsc` cannot, because its slot -//! state machine encodes "published" as one past the claim position and "free -//! again" as one lap past it, and with a single slot those are the same number. -//! So each shape supplies its own minimum and this module applies it, which is -//! the arrangement the error type was already shaped for. +//! # The bounds belong to the shape, not to the crate +//! +//! [`CapacityError`] carries both bounds rather than assuming crate-wide +//! constants, because both follow from how a shape represents its positions -- +//! and the shipped shapes disagree about both. +//! +//! - **The minimum.** `spsc` accepts a capacity of one; `mpsc` cannot, because +//! its slot state machine encodes "published" as one past the claim position +//! and "free again" as one lap past it, and with a single slot those are the +//! same number. +//! - **The maximum.** Most shapes stop at [`WRAPPING_MAX_CAPACITY`], where a +//! wrapping difference between two positions stops being unambiguous. +//! `reserving_mpsc` stops far lower, because it packs its reservation count +//! into the same word as its position so that the two can be claimed +//! together. +//! +//! So a shape supplies its own [`Bounds`] and this module applies them, which +//! is the arrangement the error type was already shaped for. use crate::error::CapacityError; /// The largest capacity that keeps a wrapping position difference unambiguous. /// -/// Positions are monotonic and wrap with the integer, so both shapes need the +/// Positions are monotonic and wrap with the integer, so a shape needs the /// difference between two of them to be readable as a signed quantity: /// /// - `spsc` computes the number of items held as `tail.wrapping_sub(head)`, @@ -31,7 +41,24 @@ use crate::error::CapacityError; /// - `mpsc` compares a slot's sequence number against a position by /// interpreting `sequence.wrapping_sub(position)` as an [`isize`], which is /// the same requirement written a different way. -pub(crate) const MAX_CAPACITY: usize = usize::MAX / 2; +/// +/// A shape whose positions are narrower than a [`usize`] has a correspondingly +/// smaller bound, and says so in its own [`Bounds`]; this is the widest any +/// shape may be. +pub(crate) const WRAPPING_MAX_CAPACITY: usize = usize::MAX / 2; + +/// What one shape will accept as a capacity. +/// +/// A named pair rather than two loose arguments, so neither a call site nor a +/// test can silently transpose them, and so a shape's answer to "how small" and +/// "how large" is written in one place with the reasoning beside it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Bounds { + /// The smallest capacity this shape can represent. + pub(crate) min: usize, + /// The largest capacity this shape can represent. + pub(crate) max: usize, +} /// Whether a bounded shape will accept a capacity, and why not if it will not. /// @@ -40,36 +67,37 @@ pub(crate) const MAX_CAPACITY: usize = usize::MAX / 2; /// of items the queue holds -- not a hint, and not rounded. See /// [`CapacityError`] for why a rejection is preferred to silently rounding. /// -/// `min_valid` is the calling shape's own smallest usable capacity. It is a -/// parameter rather than a constant because it is a property of the shape's -/// slot representation, and the two shapes do not agree on it. Each shape names -/// its own and says why, so the number is never a bare literal at a call site. -/// /// Separated from each shape's constructor so the rule can be *asked* rather /// than restated. A test that wants to check a suggested capacity is acceptable /// would otherwise have to either re-encode these conditions -- a second copy /// of a rule, free to drift from this one -- or call the constructor, which for /// a capacity near the bound means trying to allocate half the address space. -pub(crate) fn validate_capacity(capacity: usize, min_valid: usize) -> Result<(), CapacityError> { +pub(crate) fn validate_capacity(capacity: usize, bounds: Bounds) -> Result<(), CapacityError> { debug_assert!( - min_valid.is_power_of_two(), + bounds.min.is_power_of_two(), "a shape's minimum is suggested to callers verbatim, so it must itself be valid" ); + debug_assert!( + bounds.max <= WRAPPING_MAX_CAPACITY, + "no shape may exceed the width at which a wrapping position difference is unambiguous" + ); + debug_assert!( + bounds.min <= bounds.max, + "a shape that accepts nothing at all would reject every capacity with a suggestion it \ + would also reject" + ); + if capacity == 0 { - return Err(CapacityError::zero(min_valid, MAX_CAPACITY)); + return Err(CapacityError::zero(bounds)); } if !capacity.is_power_of_two() { - return Err(CapacityError::not_power_of_two( - capacity, - min_valid, - MAX_CAPACITY, - )); + return Err(CapacityError::not_power_of_two(capacity, bounds)); } - if capacity < min_valid { - return Err(CapacityError::too_small(capacity, min_valid, MAX_CAPACITY)); + if capacity < bounds.min { + return Err(CapacityError::too_small(capacity, bounds)); } - if capacity > MAX_CAPACITY { - return Err(CapacityError::too_large(capacity, min_valid, MAX_CAPACITY)); + if capacity > bounds.max { + return Err(CapacityError::too_large(capacity, bounds)); } Ok(()) } diff --git a/crates/windows-waitable-queues/src/error.rs b/crates/windows-waitable-queues/src/error.rs index 8fa99609..07896181 100644 --- a/crates/windows-waitable-queues/src/error.rs +++ b/crates/windows-waitable-queues/src/error.rs @@ -9,6 +9,8 @@ use core::fmt; use std::io; +use crate::capacity::Bounds; + /// Why a capacity was rejected at construction. /// /// Constructing a queue is the one place a caller can get this wrong, so it is @@ -28,10 +30,12 @@ pub struct CapacityError { /// The largest capacity the rejecting shape accepts. /// /// Carried on the error rather than assumed to be a crate-wide constant: - /// the bound follows from how a shape represents its positions, and a - /// future shape that represents them differently would have a different - /// one. A suggestion computed against the wrong bound is worse than no - /// suggestion, because a caller will act on it. + /// the bound follows from how a shape represents its positions, and the + /// shapes differ. Most stop where a wrapping difference between positions + /// stops being unambiguous; `reserving_mpsc` stops far lower, because it + /// packs its reservation count into the same word as its position so the + /// two can be claimed together. A suggestion computed against the wrong + /// bound is worse than no suggestion, because a caller will act on it. max_valid: usize, kind: CapacityErrorKind, } @@ -45,40 +49,29 @@ enum CapacityErrorKind { } impl CapacityError { - pub(crate) fn zero(min_valid: usize, max_valid: usize) -> Self { + fn new(requested: usize, bounds: Bounds, kind: CapacityErrorKind) -> Self { Self { - requested: 0, - min_valid, - max_valid, - kind: CapacityErrorKind::Zero, + requested, + min_valid: bounds.min, + max_valid: bounds.max, + kind, } } - pub(crate) fn not_power_of_two(requested: usize, min_valid: usize, max_valid: usize) -> Self { - Self { - requested, - min_valid, - max_valid, - kind: CapacityErrorKind::NotPowerOfTwo, - } + pub(crate) fn zero(bounds: Bounds) -> Self { + Self::new(0, bounds, CapacityErrorKind::Zero) } - pub(crate) fn too_small(requested: usize, min_valid: usize, max_valid: usize) -> Self { - Self { - requested, - min_valid, - max_valid, - kind: CapacityErrorKind::TooSmall, - } + pub(crate) fn not_power_of_two(requested: usize, bounds: Bounds) -> Self { + Self::new(requested, bounds, CapacityErrorKind::NotPowerOfTwo) } - pub(crate) fn too_large(requested: usize, min_valid: usize, max_valid: usize) -> Self { - Self { - requested, - min_valid, - max_valid, - kind: CapacityErrorKind::TooLarge, - } + pub(crate) fn too_small(requested: usize, bounds: Bounds) -> Self { + Self::new(requested, bounds, CapacityErrorKind::TooSmall) + } + + pub(crate) fn too_large(requested: usize, bounds: Bounds) -> Self { + Self::new(requested, bounds, CapacityErrorKind::TooLarge) } /// The smallest capacity the shape that rejected this request will accept. @@ -129,9 +122,9 @@ impl CapacityError { /// The largest power of two that does not exceed [`Self::max_valid`]. /// - /// The clamp target for [`Self::previous_valid`]: `max_valid` is itself not - /// necessarily a power of two -- for a ring of monotonic wrapping positions - /// it is `usize::MAX / 2`, which is `2^63 - 1` -- so clamping to it + /// The clamp target for [`Self::previous_valid`]: `max_valid` need not be a + /// power of two -- for a ring of monotonic wrapping positions it is + /// `usize::MAX / 2`, which is `2^63 - 1` -- so clamping to it /// directly would hand back a capacity that fails the power-of-two test /// instead of the size test. fn largest_power_of_two_within_bound(&self) -> usize { @@ -185,10 +178,8 @@ impl fmt::Display for CapacityError { ), CapacityErrorKind::TooLarge => write!( f, - "capacity {} is too large; it must not exceed half of usize::MAX, so that the \ - difference between the producer and consumer positions stays unambiguous across \ - wraparound", - self.requested + "capacity {} is above the largest this queue shape can represent, which is {}", + self.requested, self.max_valid ), } } @@ -244,6 +235,38 @@ impl fmt::Display for PushError { impl core::error::Error for PushError {} +/// The only way delivering into a reserved slot can fail: nobody is left to +/// take it. +/// +/// **There is deliberately no `Full` here, and the absence is the contract.** A +/// reservation's whole purpose is that the room is already the holder's, so a +/// full queue cannot refuse it. Returning [`PushError`] instead would name a +/// case that cannot occur and oblige every caller to handle it, which is how a +/// guarantee decays back into a thing you hope is true. +/// +/// The item comes back for the same reason it does from a refused push: a queue +/// that swallows what it cannot deliver leaves the caller no way to account for +/// it. That matters more here than elsewhere -- an item important enough to +/// reserve a slot for is exactly the kind whose disposal must not be silent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Disconnected(pub T); + +impl Disconnected { + /// Takes the item back out. + #[must_use] + pub fn into_inner(self) -> T { + self.0 + } +} + +impl fmt::Display for Disconnected { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("every consumer is gone") + } +} + +impl core::error::Error for Disconnected {} + /// Why a blocking receive gave up. /// /// There is no `Empty` variant, because a blocking receive does not return on diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 5ec12b86..905d472c 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -65,11 +65,12 @@ mod error; pub mod mpsc; #[cfg(test)] mod race_hooks; +pub mod reserving_mpsc; pub mod spsc; pub mod traits; -pub use error::{CapacityError, PushError, RecvError, RecvTimeoutError}; -pub use traits::{Bounded, Consumer, Drain, Producer, Waitable}; +pub use error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; +pub use traits::{Bounded, Consumer, Drain, Producer, Reserving, Waitable}; /// Pads and aligns a value onto its own cache line. /// diff --git a/crates/windows-waitable-queues/src/mpsc.rs b/crates/windows-waitable-queues/src/mpsc.rs index 9c5b36f6..9339db12 100644 --- a/crates/windows-waitable-queues/src/mpsc.rs +++ b/crates/windows-waitable-queues/src/mpsc.rs @@ -79,13 +79,14 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; -use crate::capacity::validate_capacity; +use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; use crate::doorbell::Doorbell; use crate::error::{CapacityError, PushError, RecvError, RecvTimeoutError}; -/// The smallest capacity this shape can represent. +/// What this shape accepts as a capacity. /// -/// **Two, and it is a property of the sequence protocol rather than a taste.** +/// **The minimum is two, and it is a property of the sequence protocol rather +/// than a taste.** /// A slot's sequence has to distinguish three states, and it does so by /// counting: `pos` means free, `pos + 1` means published, and the consumer /// frees it again by storing `pos + capacity`, the position the next lap will @@ -100,7 +101,15 @@ use crate::error::{CapacityError, PushError, RecvError, RecvTimeoutError}; /// cost this protocol exists to avoid, and it would do so for every queue in /// order to serve a capacity of one. A caller that genuinely wants a one-item /// handoff wants [`spsc`](crate::spsc), which represents it exactly. -const MIN_CAPACITY: usize = 2; +/// +/// The maximum is the widest any shape may be, because this one's positions are +/// full-width [`usize`] values with nothing packed beside them -- +/// [`reserving_mpsc`](crate::reserving_mpsc) pays for its reservations with a +/// far lower ceiling. +const BOUNDS: Bounds = Bounds { + min: 2, + max: WRAPPING_MAX_CAPACITY, +}; /// Creates a multi-producer, single-consumer bounded array queue. /// @@ -135,7 +144,7 @@ const MIN_CAPACITY: usize = 2; /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { - validate_capacity(capacity, MIN_CAPACITY)?; + validate_capacity(capacity, BOUNDS)?; let mut slots = Vec::with_capacity(capacity); for index in 0..capacity { diff --git a/crates/windows-waitable-queues/src/mpsc/tests.rs b/crates/windows-waitable-queues/src/mpsc/tests.rs index e14f53c4..ec70119d 100644 --- a/crates/windows-waitable-queues/src/mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/mpsc/tests.rs @@ -14,7 +14,7 @@ //! scheduler rather than the queue -- green today, red on a different machine, //! and evidence of nothing either way. -use super::{Consumer, MIN_CAPACITY, Producer, bounded, validate_capacity}; +use super::{BOUNDS, Consumer, Producer, bounded, validate_capacity}; use crate::race_hooks; use crate::{PushError, RecvError, RecvTimeoutError}; use std::collections::BTreeMap; @@ -107,7 +107,7 @@ fn the_smallest_capacity_holds_exactly_two() { // Two is this shape's floor rather than one, so the two-slot ring is the // edge case that `spsc`'s one-slot ring is: every push after the first two // is a refusal, and every pop frees exactly one slot. - let (tx, rx) = bounded::(MIN_CAPACITY).expect("the shape's own minimum must be accepted"); + let (tx, rx) = bounded::(BOUNDS.min).expect("the shape's own minimum must be accepted"); tx.push(1).expect("room for two"); tx.push(2).expect("room for two"); assert!(matches!(tx.push(3), Err(PushError::Full(3)))); @@ -236,7 +236,7 @@ fn a_zero_capacity_is_refused_because_it_could_never_accept_anything() { assert_eq!(error.requested(), 0); assert_eq!( error.next_valid(), - Some(MIN_CAPACITY), + Some(BOUNDS.min), "the suggestion must be this shape's own floor, not a crate-wide one" ); } @@ -311,18 +311,18 @@ fn a_suggested_capacity_is_one_the_constructor_would_accept() { // real path, but a suggestion near the bound is 2^62, and constructing that // queue means asking for half the address space. for requested in [0_usize, 1, 3, 100, 1000, usize::MAX / 2, usize::MAX] { - let Err(error) = validate_capacity(requested, MIN_CAPACITY) else { + let Err(error) = validate_capacity(requested, BOUNDS) else { continue; }; if let Some(previous) = error.previous_valid() { assert!( - validate_capacity(previous, MIN_CAPACITY).is_ok(), + validate_capacity(previous, BOUNDS).is_ok(), "previous_valid() for {requested} suggested {previous}, which is itself rejected" ); } if let Some(next) = error.next_valid() { assert!( - validate_capacity(next, MIN_CAPACITY).is_ok(), + validate_capacity(next, BOUNDS).is_ok(), "next_valid() for {requested} suggested {next}, which is itself rejected" ); } @@ -529,7 +529,7 @@ fn many_producers_against_the_smallest_queue_still_deliver_everything() { // least once and the tail's compare-and-swap is contended continuously. // This is where a mis-ordered claim or a slot freed at the wrong sequence // stops being theoretical. - let received = run_producers(MIN_CAPACITY); + let received = run_producers(BOUNDS.min); assert_eq!(received.len(), PRODUCERS * PER_PRODUCER); let mut per_producer = [0_usize; PRODUCERS]; diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs new file mode 100644 index 00000000..f24eed72 --- /dev/null +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -0,0 +1,1084 @@ +// Copyright (c) Mike Grier. + +//! The multi-producer, single-consumer bounded array queue **that can reserve**. +//! +//! Everything [`mpsc`](crate::mpsc) is, plus [`Producer::reserve`]: a slot +//! claimed in advance, so that a later delivery cannot be refused for want of +//! room. *Reserved is guaranteed, unreserved is best-effort.* +//! +//! # Why this is a separate shape rather than a method on `mpsc` +//! +//! Because honouring a reservation costs the producer something on **every** +//! push, including the pushes that never reserve anything. +//! +//! `mpsc`'s producer never reads the consumer's position. It asks a different +//! question -- "is the slot I am about to claim free?" -- and reads that from +//! the slot's own sequence number, which is spread across the slot array, so +//! producers working at different positions touch different cache lines. +//! Avoiding a single shared position is not incidental to that design; it is +//! most of the point of it. +//! +//! A reservation cannot be honoured from that question. "Is this slot free" does +//! not tell you **how many** slots remain, and holding one back for a reserver +//! requires exactly that count -- which requires the consumer's position, on one +//! line every thread in the system touches. +//! +//! So the two ship as peers ([D-16](../../DESIGN-NOTES.md#d-16)): `mpsc` for a +//! caller who wants the cheapest possible push and can treat a refusal as +//! backpressure, this shape for a caller with a message it must not lose. That +//! is the narrow-trait argument from [D-2](../../DESIGN-NOTES.md#d-2) reaching +//! its sharpest case -- `mpsc` does not implement +//! [`Reserving`](crate::Reserving) because it genuinely cannot, not because +//! nobody got round to it. +//! +//! # The claim word, which is why reservation is sound here +//! +//! The reservation count and the claim position live in **one** [`AtomicU64`]: +//! the low 32 bits are the position, the high 32 the number of outstanding +//! reservations. Every operation that changes either changes both together, with +//! one compare-and-swap. +//! +//! That is not tidiness, it is the correctness argument, and the obvious +//! alternative is broken in a way worth recording. With the count in its own +//! atomic: +//! +//! 1. A pushing producer reads the count, sees room, and claims the position. +//! 2. A reserving producer increments the count, reads the position, sees room, +//! and hands out the reservation. +//! +//! Each read before the other's write, and the queue now owes a slot that does +//! not exist. **Sequentially consistent fences do not close this**, unlike the +//! superficially similar hazard in [`Doorbell`](crate::doorbell::Doorbell): the +//! Dekker argument needs store-then-load on both sides, and the pushing producer +//! is load-then-store -- it *reads* the count and then *writes* the position. In +//! a total order over the four operations, both sides missing each other is +//! consistent, so no fence forbids it. Two independent claimants on one resource +//! must synchronise on one location, so the count and the position become one +//! location. +//! +//! With that, redeeming a reservation is a single compare-and-swap that +//! decrements the count and advances the position at once -- so the quantity the +//! invariant is about, `occupied + reserved`, is never momentarily wrong. +//! +//! # What the packing costs, and what it does not +//! +//! Splitting a 64-bit word 32/32 caps this shape at +//! [`BOUNDS`]`.max` = 2^31 items, and that split is forced rather than chosen: +//! a position of `b` bits keeps a wrapping difference unambiguous only up to +//! `2^(b-1)`, and the count needs `b` bits because it can reach the capacity, so +//! `b + b = 64` gives `b = 32`. There is no cleverer division of the word. +//! +//! **A 128-bit compare-and-swap would lift that cap and is deliberately not +//! used** ([D-18](../../DESIGN-NOTES.md#d-18)). It would not remove the cost that +//! matters -- the consumer's position still has to be read -- and 2^31 slots is +//! a ring this shape allocates in full at construction. + +use core::cell::{Cell, UnsafeCell}; +use core::fmt; +use core::marker::PhantomData; +use core::mem::MaybeUninit; +use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::io; +use std::os::windows::io::{BorrowedHandle, OwnedHandle}; +use std::sync::Arc; +use std::time::Duration; + +use crate::CacheAligned; +use crate::blocking::{self, Parked}; +use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; +use crate::doorbell::Doorbell; +use crate::error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; + +/// How many of the claim word's bits carry the position. +/// +/// The other half carries the outstanding-reservation count. Changing this +/// changes [`BOUNDS`] and is a breaking change to the capacities this shape +/// accepts; see the [module documentation](self) for why an even split is the +/// only sensible one. +const POSITION_BITS: u32 = 32; + +/// Isolates the position half of the claim word. +const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; + +/// What this shape accepts as a capacity. +/// +/// The minimum is two for the same reason [`mpsc`](crate::mpsc)'s is: with a +/// single slot, "published at `p`" and "free again on the next lap" would be the +/// same sequence number. +/// +/// The maximum is 2^31 rather than the crate-wide bound, because a position is +/// half of the packed claim word rather than a whole [`usize`]. A wrapping +/// 32-bit difference is unambiguous only up to 2^31, and that is exactly the +/// most items this shape can hold. +pub const BOUNDS_MAX: usize = 1 << (POSITION_BITS - 1); + +/// The capacities this shape accepts. See [`BOUNDS_MAX`]. +const BOUNDS: Bounds = Bounds { + min: 2, + max: BOUNDS_MAX, +}; + +/// The largest reservation count the word's other half can hold. +const MAX_RESERVED: u64 = u64::MAX >> POSITION_BITS; + +// The relationships the packing depends on, checked by the compiler rather than +// by a test. They are facts about constants, so a test could only ever report +// after the fact, on a build somebody chose to run; here, moving the split +// without re-deriving what depends on it does not compile. +// +// **Note what is deliberately NOT asserted.** That `BOUNDS_MAX` equals +// `1 << (POSITION_BITS - 1)` is tautological -- it is the definition -- and an +// earlier version of this block asserted exactly that, which is to say nothing. +// Widening the position to 40 bits sailed past it while silently narrowing the +// reservation field to 24, which is the real breakage. The assertions below are +// the ones that catch it. +const _: () = { + assert!( + POSITION_BITS >= 32, + "the reservation count is read out as a u32, so a field wider than 32 bits would be \ + truncated on the way out" + ); + assert!( + BOUNDS_MAX as u64 <= MAX_RESERVED, + "every slot may be reserved at once, so the count's half of the word must be able to hold \ + the whole capacity -- widening the position narrows this and is the way the packing \ + actually breaks" + ); + assert!( + BOUNDS.max <= WRAPPING_MAX_CAPACITY, + "a shape may be narrower than the crate-wide bound but never wider" + ); + assert!( + BOUNDS.min <= BOUNDS.max, + "a shape that accepts nothing would reject every capacity with a suggestion it would also \ + reject" + ); +}; + +/// Reads the position out of a claim word. +const fn position_of(word: u64) -> u32 { + (word & POSITION_MASK) as u32 +} + +/// Reads the outstanding-reservation count out of a claim word. +const fn reserved_of(word: u64) -> u32 { + (word >> POSITION_BITS) as u32 +} + +/// Builds a claim word from its two halves. +const fn claim_word(reserved: u32, position: u32) -> u64 { + ((reserved as u64) << POSITION_BITS) | position as u64 +} + +/// Creates a reserving multi-producer, single-consumer bounded array queue. +/// +/// One producer handle is returned; further producers are made by cloning it, +/// and the queue is disconnected when the last of them -- and the last +/// outstanding [`Reservation`] -- is gone. +/// +/// `capacity` must be a power of two between two and [`BOUNDS_MAX`], and is the +/// exact number of items the queue holds -- not a hint, and not rounded. +/// +/// # Errors +/// +/// Returns [`CapacityError`] if `capacity` is zero, is not a power of two, is +/// less than two, or exceeds [`BOUNDS_MAX`]. +/// +/// # Examples +/// +/// A slot taken before the work that will fill it, so the delivery cannot fail +/// for want of room: +/// +/// ``` +/// use windows_waitable_queues::reserving_mpsc; +/// +/// let (tx, rx) = reserving_mpsc::bounded::(2)?; +/// +/// // Claimed up front, while failing is still cheap. +/// let slot = tx.reserve().expect("a fresh queue has room"); +/// +/// // The rest of the queue fills. Best-effort pushes cannot take the +/// // reserved slot, so one of these is refused. +/// tx.push(1).expect("one slot remains unreserved"); +/// assert!(tx.push(2).is_err(), "the other belongs to the reservation"); +/// +/// // And the reservation is still honoured, on a queue that is otherwise full. +/// slot.send(99).expect("the room was already ours"); +/// +/// assert_eq!(rx.pop(), Some(1)); +/// assert_eq!(rx.pop(), Some(99)); +/// # Ok::<(), windows_waitable_queues::CapacityError>(()) +/// ``` +pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { + validate_capacity(capacity, BOUNDS)?; + + let mut slots = Vec::with_capacity(capacity); + for index in 0..capacity { + slots.push(Slot { + // Anything that is not `position + 1` for the position this slot + // first serves, so the consumer sees it as unpublished. The + // position's own value is the natural choice and matches the state + // the slot returns to on every later lap. + sequence: AtomicU32::new(index as u32), + value: UnsafeCell::new(MaybeUninit::uninit()), + }); + } + + let shared = Arc::new(Shared { + slots: slots.into_boxed_slice(), + mask: capacity - 1, + capacity, + head: CacheAligned(AtomicU32::new(0)), + claim: CacheAligned(AtomicU64::new(claim_word(0, 0))), + producers: AtomicUsize::new(1), + consumer_live: AtomicBool::new(true), + doorbell: Doorbell::new(), + }); + + Ok(( + Producer { + shared: Arc::clone(&shared), + not_sync: PhantomData, + }, + Consumer { + shared, + not_sync: PhantomData, + }, + )) +} + +/// One cell of the ring: an item, and a sequence number saying whether it has +/// been published. +struct Slot { + /// `position + 1` once the producer that claimed `position` has finished + /// writing, and anything else before that. + /// + /// **This shape uses the sequence for one direction only.** In + /// [`mpsc`](crate::mpsc) it answers both "has this been published?" for the + /// consumer and "is this slot free?" for the producer. Here the producer + /// answers the second from the consumer's position instead -- it has to read + /// that position anyway, to count free slots for the reservations -- so + /// nothing ever stores a "free again" value and the consumer's `pop` is one + /// store shorter than `mpsc`'s. + sequence: AtomicU32, + value: UnsafeCell>, +} + +struct Shared { + slots: Box<[Slot]>, + mask: usize, + capacity: usize, + /// Where the consumer will next read. Written only by the consumer. + /// + /// Padded onto its own cache line, and here the padding earns its place + /// twice over: unlike `mpsc`, *every* producer reads this on *every* push, + /// so letting the claim word share the line would put the consumer's writes + /// directly in their path. + head: CacheAligned, + /// The outstanding-reservation count and the claim position, packed. + /// + /// One word because they must be claimed together; see the [module + /// documentation](self) for why two atomics cannot be made correct with any + /// amount of fencing. + claim: CacheAligned, + /// How many producer handles and outstanding reservations are alive. + /// + /// **A reservation counts as a producer**, which is not bookkeeping + /// pedantry: a reservation is a promise of a message still to come, so a + /// consumer that saw the stream end while one was outstanding would be told + /// the queue was finished and then handed an item. That would lose exactly + /// the message the reservation existed to protect. + producers: AtomicUsize, + consumer_live: AtomicBool, + /// Readiness as a waitable `HANDLE`. Costs nothing until somebody asks for + /// the handle, so a polling consumer never allocates a kernel object. + doorbell: Doorbell, +} + +// SAFETY: a slot is written by exactly one producer -- the one whose +// compare-and-swap claimed that position -- and read by exactly one consumer, +// which reads it only after observing the release store of `position + 1` that +// publishes it. The write of the item therefore happens-before the read, and no +// two threads ever touch the same slot's contents at the same time. `T: Send` is +// required and sufficient because an item is moved between threads and never +// referenced from both. +unsafe impl Sync for Shared {} +// SAFETY: as above; sending the shared state is sending the items it holds. +unsafe impl Send for Shared {} + +impl Shared { + /// The capacity as the width the positions are counted in. + /// + /// Lossless by construction: [`BOUNDS`] caps the capacity at 2^31. + fn capacity_u32(&self) -> u32 { + debug_assert!(self.capacity <= BOUNDS_MAX); + self.capacity as u32 + } + + /// Whether a *best-effort* claim may take the slot at `position`, given the + /// reservations currently outstanding. + /// + /// Written as a subtraction from the capacity rather than as + /// `occupied + reserved >= capacity`, because both terms can reach 2^31 and + /// their sum would overflow the width the positions are counted in. The + /// invariant guarantees `reserved <= capacity`, so this cannot underflow. + fn has_room_beyond_reservations(&self, position: u32, reserved: u32) -> bool { + let capacity = self.capacity_u32(); + debug_assert!( + reserved <= capacity, + "reservations may never exceed the capacity they are claimed from" + ); + let occupied = position.wrapping_sub(self.head.0.load(Ordering::Acquire)); + occupied < capacity - reserved + } + + /// Items currently held, as a snapshot. + /// + /// Counts slots a producer has claimed but not yet finished writing, for the + /// reason `mpsc`'s does: counting only published items would need a walk of + /// the ring, and this number is a metric rather than a control-flow input. + fn len(&self) -> usize { + let position = position_of(self.claim.0.load(Ordering::Acquire)); + let head = self.head.0.load(Ordering::Acquire); + position.wrapping_sub(head) as usize + } + + /// Whether the consumer would find an item right now. + /// + /// Asks precisely what [`Consumer::pop`] asks -- is the slot at the head + /// position published? A claimed-but-unpublished slot answers `false`, which + /// is the right answer: the consumer may safely park on it, because the + /// producer's publishing store is followed by a signal. + fn has_ready_item(&self) -> bool { + let position = self.head.0.load(Ordering::Relaxed); + let slot = &self.slots[position as usize & self.mask]; + slot.sequence.load(Ordering::Acquire) == position.wrapping_add(1) + } + + /// Give up one unit of the producer count, signalling if it was the last. + /// + /// Shared by [`Producer`] and [`Reservation`] because they are the same + /// obligation: both represent a message that may still arrive, and the last + /// of either to leave is the one that ends the stream. + fn release_producer(&self) { + // `AcqRel` carries both halves. The release half publishes everything + // this producer pushed to whichever thread observes the count reaching + // zero, so a consumer that sees the disconnection can trust that + // draining to empty really has drained everything. The acquire half + // makes *this* thread -- when it is the one that drives the count to + // zero -- see the other producers' pushes, which is what makes the + // signal below meaningful. + if self.producers.fetch_sub(1, Ordering::AcqRel) != 1 { + return; + } + + // Disconnection is a wakeup like any other, and the only one nobody else + // can deliver. A consumer blocked on the doorbell would otherwise wait + // forever for an item that can no longer be sent. + // + // Only the *last* departure rings: an earlier one changes nothing a + // consumer could act on, and waking it to discover that would be a + // spurious wakeup per departing thread. + self.doorbell.signal(); + } + + /// Write an item into a claimed position and publish it. + /// + /// # Safety + /// + /// The caller must have claimed `position` by advancing the claim word, and + /// must not have published it already. A position is claimed by exactly one + /// producer, so this is the only writer of the slot. + unsafe fn publish(&self, position: u32, item: T) { + let slot = &self.slots[position as usize & self.mask]; + // SAFETY: the caller's claim makes this thread the only writer, and the + // room check that permitted the claim means the consumer has finished + // with whatever the slot held a lap ago. + unsafe { + (*slot.value.get()).write(item); + } + + // Release, and this is the publication: it must come after the write, + // and this is what forbids the compiler and the processor from moving it + // earlier. Until it lands, the consumer sees the slot as + // claimed-but-empty and skips it. + slot.sequence + .store(position.wrapping_add(1), Ordering::Release); + + // After the publication, never before: the doorbell says "there is + // something to take", and that must not become true before the item is + // actually takeable. A consumer woken early would find nothing, clear + // the doorbell, and go back to sleep on an item that is about to exist. + // + // A producer may signal while an *earlier* position is still + // unpublished, so the consumer wakes and finds nothing. That is a + // spurious wakeup, which the protocol tolerates by construction: the + // producer holding the earlier slot signals in its turn. + self.doorbell.signal(); + } +} + +impl Drop for Shared { + fn drop(&mut self) { + // Every handle is gone, so no synchronization is needed and the + // positions can be read directly. A slot between the two positions still + // holds an item nobody took, and dropping the queue must drop those + // rather than leak them. + // + // The sequence is consulted per slot rather than assuming every position + // in the range holds an item. A producer cannot be mid-push here -- it + // would have to hold a handle, and there are none -- so in practice + // every one does; the check states the invariant the read depends on + // instead of leaving it to that argument. + let mask = self.mask; + let head = *self.head.0.get_mut(); + let tail = position_of(*self.claim.0.get_mut()); + let mut position = head; + while position != tail { + let published = position.wrapping_add(1); + let slot = &mut self.slots[position as usize & mask]; + if *slot.sequence.get_mut() == published { + // SAFETY: the slot's sequence says the producer finished writing + // it and the consumer never took it, so it holds an initialized + // item. It is dropped exactly once, because `position` advances + // every iteration. + unsafe { + slot.value.get_mut().assume_init_drop(); + } + } + position = position.wrapping_add(1); + } + } +} + +/// A writing half of a [`reserving_mpsc`](self) queue. +/// +/// [`Clone`], so producers multiply by cloning rather than by sharing: each +/// thread owns its own handle. Not [`Sync`], so a handle is used by one thread +/// at a time. +pub struct Producer { + shared: Arc>, + /// Removes [`Sync`] without removing [`Send`]. A [`Cell`] is exactly that + /// shape, and no value of it is ever created. + not_sync: PhantomData>, +} + +impl Producer { + /// Appends an item, best-effort. + /// + /// **Cannot take a reserved slot.** A queue with one free slot and one + /// outstanding reservation refuses this, which is the reservation doing its + /// job rather than a malfunction. + /// + /// # Errors + /// + /// [`PushError::Full`] when no unreserved room remains, which is the + /// backpressure signal, and [`PushError::Disconnected`] when the consumer is + /// gone. Either way the item comes back. + pub fn push(&self, item: T) -> Result<(), PushError> { + // Relaxed: this load only proposes a claim. The compare-and-swap below + // is what makes it, and fails if the proposal was stale, so a stale read + // costs a retry rather than correctness. + let mut word = self.shared.claim.0.load(Ordering::Relaxed); + let position = loop { + let position = position_of(word); + let reserved = reserved_of(word); + + if !self.shared.has_room_beyond_reservations(position, reserved) { + // Report disconnection in preference to fullness: a full queue + // whose consumer is gone will never drain, and telling the + // caller to retry would be telling it to spin forever. + if !self.shared.consumer_live.load(Ordering::Acquire) { + return Err(PushError::Disconnected(item)); + } + return Err(PushError::Full(item)); + } + if !self.shared.consumer_live.load(Ordering::Acquire) { + return Err(PushError::Disconnected(item)); + } + + // Relaxed on both sides is sufficient: this exchange orders nothing + // but the claim itself. The item's visibility comes from the release + // store that publishes the slot, and the freedom to write the slot + // comes from the acquire load of `head` inside the room check. + // + // The reservation count is carried through unchanged, which is what + // makes a racing `reserve` fail its own exchange and re-read rather + // than have its increment silently overwritten. + match self.shared.claim.0.compare_exchange_weak( + word, + claim_word(reserved, position.wrapping_add(1)), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break position, + Err(actual) => word = actual, + } + }; + + // SAFETY: this thread's compare-and-swap claimed `position`, which no + // other producer can also have claimed, and it has not been published. + unsafe { + self.shared.publish(position, item); + } + Ok(()) + } + + /// Claims one slot for a message that must not be lost. + /// + /// See [`Reserving::reserve`](crate::Reserving::reserve) for what a + /// reservation is for. The short form: failing here is cheap, because no + /// work has been started yet, whereas failing at delivery means blocking or + /// losing the message. + /// + /// The queue stays connected while a reservation is outstanding, so a + /// consumer will not be told the stream ended and then handed the item. + #[must_use = "a reservation withholds capacity from every other producer until it is used or dropped"] + pub fn reserve(&self) -> Option> { + let mut word = self.shared.claim.0.load(Ordering::Relaxed); + loop { + let position = position_of(word); + let reserved = reserved_of(word); + + if !self.shared.has_room_beyond_reservations(position, reserved) { + return None; + } + + // The position is carried through unchanged: a reservation claims + // capacity, not an order. Where the item lands is decided when the + // reservation is redeemed, so a slot held for a long time does not + // stall everything queued behind it. + match self.shared.claim.0.compare_exchange_weak( + word, + claim_word(reserved + 1, position), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => { + // Relaxed: this thread already holds a live producer handle, + // so the count cannot reach zero during this call and no + // other thread's decision depends on when the increment + // becomes visible. The pairing that matters is in + // `release_producer`. + self.shared.producers.fetch_add(1, Ordering::Relaxed); + return Some(Reservation { + shared: Arc::clone(&self.shared), + not_sync: PhantomData, + }); + } + Err(actual) => word = actual, + } + } + } + + /// The exact number of items this queue holds when full. + #[must_use] + pub fn capacity(&self) -> usize { + self.shared.capacity + } + + /// Items currently held, as a snapshot. + #[must_use] + pub fn len(&self) -> usize { + self.shared.len() + } + + /// Whether the queue holds nothing, as a snapshot. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Slots currently claimed by a reservation and not yet redeemed, as a + /// snapshot. + #[must_use] + pub fn outstanding_reservations(&self) -> usize { + reserved_of(self.shared.claim.0.load(Ordering::Acquire)) as usize + } + + /// Whether the next best-effort push would be refused, as a snapshot. + /// + /// True when the queue is full *or* every remaining slot is reserved, since + /// those are indistinguishable to a best-effort caller. Advisory only: + /// another producer may take the last slot between this call and the push. + #[must_use] + pub fn is_full(&self) -> bool { + self.len() + self.outstanding_reservations() >= self.shared.capacity + } + + /// Whether the consumer has been dropped. + #[must_use] + pub fn is_disconnected(&self) -> bool { + !self.shared.consumer_live.load(Ordering::Acquire) + } +} + +impl Clone for Producer { + fn clone(&self) -> Self { + // Relaxed, for the reason given in `reserve`. + self.shared.producers.fetch_add(1, Ordering::Relaxed); + Self { + shared: Arc::clone(&self.shared), + not_sync: PhantomData, + } + } +} + +// Hand-written rather than derived: deriving would demand `T: Debug`, which +// would make a handle to a queue of non-`Debug` items un-printable for no +// reason. The item type is not the handle's business, so the handle reports the +// queue's state instead. +impl fmt::Debug for Producer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("reserving_mpsc::Producer") + .field("capacity", &self.capacity()) + .field("len", &self.len()) + .field("reserved", &self.outstanding_reservations()) + .field("producers", &self.shared.producers.load(Ordering::Relaxed)) + .field("disconnected", &self.is_disconnected()) + .finish() + } +} + +impl Drop for Producer { + fn drop(&mut self) { + self.shared.release_producer(); + } +} + +/// A slot claimed in advance, which [`Reservation::send`] redeems. +/// +/// Owned rather than borrowed from the [`Producer`], and [`Send`], because that +/// is the shape the use case has: an operation reserves its completion slot when +/// it is submitted and redeems it from whichever thread the completion arrives +/// on. ([`spsc`](crate::spsc)'s reservation borrows instead, because there the +/// producer handle *is* the single-producer guarantee and letting a reservation +/// outlive it would create a second one.) +/// +/// Dropping it returns the slot to the queue. +#[must_use = "a reservation withholds capacity from every other producer until it is used or dropped"] +pub struct Reservation { + shared: Arc>, + /// See [`Producer::not_sync`]. A reservation may be *moved* between threads + /// but is used by one at a time, exactly like the handle that made it. + not_sync: PhantomData>, +} + +impl Reservation { + /// Delivers into the reserved slot. + /// + /// **This cannot fail for want of room**, which is the entire purpose: the + /// slot was withheld from every other producer from the moment the + /// reservation was taken. See [`Disconnected`] for why that is the only + /// error and why the type says so. + /// + /// # Errors + /// + /// [`Disconnected`] if the consumer is gone, carrying the item back so it + /// can be accounted for rather than silently dropped. + pub fn send(self, item: T) -> Result<(), Disconnected> { + if !self.shared.consumer_live.load(Ordering::Acquire) { + // Dropping `self` on the way out releases the slot and the producer + // count, which is what should happen: this message is never coming. + return Err(Disconnected(item)); + } + + // Redeem and claim in ONE exchange: the count falls by one as the + // position rises by one, so `occupied + reserved` -- the quantity the + // whole invariant is about -- is never momentarily wrong, and no + // concurrent producer can observe a state in which this slot looks + // available. + // + // There is no room check here, and its absence is the guarantee. The + // invariant `occupied + reserved <= capacity` with `reserved >= 1` means + // `occupied < capacity`, so the slot at this position is one the + // consumer has already finished with. + let mut word = self.shared.claim.0.load(Ordering::Relaxed); + let position = loop { + let position = position_of(word); + let reserved = reserved_of(word); + debug_assert!( + reserved >= 1, + "this reservation is outstanding, so the count cannot be zero" + ); + + match self.shared.claim.0.compare_exchange_weak( + word, + claim_word(reserved - 1, position.wrapping_add(1)), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break position, + Err(actual) => word = actual, + } + }; + + // SAFETY: the exchange above claimed `position` for this thread alone, + // and the invariant argued for in the comment means the slot is free. + unsafe { + self.shared.publish(position, item); + } + + // The slot has been given up as part of the exchange above, so the + // `Drop` that would give it up again must not run. The producer count, + // however, still has to be released -- this reservation's promise is now + // fulfilled, and if it was the last outstanding one the stream ends + // here. + // + // **`mem::forget` would be wrong here, and was wrong here**: this type + // owns an `Arc`, and forgetting it leaks that strong reference, so the + // shared state is never dropped and every item still in the ring leaks + // with it. `ManuallyDrop` plus a move-out suppresses only *this type's* + // `Drop` while leaving the `Arc`'s own to run exactly once. + let this = core::mem::ManuallyDrop::new(self); + // SAFETY: `this` is a `ManuallyDrop`, so its own destructor never runs + // and the field is not read again after this move. + let shared = unsafe { core::ptr::read(&this.shared) }; + shared.release_producer(); + // `shared` falls out of scope here, releasing the reference this + // reservation held. + Ok(()) + } + + /// Whether the consumer has been dropped, so redeeming would fail. + #[must_use] + pub fn is_disconnected(&self) -> bool { + !self.shared.consumer_live.load(Ordering::Acquire) + } +} + +impl fmt::Debug for Reservation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("reserving_mpsc::Reservation") + .field("disconnected", &self.is_disconnected()) + .finish() + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + // Give the slot back. Only the count moves: the position is untouched, + // because an unredeemed reservation never occupied a position. + let mut word = self.shared.claim.0.load(Ordering::Relaxed); + loop { + let reserved = reserved_of(word); + debug_assert!( + reserved >= 1, + "this reservation is outstanding, so the count cannot be zero" + ); + match self.shared.claim.0.compare_exchange_weak( + word, + claim_word(reserved - 1, position_of(word)), + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(actual) => word = actual, + } + } + self.shared.release_producer(); + } +} + +/// The reading half of a [`reserving_mpsc`](self) queue. +/// +/// Neither [`Clone`] nor [`Sync`], which is what makes "single consumer" a fact +/// the compiler checks rather than a rule to remember. +pub struct Consumer { + shared: Arc>, + /// See [`Producer::not_sync`]. + not_sync: PhantomData>, +} + +impl Consumer { + /// Takes the oldest item, or `None` if there is none right now. + /// + /// `None` does not mean the queue is finished, and here it does not even + /// mean the queue is empty: a producer may have claimed the next position + /// and not yet published it. Order is claim order, so waiting is the only + /// correct answer -- and the producer signals when it publishes, so waiting + /// is not a gamble. + pub fn pop(&self) -> Option { + // Relaxed: this thread is the only writer of `head`. + let position = self.shared.head.0.load(Ordering::Relaxed); + let slot = &self.shared.slots[position as usize & self.shared.mask]; + // Acquire: pairs with the producer's release store, so an item it + // published is visible here. + if slot.sequence.load(Ordering::Acquire) != position.wrapping_add(1) { + return None; + } + + // SAFETY: the sequence says the producer that claimed this position + // finished writing it, and the release/acquire pair above makes that + // write visible here. This is the only consumer, and the position is + // given up below, so the item is read exactly once. + let item = unsafe { (*slot.value.get()).assume_init_read() }; + + // Release, and this is what frees the slot: a producer reads `head` with + // an acquire load to count free slots, so this store must not become + // visible before the read above completes, or that producer could claim + // the position and overwrite an item this thread had not finished + // taking. + // + // Note that nothing stores a "free again" sequence here, unlike `mpsc`. + // Advancing `head` *is* the release, because this shape's producers + // decide freedom from `head` rather than from the sequence. + self.shared + .head + .0 + .store(position.wrapping_add(1), Ordering::Release); + Some(item) + } + + /// The exact number of items this queue holds when full. + #[must_use] + pub fn capacity(&self) -> usize { + self.shared.capacity + } + + /// Items currently held, as a snapshot. + #[must_use] + pub fn len(&self) -> usize { + self.shared.len() + } + + /// Whether the queue holds nothing, as a snapshot. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Slots currently claimed by a reservation and not yet redeemed, as a + /// snapshot. + /// + /// Offered on the consumer as well as the producer because it is the + /// difference between "nothing is coming" and "something was promised": + /// a drained queue with an outstanding reservation is not an idle one. + #[must_use] + pub fn outstanding_reservations(&self) -> usize { + reserved_of(self.shared.claim.0.load(Ordering::Acquire)) as usize + } + + /// Whether every producer and every outstanding reservation is gone. + /// + /// **Check this only after [`Self::pop`] has returned `None`.** A producer + /// may push and then drop, so a queue can be disconnected and still hold + /// items; testing this first would discard them. + #[must_use] + pub fn is_disconnected(&self) -> bool { + self.shared.producers.load(Ordering::Acquire) == 0 + } + + /// Borrows the queue's readiness as a waitable `HANDLE`. + /// + /// The event is created on the first call, so a consumer that only ever + /// polls with [`Self::pop`] is charged for no kernel object. + /// + /// # Waiting on it correctly + /// + /// **Do not simply wait and then drain.** Use [`Self::arm`] to decide + /// whether waiting is safe, or the wait can miss an item and block forever; + /// [`spsc::Consumer::doorbell`](crate::spsc::Consumer::doorbell) carries the + /// worked example, and the protocol is identical here. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` on the first call. + pub fn doorbell(&self) -> io::Result> { + self.shared.doorbell.handle() + } + + /// A duplicate of [`Self::doorbell`] that the caller owns. + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` or `DuplicateHandle`. + pub fn doorbell_owned(&self) -> io::Result { + self.shared.doorbell.owned() + } + + /// Clears the doorbell and reports whether it is safe to wait on it. + /// + /// `true` means the queue had nothing takeable after the doorbell was + /// cleared, so any later push is guaranteed to signal and a wait cannot be + /// missed. `false` means something arrived in the meantime. + /// + /// Clearing must come before the check, which is the reverse of the order + /// that reads naturally; see [D-9](../../DESIGN-NOTES.md#d-9). + /// + /// # Errors + /// + /// Returns the error from `CreateEventW` on the first call. + pub fn arm(&self) -> io::Result { + // Before the clear, and so before the check: a producer running while no + // event exists skips signalling, so the check has to come after the + // event exists to catch what that skip left behind. + self.shared.doorbell.handle()?; + self.shared.doorbell.clear(); + #[cfg(test)] + crate::race_hooks::ARM.run(); + // Deliberately not `is_empty`: the question is whether `pop` would find + // something, and a claimed-but-unpublished slot is not something `pop` + // can find. + Ok(!self.shared.has_ready_item()) + } + + /// The last take before reporting the end of the stream. + /// + /// Called only after [`Self::is_disconnected`] has returned `true`, which + /// makes the answer final rather than a snapshot. It guards a race that is + /// real and narrow: a producer may push *and then* drop in the window + /// between a receive's first `pop` and its disconnection check. + fn finish(&self) -> Option { + self.pop() + } + + /// Takes the oldest item, blocking until one arrives. + /// + /// # Errors + /// + /// [`RecvError::Disconnected`] once every producer *and every outstanding + /// reservation* is gone and the queue is drained. [`RecvError::Io`] if the + /// doorbell cannot be created or waited on. + pub fn recv(&self) -> Result { + blocking::recv(self) + } + + /// Takes the oldest item, blocking until one arrives or the deadline passes. + /// + /// # Errors + /// + /// [`RecvTimeoutError::Timeout`] if the deadline passes with the queue still + /// empty, which is not a malfunction. Otherwise as [`Self::recv`]. + pub fn recv_timeout(&self, timeout: Duration) -> Result { + blocking::recv_timeout(self, timeout) + } +} + +impl Parked for Consumer { + type Item = T; + + fn pop(&self) -> Option { + Self::pop(self) + } + + fn finish(&self) -> Option { + Self::finish(self) + } + + fn arm(&self) -> io::Result { + Self::arm(self) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } + + fn doorbell(&self) -> io::Result> { + Self::doorbell(self) + } +} + +/// See [`Producer`]'s impl for why this is hand-written. +impl fmt::Debug for Consumer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("reserving_mpsc::Consumer") + .field("capacity", &self.capacity()) + .field("len", &self.len()) + .field("reserved", &self.outstanding_reservations()) + .field("producers", &self.shared.producers.load(Ordering::Relaxed)) + .field("disconnected", &self.is_disconnected()) + .finish() + } +} + +impl Drop for Consumer { + fn drop(&mut self) { + self.shared.consumer_live.store(false, Ordering::Release); + } +} + +impl crate::Producer for Producer { + type Item = T; + + fn push(&self, item: T) -> Result<(), PushError> { + Self::push(self, item) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } +} + +impl crate::Reserving for Producer { + type Item = T; + type Reservation<'a> + = Reservation + where + Self: 'a; + + fn reserve(&self) -> Option> { + Self::reserve(self) + } + + fn outstanding_reservations(&self) -> usize { + Self::outstanding_reservations(self) + } +} + +impl crate::Consumer for Consumer { + type Item = T; + + fn pop(&self) -> Option { + Self::pop(self) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } +} + +impl crate::Bounded for Producer { + fn capacity(&self) -> usize { + Self::capacity(self) + } + + fn len(&self) -> usize { + Self::len(self) + } + + fn is_empty(&self) -> bool { + Self::is_empty(self) + } +} + +impl crate::Bounded for Consumer { + fn capacity(&self) -> usize { + Self::capacity(self) + } + + fn len(&self) -> usize { + Self::len(self) + } + + fn is_empty(&self) -> bool { + Self::is_empty(self) + } +} + +impl crate::Waitable for Consumer { + fn doorbell(&self) -> io::Result> { + Self::doorbell(self) + } + + fn doorbell_owned(&self) -> io::Result { + Self::doorbell_owned(self) + } + + fn arm(&self) -> io::Result { + Self::arm(self) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs new file mode 100644 index 00000000..68470d98 --- /dev/null +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -0,0 +1,758 @@ +// Copyright (c) Mike Grier. + +//! Tests for the reserving MPSC bounded array queue. +//! +//! The shape's queueing behaviour is `mpsc`'s and is covered there; what is +//! tested here is the part that is different -- the reservation, the packed +//! claim word, and the ways the two interact with everything else. +//! +//! The load-bearing property is stated once and asserted from several angles: +//! **a granted reservation is always redeemable.** A test that only checked +//! "reserve then send works on an idle queue" would assert nothing, because the +//! failure mode is a reservation granted while a racing producer takes the last +//! slot -- so the interesting cases all put the queue under pressure first. + +use super::{ + BOUNDS_MAX, Consumer, Producer, Reservation, bounded, claim_word, position_of, reserved_of, +}; +use crate::race_hooks; +// The trait is imported anonymously because this module also names the concrete +// `Consumer` type, and only its `drain` method is wanted here. That the two can +// coexist is the point made in `traits`: the trait is named for the role and the +// handle is named for the role, and a caller who wants only the methods says so. +use crate::Consumer as _; +use crate::{Bounded, PushError, RecvError, Reserving}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::Duration; + +/// Counts its own drops, so a test can prove an item was destroyed rather than +/// leaked. `Arc` rather than a `static`, so tests that run +/// concurrently in one process cannot see each other's counts. +#[derive(Debug)] +struct DropCounter(Arc); + +impl Drop for DropCounter { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +/// Fills every slot a best-effort producer is allowed to take. +/// +/// Returns how many went in, which is the capacity less whatever is reserved. +fn fill(producer: &Producer, item: T) -> usize { + let mut pushed = 0; + while producer.push(item.clone()).is_ok() { + pushed += 1; + } + pushed +} + +// --------------------------------------------------------------------------- +// The packed claim word. +// +// Tested directly as well as through the queue: the packing is arithmetic, and +// arithmetic is worth checking at its edges rather than only through the six +// layers of queue that happen to use it. +// --------------------------------------------------------------------------- + +#[test] +fn the_claim_word_round_trips_both_halves() { + for &reserved in &[0_u32, 1, 2, 1000, u32::MAX - 1, u32::MAX] { + for &position in &[0_u32, 1, 2, 1000, u32::MAX - 1, u32::MAX] { + let word = claim_word(reserved, position); + assert_eq!( + (reserved_of(word), position_of(word)), + (reserved, position), + "packing must be lossless in both halves, including at their extremes" + ); + } + } +} + +#[test] +fn the_two_halves_do_not_bleed_into_each_other() { + // The mistake packing invites: a position that wraps must not carry into + // the reservation count, and a count must not appear as a position. + let word = claim_word(0, u32::MAX); + assert_eq!( + reserved_of(word), + 0, + "a maximal position leaves the count at zero" + ); + + let word = claim_word(u32::MAX, 0); + assert_eq!( + position_of(word), + 0, + "a maximal count leaves the position at zero" + ); + + // And an increment of the position at its maximum wraps within its own half + // rather than incrementing the count, which is what the queue relies on + // every time a position laps. + let wrapped = claim_word(7, u32::MAX.wrapping_add(1)); + assert_eq!((reserved_of(wrapped), position_of(wrapped)), (7, 0)); +} + +// The relationship between the split and the ceiling is deliberately NOT tested +// here. It is a fact about constants, so it lives as a `const` assertion beside +// `BOUNDS` in the parent module, where changing the split without changing the +// ceiling fails to compile. A test would have been the weaker instrument: it can +// only report after the fact, and only on a build somebody chose to run. + +#[test] +fn a_capacity_above_this_shapes_ceiling_is_refused_even_though_others_accept_it() { + // The bound is a property of the shape, which is exactly what D-12 argued + // and what this shape is the second instance of. `mpsc` takes this capacity + // happily; the packing means this one cannot. + let error = bounded::(BOUNDS_MAX * 2).expect_err("beyond the packed position's range"); + assert_eq!(error.max_valid(), BOUNDS_MAX); + assert_eq!( + error.previous_valid(), + Some(BOUNDS_MAX), + "and the correction offered is this shape's own ceiling" + ); +} + +// --------------------------------------------------------------------------- +// The reservation guarantee. +// --------------------------------------------------------------------------- + +#[test] +fn a_reserved_slot_is_delivered_into_a_queue_that_is_otherwise_full() { + // The whole contract in one test: reserve, let the best-effort path fill + // everything it is allowed to, and redeem anyway. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("a fresh queue has room"); + + let pushed = fill(&tx, 1); + assert_eq!(pushed, 3, "the reservation withheld exactly one slot"); + assert!(tx.is_full(), "and now nothing more may be pushed"); + + slot.send(99).expect("the room was already ours"); + + let drained: Vec = rx.drain().collect(); + assert_eq!( + drained, + vec![1, 1, 1, 99], + "the reserved item lands where it was redeemed, not where it was claimed" + ); +} + +#[test] +fn a_reservation_withholds_a_slot_from_the_best_effort_path() { + let (tx, _rx) = bounded::(8).expect("8 is a valid capacity"); + assert_eq!( + fill(&tx, 0), + 8, + "with nothing reserved, every slot is available" + ); + + let (tx, _rx) = bounded::(8).expect("8 is a valid capacity"); + let reservations: Vec<_> = (0..3).map(|_| tx.reserve().expect("room")).collect(); + assert_eq!(tx.outstanding_reservations(), 3); + assert_eq!( + fill(&tx, 0), + 5, + "three reserved leaves five for the best-effort path" + ); + drop(reservations); +} + +#[test] +fn dropping_a_reservation_returns_the_slot() { + let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + assert_eq!(tx.outstanding_reservations(), 1); + + drop(slot); + assert_eq!(tx.outstanding_reservations(), 0); + assert_eq!( + fill(&tx, 0), + 4, + "a released reservation is capacity given back, not capacity lost" + ); +} + +#[test] +fn a_redeemed_reservation_does_not_also_release_its_slot() { + // The double-release bug this shape's `send` avoids by consuming `self` and + // suppressing the drop. If both ran, the count would underflow and the + // queue would over-admit for ever afterwards. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + for round in 0..10 { + let slot = tx.reserve().expect("room"); + assert_eq!(tx.outstanding_reservations(), 1); + slot.send(round).expect("the room was ours"); + assert_eq!( + tx.outstanding_reservations(), + 0, + "redeeming releases the claim exactly once" + ); + assert_eq!(rx.pop(), Some(round)); + } + assert_eq!( + fill(&tx, 0), + 4, + "and the capacity is intact after ten cycles" + ); +} + +#[test] +fn reserving_fails_when_every_slot_is_spoken_for() { + let (tx, _rx) = bounded::(2).expect("2 is a valid capacity"); + let _first = tx.reserve().expect("room"); + let _second = tx.reserve().expect("room"); + assert!( + tx.reserve().is_none(), + "reservations are drawn from the same capacity as everything else" + ); + + let (tx, _rx) = bounded::(2).expect("2 is a valid capacity"); + fill(&tx, 0); + assert!( + tx.reserve().is_none(), + "and a full queue has nothing left to promise" + ); +} + +#[test] +fn a_full_queue_refuses_a_best_effort_push_and_hands_the_item_back() { + let (tx, rx) = bounded::(2).expect("2 is a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + + match tx.push(3) { + Err(PushError::Full(returned)) => assert_eq!(returned, 3), + other => panic!("expected Full, got {other:?}"), + } + assert_eq!(rx.pop(), Some(1)); + assert_eq!(rx.pop(), Some(2)); +} + +#[test] +fn a_push_refused_for_a_reservation_is_still_reported_as_full() { + // A best-effort caller cannot tell "no slots" from "the only slot is + // reserved", and should not have to: both mean "no room for you", both are + // backpressure, and both clear when the queue drains. + let (tx, _rx) = bounded::(2).expect("2 is a valid capacity"); + let _slot = tx.reserve().expect("room"); + tx.push(1).expect("one slot is unreserved"); + + assert!( + matches!(tx.push(2), Err(PushError::Full(2))), + "the reserved slot is not available to the best-effort path" + ); + assert!(!tx.is_empty(), "yet the queue is demonstrably not empty"); +} + +// --------------------------------------------------------------------------- +// The guarantee under contention, which is the reason the claim word is packed. +// --------------------------------------------------------------------------- + +/// How many producer threads the concurrent tests use. +/// +/// Fixed rather than derived from the machine's core count, so a failure +/// reproduces on the machine that reported it. +const PRODUCERS: usize = 4; + +#[test] +fn every_granted_reservation_is_redeemable_under_contention() { + // **The test the packed claim word exists to pass.** With the count in its + // own atomic, a pushing producer and a reserving one can each read before + // the other's write, and the queue grants a slot that does not exist. That + // shows up here as a `send` finding no room -- which, because the invariant + // it violates is checked by a debug assertion in `send`, aborts the test + // rather than quietly corrupting the ring. + // + // A small capacity and many threads, because the race needs the queue to be + // near-full continuously for the two paths to collide at the boundary. + const ROUNDS: usize = 2_000; + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + + let threads: Vec<_> = (0..PRODUCERS) + .map(|producer| { + let handle = tx.clone(); + thread::spawn(move || { + let mut granted = 0_usize; + for round in 0..ROUNDS { + // Alternate the two paths so both are contending for the + // same last slot rather than taking turns. + if round % 2 == 0 { + let _ = handle.push(producer); + } else if let Some(slot) = handle.reserve() { + granted += 1; + slot.send(producer).expect("a granted slot is guaranteed"); + } + } + granted + }) + }) + .collect(); + drop(tx); + + // Drain continuously, so the queue keeps returning to the near-full + // boundary instead of simply staying full. + let mut received = 0_usize; + while let Ok(item) = rx.recv() { + assert!(item < PRODUCERS, "items must not be torn or invented"); + received += 1; + } + + let granted: usize = threads + .into_iter() + .map(|thread| thread.join().expect("no producer may panic")) + .sum(); + + assert!( + granted > 0, + "the run must actually have exercised reservations" + ); + assert!( + received >= granted, + "every reservation that was granted must have been delivered: \ + {granted} granted, only {received} items arrived in total" + ); +} + +#[test] +fn a_reservation_holds_capacity_against_every_other_producer() { + // Not just against the thread that took it. Reserve on one thread, fill + // from others, and redeem: the slot must have survived their contention. + let (tx, rx) = bounded::(8).expect("8 is a valid capacity"); + let slot = tx.reserve().expect("room"); + + let threads: Vec<_> = (0..PRODUCERS) + .map(|_| { + let handle = tx.clone(); + thread::spawn(move || while handle.push(0).is_ok() {}) + }) + .collect(); + for thread in threads { + thread.join().expect("no producer may panic"); + } + + assert_eq!(tx.len(), 7, "seven taken, one withheld"); + slot.send(99).expect("the withheld slot is still ours"); + assert_eq!(rx.len(), 8); +} + +#[test] +fn a_reservation_can_be_redeemed_from_another_thread() { + // The shape of the real use case, and the reason this shape's reservation + // is owned rather than borrowed: claim the slot where the work is + // submitted, redeem it wherever the completion lands. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + fill(&tx, 1); + + thread::spawn(move || { + slot.send(99).expect("the room was claimed before the move"); + }) + .join() + .expect("the redeeming thread must not panic"); + + let drained: Vec = rx.drain().collect(); + assert_eq!(drained.last(), Some(&99)); +} + +#[test] +fn a_reservation_is_send_but_not_sync() { + fn assert_send() {} + assert_send::>(); + assert_send::>(); + assert_send::>(); + + // `!Sync` is asserted by the absence of any test that shares one across + // threads: the compiler refuses to write it. +} + +// --------------------------------------------------------------------------- +// Disconnection, which a reservation participates in. +// --------------------------------------------------------------------------- + +#[test] +fn an_outstanding_reservation_keeps_the_stream_open() { + // **A reservation is a promise of a message still to come.** If dropping + // the last producer ended the stream while one was outstanding, the + // consumer would be told the queue was finished and then handed an item -- + // losing exactly the message the reservation existed to protect. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + drop(tx); + + assert!( + !rx.is_disconnected(), + "a promise outstanding is a producer outstanding" + ); + + slot.send(7).expect("the consumer is alive"); + assert!( + rx.is_disconnected(), + "and redeeming the last one does end the stream" + ); + assert_eq!(rx.pop(), Some(7), "with the promised item still owed"); +} + +#[test] +fn dropping_an_outstanding_reservation_also_ends_the_stream() { + // The other half: a promise abandoned is still a promise resolved, so the + // consumer must not be left waiting on it for ever. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + drop(tx); + assert!(!rx.is_disconnected()); + + drop(slot); + assert!( + rx.is_disconnected(), + "an abandoned promise resolves the stream" + ); +} + +#[test] +fn a_blocked_consumer_is_woken_by_the_last_reservation_being_redeemed() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + drop(tx); + + let sender = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + slot.send(7).expect("the consumer is alive"); + }); + + assert_eq!( + rx.recv().expect("the reservation is redeemed"), + 7, + "a parked consumer must be woken by a reserved delivery like any other" + ); + assert!(matches!(rx.recv(), Err(RecvError::Disconnected))); + sender.join().expect("the sender must not panic"); +} + +#[test] +fn a_blocked_consumer_is_woken_by_the_last_reservation_being_dropped() { + // Caught as a hang if the drop path forgets to release the producer count + // or to ring the doorbell. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + drop(tx); + + let abandoner = thread::spawn(move || { + thread::sleep(Duration::from_millis(50)); + drop(slot); + }); + + assert!( + matches!(rx.recv(), Err(RecvError::Disconnected)), + "abandoning the last promise must wake a parked consumer" + ); + abandoner + .join() + .expect("the abandoning thread must not panic"); +} + +#[test] +fn redeeming_into_a_departed_consumer_hands_the_item_back() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + drop(rx); + + assert!(slot.is_disconnected()); + let error = slot.send(7).expect_err("nobody is left to take it"); + assert_eq!( + error.into_inner(), + 7, + "an item important enough to reserve for must not be dropped silently" + ); +} + +#[test] +fn an_abandoned_reservation_leaves_the_queue_usable() { + // A reservation that fails to be redeemed must not poison the capacity. + let (tx, rx) = bounded::(2).expect("2 is a valid capacity"); + for _ in 0..50 { + let slot = tx.reserve().expect("room"); + drop(slot); + } + assert_eq!(tx.outstanding_reservations(), 0); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + assert!(tx.push(3).is_err()); + assert_eq!(rx.pop(), Some(1)); +} + +// --------------------------------------------------------------------------- +// Queue behaviour, kept honest against the shape it is a variant of. +// --------------------------------------------------------------------------- + +#[test] +fn items_come_out_in_the_order_they_went_in() { + let (tx, rx) = bounded::(8).expect("a power-of-two capacity"); + for value in 0..8 { + tx.push(value).expect("room for eight"); + } + let drained: Vec = rx.drain().collect(); + assert_eq!(drained, (0..8).collect::>()); +} + +#[test] +fn the_ring_wraps_many_times_without_losing_order() { + // The test that indicts the position arithmetic, and it matters more here + // than in `mpsc`: this shape decides a slot is free from the consumer's + // position rather than from the slot's own sequence, so an error in the + // wrapping subtraction is a use-after-free rather than a wrong answer. + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + for round in 0..2000 { + tx.push(round).expect("the previous item was taken"); + assert_eq!(rx.pop(), Some(round)); + } + assert!(rx.is_empty()); +} + +#[test] +fn a_partly_full_ring_wraps_correctly_with_a_reservation_held_throughout() { + // Keeps a reservation outstanding across hundreds of laps, so the count + // must survive every position wrap in the packed word. + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + let slot = tx.reserve().expect("room"); + + for round in 0..500 { + tx.push(round).expect("three slots remain unreserved"); + assert_eq!(rx.pop(), Some(round)); + assert_eq!(tx.outstanding_reservations(), 1, "round {round}"); + } + + slot.send(99).expect("still ours after five hundred laps"); + assert_eq!(rx.pop(), Some(99)); +} + +#[test] +fn zero_sized_items_round_trip() { + let (tx, rx) = bounded::<()>(2).expect("a power-of-two capacity"); + let slot = tx.reserve().expect("room"); + tx.push(()).expect("room"); + assert!(matches!(tx.push(()), Err(PushError::Full(())))); + slot.send(()).expect("the room was ours"); + assert_eq!(rx.pop(), Some(())); + assert_eq!(rx.pop(), Some(())); + assert_eq!(rx.pop(), None); +} + +#[test] +fn dropping_the_queue_drops_the_items_it_still_holds() { + let drops = Arc::new(AtomicUsize::new(0)); + { + let (tx, _rx) = bounded::(8).expect("a power-of-two capacity"); + let slot = tx.reserve().expect("room"); + for _ in 0..5 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + } + slot.send(DropCounter(Arc::clone(&drops))) + .expect("the room was ours"); + assert_eq!(drops.load(Ordering::Relaxed), 0, "nothing dropped yet"); + } + assert_eq!( + drops.load(Ordering::Relaxed), + 6, + "every undrained item must be dropped, not leaked -- including the reserved one" + ); +} + +#[test] +fn dropping_the_queue_after_a_wrap_drops_only_what_is_resident() { + let drops = Arc::new(AtomicUsize::new(0)); + { + let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); + for _ in 0..6 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + rx.pop().expect("an item"); + } + assert_eq!(drops.load(Ordering::Relaxed), 6); + for _ in 0..3 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + } + } + assert_eq!( + drops.load(Ordering::Relaxed), + 9, + "the three still resident must also be dropped" + ); +} + +#[test] +fn many_producers_deliver_every_item_exactly_once() { + const PER_PRODUCER: usize = 500; + let (tx, rx) = bounded::<(usize, usize)>(16).expect("a valid capacity"); + + let threads: Vec<_> = (0..PRODUCERS) + .map(|producer| { + let handle = tx.clone(); + thread::spawn(move || { + for sequence in 0..PER_PRODUCER { + let mut item = (producer, sequence); + while let Err(PushError::Full(returned)) = handle.push(item) { + item = returned; + std::hint::spin_loop(); + } + } + }) + }) + .collect(); + drop(tx); + + let mut per_producer = [0_usize; PRODUCERS]; + while let Ok((producer, sequence)) = rx.recv() { + assert_eq!( + sequence, per_producer[producer], + "a producer's own items must arrive in that producer's order" + ); + per_producer[producer] += 1; + } + for thread in threads { + thread.join().expect("no producer may panic"); + } + assert!(per_producer.iter().all(|count| *count == PER_PRODUCER)); +} + +// --------------------------------------------------------------------------- +// The doorbell, which behaves as it does everywhere else. +// --------------------------------------------------------------------------- + +#[test] +fn polling_never_creates_a_kernel_object() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + slot.send(1).expect("the room was ours"); + while rx.pop().is_some() {} + drop(tx); + while rx.pop().is_some() {} + + assert!( + !rx.shared.doorbell.is_armed(), + "a poll-only consumer must allocate no kernel object, reservations included" + ); +} + +#[test] +fn a_reserved_delivery_lights_the_doorbell() { + // A reserved send is a delivery like any other, so it must ring. If it did + // not, a consumer parked on the doorbell would sleep through precisely the + // message that was important enough to reserve a slot for. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + let slot = tx.reserve().expect("room"); + assert!(rx.arm().expect("arming must succeed"), "nothing yet"); + + slot.send(1).expect("the room was ours"); + assert!( + !rx.arm().expect("arming must succeed"), + "a reserved delivery must be visible to the arming protocol" + ); +} + +#[test] +fn the_real_arm_finds_an_item_that_lands_inside_its_window() { + // The same deterministic indictment of the reversed order used by the other + // shapes, driven through this one's `arm`. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + let safe_to_wait = race_hooks::ARM.with( + move || { + tx.push(1).expect("there is room"); + }, + || rx.arm().expect("arming must succeed"), + ); + + assert!( + !safe_to_wait, + "an item landing between the clear and the check must be found, not waited past" + ); +} + +#[test] +fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { + let (_tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + let safe_to_wait = race_hooks::ARM.with(|| {}, || rx.arm().expect("arming must succeed")); + assert!(safe_to_wait, "nothing arrived, so waiting is right"); +} + +// --------------------------------------------------------------------------- +// Through the traits, which is where this shape and `mpsc` visibly differ. +// --------------------------------------------------------------------------- + +#[test] +fn the_shape_is_usable_through_the_reserving_trait() { + fn reserve_and_send

(producer: &P, item: P::Item) -> bool + where + P: Reserving + Bounded, + P::Item: Copy, + for<'a> P::Reservation<'a>: ReservationLike, + { + let before = producer.outstanding_reservations(); + let Some(slot) = producer.reserve() else { + return false; + }; + assert_eq!(producer.outstanding_reservations(), before + 1); + slot.deliver(item).is_ok() + } + + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + assert!(reserve_and_send(&tx, 7)); + assert_eq!(rx.pop(), Some(7)); +} + +/// The one operation the [`Reserving`] trait deliberately does not name. +/// +/// Redeeming consumes the reservation and hands back a shape-specific error, so +/// putting it on the trait would have meant an associated error type carried for +/// the sake of one method. The trait names how a claim is *obtained*, which is +/// the part a generic caller needs; a caller generic over redeeming as well can +/// say so itself, as this does. +trait ReservationLike { + type Error; + fn deliver(self, item: T) -> Result<(), Self::Error>; +} + +impl ReservationLike for Reservation { + type Error = crate::Disconnected; + + fn deliver(self, item: T) -> Result<(), Self::Error> { + self.send(item) + } +} + +impl ReservationLike for crate::spsc::Reservation<'_, T> { + type Error = crate::Disconnected; + + fn deliver(self, item: T) -> Result<(), Self::Error> { + self.send(item) + } +} + +#[test] +fn both_reserving_shapes_satisfy_the_trait() { + // The D-3 check, run for the `Reserving` trait: two implementations that do + // not resemble each other internally, one handing out a borrowed + // reservation and one an owned one, reached through the same generic code. + fn claim_one(producer: &P) -> Option> { + producer.reserve() + } + + let (spsc_tx, _spsc_rx) = crate::spsc::bounded::(4).expect("4 is valid for both"); + let (mpsc_tx, _mpsc_rx) = bounded::(4).expect("4 is valid for both"); + + assert!(claim_one(&spsc_tx).is_some()); + assert!(claim_one(&mpsc_tx).is_some()); + assert_eq!( + spsc_tx.outstanding_reservations(), + 0, + "the claim was dropped" + ); + assert_eq!(mpsc_tx.outstanding_reservations(), 0); +} diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 54751a89..05f6e411 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -75,18 +75,24 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; -use crate::capacity::validate_capacity; +use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; use crate::doorbell::Doorbell; -use crate::error::{CapacityError, PushError, RecvError, RecvTimeoutError}; +use crate::error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; -/// The smallest capacity this shape can represent. +/// What this shape accepts as a capacity. /// -/// One, and there is nothing to work around: a single slot is either inside -/// `[head, tail)` or outside it, and those are the only two states this shape's -/// positions have to distinguish. [`mpsc`](crate::mpsc) needs two, because its -/// slots carry a third state, and that difference is why each shape names its -/// own minimum rather than sharing one. -const MIN_CAPACITY: usize = 1; +/// The minimum is one, and there is nothing to work around: a single slot is +/// either inside `[head, tail)` or outside it, and those are the only two +/// states this shape's positions have to distinguish. [`mpsc`](crate::mpsc) +/// needs two, because its slots carry a third state, and that difference is why +/// each shape names its own bounds rather than sharing one pair. +/// +/// The maximum is the widest any shape may be, because this one's positions are +/// full-width [`usize`] values with nothing packed beside them. +const BOUNDS: Bounds = Bounds { + min: 1, + max: WRAPPING_MAX_CAPACITY, +}; /// Creates a single-producer, single-consumer bounded ring. /// @@ -111,7 +117,7 @@ const MIN_CAPACITY: usize = 1; /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { - validate_capacity(capacity, MIN_CAPACITY)?; + validate_capacity(capacity, BOUNDS)?; let mut slots = Vec::with_capacity(capacity); slots.resize_with(capacity, || UnsafeCell::new(MaybeUninit::uninit())); @@ -124,6 +130,7 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit tail: CacheAligned(AtomicUsize::new(0)), producer_live: AtomicBool::new(true), consumer_live: AtomicBool::new(true), + reserved: AtomicUsize::new(0), doorbell: Doorbell::new(), }); @@ -149,6 +156,18 @@ struct Shared { tail: CacheAligned, producer_live: AtomicBool, consumer_live: AtomicBool, + /// Slots claimed by a [`Reservation`] and not yet redeemed. + /// + /// **Written only by the producer's thread**, which is what makes + /// reservation nearly free in this shape: `reserve`, `Reservation::send` and + /// `Reservation::drop` all run on the single producer, so a plain load and + /// store suffice where [`reserving_mpsc`](crate::reserving_mpsc) needs a + /// compare-and-swap against a packed word. The line is exclusive to that + /// core, so the extra read on the push path costs essentially nothing. + /// + /// Atomic rather than a [`Cell`] only because the consumer reads it as a + /// metric, and a torn read of a metric is still undefined behaviour. + reserved: AtomicUsize, /// Readiness as a waitable `HANDLE`. Costs nothing until somebody asks for /// the handle, so a polling consumer never allocates a kernel object. doorbell: Doorbell, @@ -177,6 +196,41 @@ impl Shared { let head = self.head.0.load(Ordering::Acquire); tail.wrapping_sub(head) } + + /// Write an item into the slot at `tail` and publish it. + /// + /// Shared by [`Producer::push`] and [`Reservation::send`] so that the + /// ordering argument below is made once. The two differ only in how they + /// established that there is room -- one checked, the other was promised -- + /// and nothing downstream of that decision should be written twice. + /// + /// # Safety + /// + /// The caller must have established that the slot at `tail` is free: either + /// by the room check in `push`, or by holding a reservation. + unsafe fn publish(&self, tail: usize, item: T) { + // SAFETY: the caller's precondition says this slot holds no initialized + // item, so writing a `MaybeUninit` over it drops nothing. + unsafe { + (*self.slots[tail & self.mask].get()).write(item); + } + + // Release: publishes the slot write to the consumer's acquire load. The + // store must come after the write, and this is what forbids the + // compiler and the processor from moving it earlier. + self.tail.0.store(tail.wrapping_add(1), Ordering::Release); + + // After the release store, never before: the doorbell says "there is + // something to take", and that must not become true before the item is + // actually takeable. A consumer woken early would find the queue empty, + // clear the doorbell, and go back to sleep on an item that is about to + // exist -- a lost wakeup manufactured by signalling too eagerly. + // + // Cheap when it is redundant: `signal` returns without a syscall if the + // doorbell is already lit, so a producer running ahead of its consumer + // pays one atomic per push rather than one `SetEvent`. + self.doorbell.signal(); + } } impl Drop for Shared { @@ -212,11 +266,15 @@ pub struct Producer { } impl Producer { - /// Appends an item. + /// Appends an item, best-effort. + /// + /// **Cannot take a reserved slot.** A queue with one free slot and one + /// outstanding [`Reservation`] refuses this, which is the reservation doing + /// its job rather than a malfunction. /// /// # Errors /// - /// [`PushError::Full`] when the queue is at capacity, which is the + /// [`PushError::Full`] when no unreserved room remains, which is the /// backpressure signal rather than a malfunction, and /// [`PushError::Disconnected`] when the consumer is gone. Either way the /// item comes back, so nothing is lost by the refusal. @@ -227,8 +285,14 @@ impl Producer { // Acquire: pairs with the consumer's release store, so a slot it freed // is visible as free here. let head = self.shared.head.0.load(Ordering::Acquire); - - if tail.wrapping_sub(head) == self.shared.capacity { + // Relaxed, and this is the whole cost of reservation on this shape: the + // only writer of `reserved` is this thread, so the line is exclusive to + // this core and cannot hold a stale value of its own. + let reserved = self.shared.reserved.load(Ordering::Relaxed); + + // The sum cannot overflow: each term is at most the capacity, which is + // itself at most half of `usize::MAX`. + if tail.wrapping_sub(head) + reserved >= self.shared.capacity { // Report disconnection in preference to fullness: a full queue // whose consumer is gone will never drain, and telling the caller // to retry would be telling it to spin forever. @@ -242,33 +306,13 @@ impl Producer { } // SAFETY: `tail` is outside `[head, tail)`, so this slot is owned by - // the producer and holds no initialized item. Writing a `MaybeUninit` - // over uninitialized memory drops nothing. + // the producer and holds no initialized item, and the room check above + // left it unclaimed by any reservation. unsafe { - (*self.shared.slots[tail & self.shared.mask].get()).write(item); + self.shared.publish(tail, item); } - - // Release: publishes the slot write to the consumer's acquire load. The - // store must come after the write, and this is what forbids the - // compiler and the processor from moving it earlier. - self.shared - .tail - .0 - .store(tail.wrapping_add(1), Ordering::Release); - - // After the release store, never before: the doorbell says "there is - // something to take", and that must not become true before the item is - // actually takeable. A consumer woken early would find the queue empty, - // clear the doorbell, and go back to sleep on an item that is about to - // exist -- a lost wakeup manufactured by signalling too eagerly. - // - // Cheap when it is redundant: `signal` returns without a syscall if the - // doorbell is already lit, so a producer running ahead of its consumer - // pays one atomic per push rather than one `SetEvent`. - self.shared.doorbell.signal(); Ok(()) } - /// The exact number of items this queue holds when full. #[must_use] pub fn capacity(&self) -> usize { @@ -287,14 +331,58 @@ impl Producer { self.len() == 0 } - /// Whether the next push would be refused for want of room, as a snapshot. + /// Whether the next best-effort push would be refused, as a snapshot. + /// + /// True when the queue is full *or* every remaining slot is reserved, since + /// those are indistinguishable to a best-effort caller. /// /// Advisory only. Nothing is gained by testing it before [`Self::push`], /// which reports the same condition without the window in between; it is /// offered for metrics rather than for control flow. #[must_use] pub fn is_full(&self) -> bool { - self.len() == self.shared.capacity + self.len() + self.outstanding_reservations() >= self.shared.capacity + } + + /// Slots currently claimed by a [`Reservation`] and not yet redeemed. + #[must_use] + pub fn outstanding_reservations(&self) -> usize { + self.shared.reserved.load(Ordering::Relaxed) + } + + /// Claims one slot for a message that must not be lost. + /// + /// See [`Reserving::reserve`](crate::Reserving::reserve) for what a + /// reservation is for. The short form: failing here is cheap, because no + /// work has been started yet, whereas failing at delivery means blocking or + /// losing the message. + /// + /// **The reservation borrows this producer**, which is not an arbitrary + /// choice of ownership. This shape is sound because exactly one thread ever + /// writes the ring, and the producer handle is what makes that true -- it is + /// neither [`Clone`] nor [`Sync`]. An owned reservation could be moved to a + /// second thread while the producer stayed on the first, and then two + /// threads would be writing. Borrowing pins the producer for as long as any + /// reservation is outstanding, so the compiler enforces what the shape + /// requires. [`reserving_mpsc`](crate::reserving_mpsc), which has no such + /// constraint, hands out an owned reservation instead. + #[must_use = "a reservation withholds capacity from the best-effort path until it is used or dropped"] + pub fn reserve(&self) -> Option> { + let tail = self.shared.tail.0.load(Ordering::Relaxed); + let head = self.shared.head.0.load(Ordering::Acquire); + let reserved = self.shared.reserved.load(Ordering::Relaxed); + + if tail.wrapping_sub(head) + reserved >= self.shared.capacity { + return None; + } + + // A plain store, where `reserving_mpsc` needs a compare-and-swap against + // a packed word: there is only one producer, so `reserve`, `push` and + // the redemption all run on this thread and cannot interleave with each + // other. That is the entire difference between the two shapes' + // reservation machinery, and it is why this one costs nothing. + self.shared.reserved.store(reserved + 1, Ordering::Relaxed); + Some(Reservation { producer: self }) } /// Whether the consumer has been dropped. @@ -333,6 +421,93 @@ impl Drop for Producer { } } +/// A slot claimed in advance, which [`Reservation::send`] redeems. +/// +/// Borrows the [`Producer`] that made it, so the producer cannot move to +/// another thread while a claim is outstanding. See [`Producer::reserve`] for +/// why that is a soundness requirement here and not merely a style choice. +/// +/// Dropping it returns the slot to the best-effort path. +#[must_use = "a reservation withholds capacity from the best-effort path until it is used or dropped"] +pub struct Reservation<'a, T> { + producer: &'a Producer, +} + +impl Reservation<'_, T> { + /// Delivers into the reserved slot. + /// + /// **This cannot fail for want of room**, which is the entire purpose: the + /// slot was withheld from the best-effort path from the moment the + /// reservation was taken. See [`Disconnected`] for why that is the only + /// error and why the type says so. + /// + /// # Errors + /// + /// [`Disconnected`] if the consumer is gone, carrying the item back so it + /// can be accounted for rather than silently dropped. + pub fn send(self, item: T) -> Result<(), Disconnected> { + let shared = &self.producer.shared; + if !shared.consumer_live.load(Ordering::Acquire) { + // Dropping `self` on the way out releases the slot, which is what + // should happen: this message is never being delivered. + return Err(Disconnected(item)); + } + + let tail = shared.tail.0.load(Ordering::Relaxed); + // SAFETY: the reservation guarantees a free slot -- the room check that + // granted it withheld one from the best-effort path, and this thread is + // the only one that could have consumed it since. + unsafe { + shared.publish(tail, item); + } + + // Released only now, after the slot it guaranteed has actually been + // used. No other thread pushes into this shape, so the moment between + // the publication and this store is invisible to anything that could + // act on it; the consumer may see the pair inconsistently, but only as + // a metric. + let reserved = shared.reserved.load(Ordering::Relaxed); + debug_assert!( + reserved >= 1, + "this reservation is outstanding, so the count cannot be zero" + ); + shared.reserved.store(reserved - 1, Ordering::Relaxed); + + // The slot has been given up above, so the `Drop` that would give it up + // a second time must not run. + core::mem::forget(self); + Ok(()) + } + + /// Whether the consumer has been dropped, so redeeming would fail. + #[must_use] + pub fn is_disconnected(&self) -> bool { + self.producer.is_disconnected() + } +} + +impl fmt::Debug for Reservation<'_, T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("spsc::Reservation") + .field("disconnected", &self.is_disconnected()) + .finish() + } +} + +impl Drop for Reservation<'_, T> { + fn drop(&mut self) { + let reserved = self.producer.shared.reserved.load(Ordering::Relaxed); + debug_assert!( + reserved >= 1, + "this reservation is outstanding, so the count cannot be zero" + ); + self.producer + .shared + .reserved + .store(reserved - 1, Ordering::Relaxed); + } +} + /// The reading half of an [`spsc`](self) ring. /// /// Neither [`Clone`] nor [`Sync`], which is what makes "single consumer" a fact @@ -617,6 +792,22 @@ impl crate::Consumer for Consumer { } } +impl crate::Reserving for Producer { + type Item = T; + type Reservation<'a> + = Reservation<'a, T> + where + Self: 'a; + + fn reserve(&self) -> Option> { + Self::reserve(self) + } + + fn outstanding_reservations(&self) -> usize { + Self::outstanding_reservations(self) + } +} + impl crate::Bounded for Producer { fn capacity(&self) -> usize { Self::capacity(self) diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index 31cdd225..cddb0fe6 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -6,7 +6,7 @@ //! joined thread rather than a sleep, so they are deterministic: the assertion //! runs after the peer has finished, not after a guess about how long it takes. -use super::{Consumer, MIN_CAPACITY, Producer, bounded, validate_capacity}; +use super::{BOUNDS, Consumer, Producer, bounded, validate_capacity}; use crate::race_hooks; use crate::{PushError, RecvError, RecvTimeoutError}; use std::os::windows::io::AsRawHandle; @@ -852,18 +852,18 @@ fn a_suggested_capacity_is_one_the_constructor_would_accept() { // queue means asking for half the address space -- the first version of // this test aborted the process with a four-exabyte allocation failure. for requested in [1_usize, 3, 100, 1000, 0, usize::MAX / 2, usize::MAX] { - let Err(error) = validate_capacity(requested, MIN_CAPACITY) else { + let Err(error) = validate_capacity(requested, BOUNDS) else { continue; }; if let Some(previous) = error.previous_valid() { assert!( - validate_capacity(previous, MIN_CAPACITY).is_ok(), + validate_capacity(previous, BOUNDS).is_ok(), "previous_valid() for {requested} suggested {previous}, which is itself rejected" ); } if let Some(next) = error.next_valid() { assert!( - validate_capacity(next, MIN_CAPACITY).is_ok(), + validate_capacity(next, BOUNDS).is_ok(), "next_valid() for {requested} suggested {next}, which is itself rejected" ); } @@ -875,8 +875,8 @@ fn the_largest_request_is_clamped_rather_than_rounded() { // Rounding `usize::MAX` down to the nearest power of two gives 2^63, which // is larger than the largest representable capacity. Before this was fixed // the suggestion was exactly that unusable value. - let error = validate_capacity(usize::MAX, MIN_CAPACITY) - .expect_err("usize::MAX is not a valid capacity"); + let error = + validate_capacity(usize::MAX, BOUNDS).expect_err("usize::MAX is not a valid capacity"); let previous = error .previous_valid() .expect("there is a valid capacity below usize::MAX"); @@ -891,7 +891,7 @@ fn the_largest_request_is_clamped_rather_than_rounded() { "and it must still be a power of two" ); assert!( - validate_capacity(previous, MIN_CAPACITY).is_ok(), + validate_capacity(previous, BOUNDS).is_ok(), "and must be accepted" ); } diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs index 9727e542..6e186b5f 100644 --- a/crates/windows-waitable-queues/src/traits.rs +++ b/crates/windows-waitable-queues/src/traits.rs @@ -149,6 +149,83 @@ pub trait Bounded { } } +/// A producer that can claim a slot in advance, so that a later delivery cannot +/// be refused for want of room. +/// +/// # What a reservation is for +/// +/// A bounded queue refuses when it is full, and that refusal is the +/// backpressure it exists to provide. But not everything travelling a queue can +/// survive being refused the same way. A telemetry sample lost to a full queue +/// is a gap in a chart; an I/O completion lost to a full queue is a caller +/// waiting forever for something that already happened. +/// +/// Rather than sort that out per message at the point of delivery -- where the +/// queue is already full and the decision is already too late -- reliability +/// becomes a property of **capacity claimed in advance**. The slot is taken +/// before the work that will fill it is allowed to start, so by the time there +/// is something to deliver, the room is already the holder's. One line covers +/// it: *reserved is guaranteed, unreserved is best-effort.* +/// +/// The same discipline, reached independently, is what +/// `windows-file-watcher`'s notification queue runs on. +/// +/// # Why this is a trait a shape may lack +/// +/// [`mpsc`](crate::mpsc) deliberately does **not** implement this, and that is +/// the clearest illustration of why the capability traits are narrow +/// ([D-2](../../DESIGN-NOTES.md#d-2)). Honouring a reservation means knowing how +/// many slots remain, which costs a producer a read of the consumer's position +/// on every push -- a single line every thread touches. `mpsc`'s push avoids +/// that read by design, so it cannot answer the question, and +/// [`reserving_mpsc`](crate::reserving_mpsc) exists beside it for callers who +/// would rather pay than lose a message. +/// +/// A fat trait would have forced that cost on both, or excluded the reservation +/// from the contract entirely. Narrow traits let the two ship as peers. +pub trait Reserving { + /// What this queue carries. + type Item; + + /// The claim, which is redeemed or released but never ignored. + /// + /// **Generic over a lifetime because the two shapes genuinely differ**, and + /// that difference is the trait being validated by two implementations + /// rather than shaped around one ([D-3](../../DESIGN-NOTES.md#d-3)). + /// [`reserving_mpsc`](crate::reserving_mpsc) hands out an owned, [`Send`] + /// reservation, because its use case is to claim a slot when an operation is + /// submitted and redeem it from whichever thread the completion arrives on. + /// [`spsc`](crate::spsc) hands out one that borrows the producer, because + /// there the producer handle *is* the single-producer guarantee: an owned + /// reservation could outlive it on another thread, and then two threads + /// would be writing the ring. + type Reservation<'a> + where + Self: 'a; + + /// Claims one slot, or reports that none is available. + /// + /// **This is the fallible half, and deliberately so.** Failing here is + /// cheap: no work has been started and nothing needs delivering, so a + /// caller can wait, shed load, or refuse the request upstream. That is the + /// whole trade -- the failure is moved from the moment of delivery, when + /// the only remaining options are to block or to lose the message, to the + /// moment of admission, when there are still good ones. + /// + /// A claim held is capacity withdrawn from every other producer, so hold it + /// for as long as correctness needs and no longer. Dropping it returns the + /// slot. + #[must_use = "a reservation withholds capacity from every other producer until it is used or dropped"] + fn reserve(&self) -> Option>; + + /// How many slots are currently claimed and not yet redeemed. + /// + /// A snapshot, and offered for metrics rather than for control flow: + /// [`Reserving::reserve`] answers "can I have one" without the window that + /// testing this first would open. + fn outstanding_reservations(&self) -> usize; +} + /// A queue whose readiness can be waited on as a Windows `HANDLE`. /// /// This is the capability the crate is named for, and the reason it exists From 2f6ff3fa32e2cb29b8582e243517813621f64f50 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 00:03:52 -0400 Subject: [PATCH 029/361] test(waitable-queues): close the spsc reservation test hole the sabotage sweep found The sweep reported 'spsc: a best-effort push may take a reserved slot' as survived, and it was right: reserve was added to spsc but every reservation test was written against reserving_mpsc. The two implementations share nothing -- a plain counter versus a packed compare-and-swap word -- so covering one leaves the other entirely unguarded, which is precisely the hole a sweep exists to find and a green suite cannot show. Writing the wakeup test then surfaced something worth keeping: the compiler refuses to move an spsc reservation to another thread at all, because it borrows a !Sync producer. That is the borrow doing its job, so it is now asserted by a compile_fail doctest rather than described in prose -- and verified by removing the attribute and confirming the error is the Send/Sync one rather than a typo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-waitable-queues/src/spsc.rs | 17 ++ .../windows-waitable-queues/src/spsc/tests.rs | 243 ++++++++++++++++++ 2 files changed, 260 insertions(+) diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 05f6e411..4804f1fd 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -366,6 +366,23 @@ impl Producer { /// reservation is outstanding, so the compiler enforces what the shape /// requires. [`reserving_mpsc`](crate::reserving_mpsc), which has no such /// constraint, hands out an owned reservation instead. + /// + /// **The refusal is asserted, not merely described.** A reservation cannot + /// be moved to another thread, because it borrows a producer that is not + /// [`Sync`], so `&Producer` is not [`Send`]: + /// + /// ```compile_fail + /// # use windows_waitable_queues::spsc; + /// let (tx, _rx) = spsc::bounded::(4).unwrap(); + /// let slot = tx.reserve().expect("room"); + /// // Rejected: moving this would put a second writer on the ring. + /// std::thread::spawn(move || { + /// slot.send(1).ok(); + /// }); + /// ``` + /// + /// The consumer handle *is* [`Send`], so a blocked receiver can still live + /// on another thread -- it is only the writing side that is pinned. #[must_use = "a reservation withholds capacity from the best-effort path until it is used or dropped"] pub fn reserve(&self) -> Option> { let tail = self.shared.tail.0.load(Ordering::Relaxed); diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index cddb0fe6..95672c58 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -941,3 +941,246 @@ fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { "an empty queue must still be safe to wait on, or the wait never happens at all" ); } + +// --------------------------------------------------------------------------- +// Reservation. +// +// The mechanism here is a plain counter written only by the producer's thread, +// where `reserving_mpsc` needs a compare-and-swap against a packed word. The +// two implementations share nothing, so the guarantee has to be asserted +// separately on each -- a point made empirically rather than by argument: the +// sabotage sweep found this whole section missing, because the reserving_mpsc +// tests covered the mpsc path and left this one unguarded. +// --------------------------------------------------------------------------- + +/// Fills every slot the best-effort path is allowed to take, and reports how +/// many went in. +fn fill(producer: &Producer) -> usize { + let mut pushed = 0; + while producer.push(0).is_ok() { + pushed += 1; + } + pushed +} + +#[test] +fn a_reservation_withholds_a_slot_from_the_best_effort_path() { + let (tx, _rx) = bounded::(8).expect("8 is a valid capacity"); + assert_eq!( + fill(&tx), + 8, + "with nothing reserved, every slot is available" + ); + + let (tx, _rx) = bounded::(8).expect("8 is a valid capacity"); + let reservations: Vec<_> = (0..3).map(|_| tx.reserve().expect("room")).collect(); + assert_eq!(tx.outstanding_reservations(), 3); + assert_eq!( + fill(&tx), + 5, + "three reserved leaves five for the best-effort path" + ); + drop(reservations); +} + +#[test] +fn a_reserved_slot_is_delivered_into_a_queue_that_is_otherwise_full() { + // The contract in one test: reserve, let the best-effort path take + // everything it is allowed to, and redeem anyway. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("a fresh queue has room"); + + assert_eq!(fill(&tx), 3, "the reservation withheld exactly one slot"); + assert!(tx.is_full(), "and now nothing more may be pushed"); + + slot.send(99).expect("the room was already ours"); + + let drained: Vec = std::iter::from_fn(|| rx.pop()).collect(); + assert_eq!( + drained, + vec![0, 0, 0, 99], + "the reserved item lands where it was redeemed, not where it was claimed" + ); +} + +#[test] +fn a_push_refused_for_a_reservation_is_still_reported_as_full() { + // A best-effort caller cannot tell "no slots" from "the only slot is + // reserved", and should not have to: both mean "no room for you". + let (tx, _rx) = bounded::(2).expect("2 is a valid capacity"); + let _slot = tx.reserve().expect("room"); + tx.push(1).expect("one slot is unreserved"); + + assert!( + matches!(tx.push(2), Err(PushError::Full(2))), + "the reserved slot is not available to the best-effort path" + ); +} + +#[test] +fn dropping_a_reservation_returns_the_slot() { + let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + assert_eq!(tx.outstanding_reservations(), 1); + + drop(slot); + assert_eq!(tx.outstanding_reservations(), 0); + assert_eq!( + fill(&tx), + 4, + "a released reservation is capacity given back, not capacity lost" + ); +} + +#[test] +fn a_redeemed_reservation_does_not_also_release_its_slot() { + // The double-release bug `send` avoids by consuming `self` and suppressing + // the drop. If both ran, the count would underflow and the queue would + // over-admit for ever afterwards. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + for round in 0..10 { + let slot = tx.reserve().expect("room"); + assert_eq!(tx.outstanding_reservations(), 1); + slot.send(round).expect("the room was ours"); + assert_eq!( + tx.outstanding_reservations(), + 0, + "redeeming releases the claim exactly once" + ); + assert_eq!(rx.pop(), Some(round)); + } + assert_eq!(fill(&tx), 4, "and the capacity is intact after ten cycles"); +} + +#[test] +fn reserving_fails_when_every_slot_is_spoken_for() { + let (tx, _rx) = bounded::(2).expect("2 is a valid capacity"); + let _first = tx.reserve().expect("room"); + let _second = tx.reserve().expect("room"); + assert!( + tx.reserve().is_none(), + "reservations are drawn from the same capacity as everything else" + ); + + let (tx, _rx) = bounded::(2).expect("2 is a valid capacity"); + fill(&tx); + assert!( + tx.reserve().is_none(), + "and a full queue has nothing left to promise" + ); +} + +#[test] +fn a_reservation_survives_many_wraps_of_the_ring() { + // The counter must be independent of the positions, so hundreds of laps + // beneath a held reservation must not disturb it. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + + for round in 0..500 { + tx.push(round).expect("three slots remain unreserved"); + assert_eq!(rx.pop(), Some(round)); + assert_eq!(tx.outstanding_reservations(), 1, "round {round}"); + } + + slot.send(99).expect("still ours after five hundred laps"); + assert_eq!(rx.pop(), Some(99)); +} + +#[test] +fn redeeming_into_a_departed_consumer_hands_the_item_back() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + drop(rx); + + assert!(slot.is_disconnected()); + let error = slot.send(7).expect_err("nobody is left to take it"); + assert_eq!( + error.into_inner(), + 7, + "an item important enough to reserve for must not be dropped silently" + ); +} + +#[test] +fn a_reserved_delivery_lights_the_doorbell() { + // A reserved send is a delivery like any other, so it must ring. If it did + // not, a consumer parked on the doorbell would sleep through precisely the + // message that was important enough to reserve a slot for. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + let slot = tx.reserve().expect("room"); + assert!(rx.arm().expect("arming must succeed"), "nothing yet"); + + slot.send(1).expect("the room was ours"); + assert!( + doorbell_is_lit(&rx), + "a reserved delivery must ring like any other" + ); + assert!( + !rx.arm().expect("arming must succeed"), + "and must be visible to the arming protocol" + ); +} + +#[test] +fn a_blocked_consumer_is_woken_by_a_reserved_delivery() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("room"); + + // **The reservation cannot cross a thread boundary, and the compiler says + // so**: it borrows a producer that is not `Sync`, so `&Producer` is not + // `Send` and even a scoped thread is refused. That is the borrow doing its + // job rather than an inconvenience -- see the `compile_fail` doctest on + // `Producer::reserve`, which asserts the refusal directly. + // + // So the *consumer* goes across instead. It is a separate handle and is + // `Send`, which is what makes this test expressible at all. + let receiver = thread::spawn(move || rx.recv()); + thread::sleep(Duration::from_millis(50)); + slot.send(7).expect("the consumer is alive"); + + assert_eq!( + receiver + .join() + .expect("the consumer must not panic") + .expect("the reservation is redeemed"), + 7, + "a parked consumer must be woken by a reserved delivery" + ); +} + +#[test] +fn an_abandoned_reservation_leaves_the_queue_usable() { + // A reservation that fails to be redeemed must not poison the capacity. + let (tx, rx) = bounded::(2).expect("2 is a valid capacity"); + for _ in 0..50 { + let slot = tx.reserve().expect("room"); + drop(slot); + } + assert_eq!(tx.outstanding_reservations(), 0); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + assert!(tx.push(3).is_err()); + assert_eq!(rx.pop(), Some(1)); +} + +#[test] +fn dropping_the_queue_drops_a_reserved_item_it_still_holds() { + let drops = Arc::new(AtomicUsize::new(0)); + { + let (tx, _rx) = bounded::(8).expect("a power-of-two capacity"); + let slot = tx.reserve().expect("room"); + for _ in 0..5 { + tx.push(DropCounter(Arc::clone(&drops))).expect("room"); + } + slot.send(DropCounter(Arc::clone(&drops))) + .expect("the room was ours"); + assert_eq!(drops.load(Ordering::Relaxed), 0, "nothing dropped yet"); + } + assert_eq!( + drops.load(Ordering::Relaxed), + 6, + "every undrained item must be dropped, including the reserved one" + ); +} From 76247ae8082605727f5901317b473e00a784ad8b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 00:16:12 -0400 Subject: [PATCH 030/361] docs: record the spsc reservation test hole in M31.2's done-note The sweep's finding is the most valuable thing it produced this milestone, and the checklist is where a future reader looks for what an item actually cost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 654d1fa5..9e58685b 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -281,7 +281,17 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m them while silently narrowing the count's field to 24, which is how the packing actually breaks. Both rewritten, and the assertions verified by sabotage -- a too-wide and a too-narrow split each now fail the build with the right message. - 155 unit tests and 5 doctests, the whole suite in 0.31s. + **And the sweep found a third, which is the one it exists to find: `spsc`'s reservation guarantee was + entirely untested.** Every reservation test had been written against `reserving_mpsc`, and because the + two implementations share *nothing* -- a plain counter against a packed compare-and-swap word -- + covering one left the other completely unguarded. A green suite cannot show that; only asking "would + these tests fail if the code were wrong" can. Writing the missing wakeup test then surfaced something + worth keeping: **the compiler refuses to move an `spsc` reservation to another thread at all**, because + it borrows a `!Sync` producer. That is the borrow doing its job, so it is now a `compile_fail` doctest + rather than a sentence -- itself verified by removing the attribute and confirming the error is the + `Send`/`Sync` one and not a typo. + 167 unit tests, 5 doctests and 1 `compile_fail` doctest, the whole suite in 0.31s. Thirty-one + sabotages, all behaving as declared: eight new ones for this milestone. - [ ] **M31.3** -- Shutdown in both directions: the consumer learns when every producer is gone, and a producer learns when the consumer is gone and fails with a typed error. Descriptors in flight at From 38e35b0dc6b49aa4cc9a89fa5d7e9536a5057a79 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 00:37:07 -0400 Subject: [PATCH 031/361] feat(waitable-queues): hand undrained items back at teardown instead of destroying them in place R8's third clause asks that descriptors in flight at teardown be accounted, not dropped, because some own handles and their disposal must be allowed to block. The first two clauses were already shipped in M30 and M31.1; this is the one that was not, and the default was quietly bad. Undrained items were destroyed in place, inside the last Arc release, so T's destructor ran on whichever thread happened to drop last. That thread is not knowable in advance and nobody chose it: it may be a pool callback that must not block, and the namespace session's example is closing a handle to a dead network path -- exactly the blocking operation this facility exists to keep off a caller's thread. Drop cannot be made to hand them back: &mut self, no return value, cannot fail, and by the time it runs every handle is gone so there is nobody to return them to. That is why the Disposal sink is supplied at construction rather than requested at teardown. The last handle to drop is the only place that sees every survivor, and it is the one place with no way to report. Draining first does not close the hole either -- a producer may push after the consumer has taken everything available -- which is also why into_remaining was considered and refused: it would cover only the orderly path, and drain already does what it would do. The default is unchanged and still destroys in place, because for items that own nothing that is exactly right. What changed is that the behaviour now has a name and an alternative. The claim under test is about threads rather than counts. Asserting only that the sink receives the items would test the mechanism, not the property, so the suite records the ThreadId a destructor runs on and asserts it is not the thread that released the last handle -- with a control, without a sink, showing that it is. Without that control the first test would look identical if destructors simply never ran anywhere observable. Routing is asserted once per shape rather than once for the crate, because each walks its own layout to find survivors. That is the lesson M31.2's sweep taught about the reservation guarantee, applied before the sweep had to teach it twice. A panicking sink is caught and the walk continues: a panic escaping a destructor abandons every item behind it -- the exact handles this accounts for -- and during an unwind aborts the process. The unsafe Sync impls were amended rather than left standing. Teardown holds a boxed FnMut, which is Send but not Sync, so those impls now force Sync onto a field that lacks it. That is sound for a narrower reason, and the comments say which: the field is private, no method reads it, and the only access is from Drop under &mut self. Completed item: M31.3: Shutdown in both directions: the consumer learns when every producer is gone, and a producer learns when the consumer is gone and fails with a typed error. Descriptors in flight at teardown are accounted, not dropped -- some own handles, and their disposal must be allowed to block, which is the hazard the namespace session flagged for undrained completions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 35 +- .../windows-waitable-queues/DESIGN-NOTES.md | 75 ++++ crates/windows-waitable-queues/README.md | 7 + crates/windows-waitable-queues/sabotage.json | 63 ++++ .../windows-waitable-queues/src/disposal.rs | 176 ++++++++++ .../src/disposal/tests.rs | 154 +++++++++ crates/windows-waitable-queues/src/lib.rs | 23 +- crates/windows-waitable-queues/src/mpsc.rs | 57 ++- .../windows-waitable-queues/src/mpsc/tests.rs | 158 ++++++++- .../src/reserving_mpsc.rs | 53 ++- .../src/reserving_mpsc/tests.rs | 178 +++++++++- crates/windows-waitable-queues/src/spsc.rs | 80 ++++- .../windows-waitable-queues/src/spsc/tests.rs | 326 +++++++++++++++++- 13 files changed, 1356 insertions(+), 29 deletions(-) create mode 100644 crates/windows-waitable-queues/src/disposal.rs create mode 100644 crates/windows-waitable-queues/src/disposal/tests.rs diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 9e58685b..62283074 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -293,10 +293,43 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m 167 unit tests, 5 doctests and 1 `compile_fail` doctest, the whole suite in 0.31s. Thirty-one sabotages, all behaving as declared: eight new ones for this milestone. -- [ ] **M31.3** -- Shutdown in both directions: the consumer learns when every producer is gone, and a +- [x] **M31.3** -- Shutdown in both directions: the consumer learns when every producer is gone, and a producer learns when the consumer is gone and fails with a typed error. Descriptors in flight at teardown are **accounted, not dropped** -- some own handles, and their disposal must be allowed to block, which is the hazard the namespace session flagged for undrained completions. + **The first two clauses were already shipped, and this was audited rather than assumed.** Every shape + has `is_disconnected` on both ends, `PushError::Disconnected(T)` hands the item back to a producer whose + consumer is gone, and M31.2 added `Disconnected` for a reservation redeemed into a dead queue. A + reservation also counts as a producer, so an outstanding promise holds the stream open. Nothing was + needed there; saying so is the point, since the alternative is checking a box on work done elsewhere. + **The third clause was the whole item, and the default was quietly bad.** Undrained items were destroyed + *in place*, inside the last `Arc` release -- so `T`'s destructor ran on whichever thread happened to drop + last. That thread is not knowable in advance and nobody chose it: it may be a pool callback that must not + block, and the namespace session's example is closing a handle to a dead network path, which is exactly + the blocking operation the facility exists to keep off a caller's thread. + **`Drop` cannot be made to hand them back** -- `&mut self`, no return, cannot fail, and by then every + handle is gone so there is nobody to return them *to*. That is why `Disposal` is supplied at + construction rather than requested at teardown: the last handle to drop is the only place that sees + every survivor, and it is the one place with no way to report + ([D-20](crates/windows-waitable-queues/DESIGN-NOTES.md#d-20)). The default is unchanged and still + destroys in place, because for items that own nothing that is exactly right -- what changed is that it + now has a name and an alternative. + **The claim under test is about threads, not counts.** Asserting only that the sink receives the items + would test the mechanism rather than the property, so the suite records the `ThreadId` a destructor runs + on and asserts it is *not* the thread that released the last handle -- with a control, without a sink, + showing that it is. Without that control the first test would look identical if destructors simply never + ran anywhere observable. + **Two smaller decisions recorded rather than left implicit.** A panicking sink is caught and the walk + continues ([D-21](crates/windows-waitable-queues/DESIGN-NOTES.md#d-21)), because a panic escaping a + destructor abandons every item behind it and aborts outright during an unwind. And `into_remaining` was + considered and refused ([D-22](crates/windows-waitable-queues/DESIGN-NOTES.md#d-22)): producers may push + after the consumer is consumed, so it would cover only the orderly path, and `drain` already does what + it would do. + Routing is asserted once per shape rather than once for the crate, since each walks its own layout -- + M31.2's sweep taught that lesson about the reservation guarantee, and this applies it before the sweep + had to teach it twice. + 194 unit tests, 7 doctests and 1 `compile_fail` doctest, the whole suite in 0.33s. Thirty-six sabotages, + all behaving as declared: five new ones for this milestone. - [ ] **M31.4** -- Observability (R9): depth, high-water, and **a count of doorbells actually rung**. That last one is what makes the skip rule measurable rather than assumed, and sabotage-verifiable -- disabling diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 7cdb1bc6..6236433a 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -43,6 +43,9 @@ preferred. | D-17 | **The reservation count and the claim position live in one word, because a check-and-claim over both must be a single atomic operation.** Two atomics cannot be made correct with any amount of fencing: the pushing producer is load-then-store and the reserving one store-then-load, so the Dekker argument does not apply and both can miss each other. The 32/32 split is forced by the arithmetic, and caps this shape at 2^31 items. | | D-18 | **A 128-bit compare-and-swap is refused.** It would lift the 2^31 cap and nothing else -- the consumer's position still has to be read -- at the cost of a dependency, a target-feature floor not in the x86-64 baseline, and a different instruction on the ARM64 machine this workspace measures on. Revisit only for a tagged pointer, which is what [M-inf.1](../../CHECKLIST-io-domains.md)'s linked and sharded shapes would need. | | D-19 | **The coalesced loss latch is deliberately not generalised from the file watcher.** Coalescing there is sound because a desync is *idempotent* -- two mean the same as one, and the answer to both is a re-scan. A queue of arbitrary `T` has no such property, so what generalises is a loss *count*, which is [M31.4](../../CHECKLIST-io-domains.md)'s observability rather than a policy. | +| D-20 | **Undrained items are handed to a caller-supplied sink at teardown, and the sink is chosen at construction because `Drop` has nowhere to hand them back to.** Without one they are destroyed on whichever thread released the last handle -- which may be a pool callback that must not block, and closing a handle to a dead network path can block for a long time. The default is unchanged; what changes is that it is now a named choice. | +| D-21 | **A panicking disposal sink is caught and the teardown walk continues.** The sink is caller code inside a destructor: a panic escaping it abandons every item behind it -- the exact handles the mechanism exists to account for -- and during an unwind aborts the process. Catching declines to turn a caller's bug into a much larger one. | +| D-22 | **No `into_remaining`, because it would not close the hole and `drain` already covers what it would do.** A consumer can take everything available, but a producer may push afterwards, so an orderly drain covers only the orderly path. The last handle to drop is the only place that sees every survivor. | ## D-2: capabilities are sliced, not gathered @@ -599,3 +602,75 @@ depends on what the payload means. `ArrayQueue` usable as a ring buffer, which is right for telemetry where an overwritten entry is a lost sample. Here an entry may be an I/O submission, where it is a lost *operation*. The two must not share a knob, because a knob invites a caller to pick the wrong one. + +## D-20: teardown hands undrained items back, and the decision is made at construction + +[R8](../../design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) asks that +descriptors in flight at teardown be **accounted, not dropped**, "because some own handles, and their +disposal must be allowed to block". The 2026-08-27 namespace session states the same hazard concretely: +an async open's completion carries an owned handle, and closing one to a dead network path is exactly the +blocking operation the whole facility exists to keep off a caller's thread. + +**The default answer to "who destroys the items nobody drained?" was bad in a way that is easy to miss.** +They were destroyed in place, inside the last `Arc` release -- so `T`'s destructor ran on whichever thread +happened to drop last. That thread is not knowable in advance and nobody chose it: it may be a thread-pool +callback that must not block, or a producer with no idea it was holding the last reference. Nothing told +the owner it had happened. + +**`Drop` cannot be made to hand them back.** It takes `&mut self`, returns nothing, and cannot fail; by +the time it runs every handle is gone, so there is nobody left to return anything *to*. Whatever the queue +is going to do with those items, it has to have been told beforehand. That is the whole reason [`Disposal`] +is supplied at construction rather than asked for at teardown -- not ergonomics, but the shape of the only +place that sees every survivor. + +So a queue built with a sink hands each survivor to it. The owner then decides where disposal happens: a +sink that moves items to a reaper thread keeps the blocking off the dropping thread entirely, while one +that disposes inline is perfectly fine when the dropping thread is allowed to block. Either way it is a +decision somebody made. + +**The default is unchanged and still destroys in place.** For items that own nothing -- which is most of +them -- that is exactly right, and a queue of `u32` should not have to think about any of this. What +changed is that the behaviour now has a name and an alternative. + +**The claim under test is about threads, not counts.** It would be easy to assert only that the sink +receives the items, which is the mechanism rather than the property. The suite instead records the +`ThreadId` a destructor runs on and asserts it is *not* the thread that released the last handle -- with a +control, without a sink, showing it *is*. That control matters: without it the first test would look +identical if destructors simply never ran anywhere observable. + +Each shape walks its own layout to find survivors, so the routing is asserted once per shape rather than +once for the crate. That is the lesson M31.2's sweep taught about the reservation guarantee, applied +before the sweep had to teach it again. + +## D-21: a panicking sink is caught, and the walk continues + +The sink is caller-supplied code running inside a destructor, which is the worst place for it to panic. +A panic escaping there does one of two bad things: during an unwind it aborts the process, and otherwise +it abandons every item not yet disposed -- precisely the handles the mechanism exists to account for. + +So the call is wrapped and the walk continues. This is deliberately **not** "swallowing an error": the +item has already been moved into the sink, so there is nothing left to report about it, and the item is +destroyed by the unwind rather than leaked. A sink that panics is a bug in the caller; catching only +declines to turn it into a much larger one. + +`AssertUnwindSafe` is the honest annotation rather than a way past the bound. The only state observable +after a panic is the caller's own closure, and the queue's invariants do not depend on the sink at all -- +teardown is already past the point where anything could observe them. + +## D-22: no `into_remaining`, because it would not close the hole + +The obvious API for shutdown is "consume the consumer, get everything that is left". It was considered +and refused, for two reasons that compound. + +**It does not close the hole.** A consumer can take everything *available*, but producers may still push +afterwards -- so it covers the orderly path and nothing else, and the disorderly path is the one that +strands handles. The last handle to drop remains the only place that sees every survivor, which is where +[D-20](#d-20) puts the mechanism. + +**And it adds nothing over what exists.** `Consumer::drain` already takes everything available; an +`into_remaining` would be that plus consuming the handle. Since the sink covers the case `drain` cannot, +the extra method would be surface without capability. + +The orderly shutdown therefore stays what it already was: drain to empty, observe +`Consumer::is_disconnected`, and take the final item with the receive loop's `finish` step. The sink is +for everything that does not go to plan. diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 1a5d325f..25e34a62 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -83,6 +83,13 @@ again next lap" in a one-slot ring. construction. - **It will not create a kernel object you never use.** The doorbell is created lazily, so a consumer that only polls allocates none. +- **It will not destroy your items on a thread you did not choose.** A queue + built with a `Disposal` sink hands whatever nobody drained back to you at + teardown, rather than running the destructors inside the last handle's drop. + That matters when an item owns a handle, because closing one can block -- + and the thread that happens to release last may be a pool callback that must + not. Without a sink the items are destroyed in place, which is the right + default for items that own nothing. - **It will not round your capacity.** A capacity that a shape cannot represent is refused, with the nearest valid neighbours on the error, rather than silently turned into one the caller did not choose. diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json index 229b683e..217ba9d2 100644 --- a/crates/windows-waitable-queues/sabotage.json +++ b/crates/windows-waitable-queues/sabotage.json @@ -415,6 +415,69 @@ " shared.release_producer();" ] }, + { + "name": "teardown ignores the disposal sink and destroys in place", + "file": "src/disposal.rs", + "expect": "caught", + "why": "The whole mechanism in one line. If the policy always takes the default path, every queue silently reverts to running arbitrary destructors on whichever thread released the last handle -- which is the hazard, and it is invisible from any test that only counts survivors rather than observing where they were destroyed.", + "find": [ + " let Some(disposal) = self.disposal.as_mut() else {" + ], + "replace": [ + " let Some(disposal) = Option::<&mut Disposal>::None else {" + ] + }, + { + "name": "a panicking sink is not contained", + "file": "src/disposal.rs", + "expect": "caught", + "why": "The sink is caller code running inside a destructor. Letting a panic escape abandons every item not yet disposed -- exactly the handles this mechanism exists to account for -- and during an unwind it aborts the process outright. Caught by the test that panics on the fourth of ten items and requires the other nine to be disposed anyway.", + "find": [ + " let _ = catch_unwind(AssertUnwindSafe(|| (disposal.sink)(item)));" + ], + "replace": [ + " (disposal.sink)(item);" + ] + }, + { + "name": "spsc teardown destroys survivors instead of handing them over", + "file": "src/spsc.rs", + "expect": "caught", + "why": "Each shape walks its own layout to find survivors, so the routing has to be asserted once per shape -- covering one says nothing about the others, which is the same lesson M31.2's sweep taught about the reservation guarantee.", + "find": [ + " let item = unsafe { (*self.slots[pos & mask].get()).assume_init_read() };", + " self.teardown.dispose(item);" + ], + "replace": [ + " unsafe { (*self.slots[pos & mask].get()).assume_init_drop() };" + ] + }, + { + "name": "mpsc teardown destroys survivors instead of handing them over", + "file": "src/mpsc.rs", + "expect": "caught", + "why": "As for spsc, and this walk differs: it consults each slot's sequence rather than assuming the whole resident range is published.", + "find": [ + " let item = unsafe { slot.value.get_mut().assume_init_read() };", + " self.teardown.dispose(item);" + ], + "replace": [ + " unsafe { slot.value.get_mut().assume_init_drop() };" + ] + }, + { + "name": "reserving_mpsc teardown destroys survivors instead of handing them over", + "file": "src/reserving_mpsc.rs", + "expect": "caught", + "why": "The shape where it matters most: a reservation is taken because its message must not be lost, so a redeemed message abandoned at teardown would be lost after all -- just later and more quietly.", + "find": [ + " let item = unsafe { slot.value.get_mut().assume_init_read() };", + " self.teardown.dispose(item);" + ], + "replace": [ + " unsafe { slot.value.get_mut().assume_init_drop() };" + ] + }, { "name": "spsc: a best-effort push may take a reserved slot", "file": "src/spsc.rs", diff --git a/crates/windows-waitable-queues/src/disposal.rs b/crates/windows-waitable-queues/src/disposal.rs new file mode 100644 index 00000000..dd005543 --- /dev/null +++ b/crates/windows-waitable-queues/src/disposal.rs @@ -0,0 +1,176 @@ +// Copyright (c) Mike Grier. + +//! What becomes of items still in the queue when it is torn down. +//! +//! # The hazard, which is not hypothetical +//! +//! A queue's items can own resources, and a descriptor for a completed async +//! open owns a **handle**. Closing a handle is not always cheap: closing one to +//! a dead network path can block for a long time, and keeping exactly that +//! operation off a caller's thread is the sort of thing the queue exists to +//! serve in the first place. +//! +//! So the question "who destroys the items nobody drained?" has a bad default +//! answer. Without this module, they are destroyed **in place, on whichever +//! thread happened to release the last handle** -- which may be a thread-pool +//! callback that must not block, or a producer that has no idea it is holding +//! the last reference. Nobody chose that thread, and nothing tells the owner it +//! happened. +//! +//! # Why `Drop` cannot simply hand them back +//! +//! The obvious fix -- return the remainder from teardown -- is not available. +//! [`Drop::drop`] takes `&mut self`, returns nothing, and cannot fail. By the +//! time it runs, every handle is already gone, so there is nobody left to +//! return anything *to*. Anything the queue is going to do with those items, it +//! must have been told in advance. +//! +//! Draining first does not close the hole either. A consumer can take +//! everything available, but a producer may push again afterwards, so an +//! orderly drain covers the orderly path and nothing else. **The last handle to +//! drop is the only place that sees every remaining item**, and it is the one +//! place with no way to report. +//! +//! # So the decision is made at construction +//! +//! A queue built with [`Disposal`] hands each surviving item to that sink +//! instead of destroying it. The owner therefore decides where disposal +//! happens: a sink that moves items to a reaper thread keeps the blocking off +//! the dropping thread entirely, and one that disposes inline is fine when the +//! dropping thread is allowed to block. Either way it is a decision somebody +//! made rather than one that fell out of which `Arc` clone happened to die +//! last. +//! +//! **The default is unchanged and still destroys in place**, because for the +//! overwhelmingly common case -- items that own nothing -- that is exactly +//! right, and a queue of `u32` should not have to think about any of this. What +//! changes is that the behaviour is now written down as a choice with a name. + +use core::fmt; +use std::panic::{AssertUnwindSafe, catch_unwind}; + +/// Where a queue's surviving items go when it is torn down. +/// +/// See the [module documentation](self) for why this has to be supplied up +/// front rather than asked for at teardown. +/// +/// # Examples +/// +/// Handing the remainder to a channel a reaper thread drains, so a destructor +/// that blocks does so somewhere that is allowed to: +/// +/// ``` +/// use std::sync::mpsc; +/// use windows_waitable_queues::{Disposal, spsc}; +/// +/// let (undelivered, reaper) = mpsc::channel(); +/// let (tx, rx) = spsc::bounded_with_disposal::( +/// 4, +/// Disposal::new(move |item| { +/// // Cheap and non-blocking: the reaper thread does the real work. +/// let _ = undelivered.send(item); +/// }), +/// )?; +/// +/// tx.push(1).expect("a fresh queue has room"); +/// tx.push(2).expect("a fresh queue has room"); +/// drop((tx, rx)); +/// +/// // Nothing was destroyed behind the owner's back. +/// assert_eq!(reaper.into_iter().collect::>(), vec![1, 2]); +/// # Ok::<(), windows_waitable_queues::CapacityError>(()) +/// ``` +pub struct Disposal { + sink: Box, +} + +impl Disposal { + /// Builds a sink from a closure. + /// + /// The closure is called once per surviving item, on the thread that + /// released the queue's last handle. **It should be cheap**: if disposal + /// can block, the useful shape is to move the item somewhere a thread that + /// may block will find it, rather than to do the blocking work here. + /// + /// `Send` because the thread that tears the queue down is whichever one + /// happened to drop last, and is not knowable in advance. Not `Sync`, + /// because it is only ever called from a teardown that has exclusive + /// access. + pub fn new(sink: impl FnMut(T) + Send + 'static) -> Self { + Self { + sink: Box::new(sink), + } + } +} + +impl fmt::Debug for Disposal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Disposal(..)") + } +} + +/// A shape's teardown policy: the sink if it was given one, otherwise the +/// default. +/// +/// Held by every shape's shared state and touched only from `Drop`, so it costs +/// the hot paths nothing but the space. +pub(crate) struct Teardown { + disposal: Option>, +} + +impl Teardown { + /// The default: destroy each surviving item where it lies. + pub(crate) const fn drop_in_place() -> Self { + Self { disposal: None } + } + + /// Hand each surviving item to `disposal` instead. + pub(crate) const fn handing_off(disposal: Disposal) -> Self { + Self { + disposal: Some(disposal), + } + } + + /// Dispose of one surviving item. + /// + /// # A panicking sink does not strand the items behind it + /// + /// The sink is caller-supplied code running inside a destructor, which is + /// the worst place for it to panic: a panic escaping here during an unwind + /// aborts the process, and one escaping otherwise abandons every item not + /// yet disposed -- precisely the handles this whole mechanism exists to + /// account for. + /// + /// So a panic is caught and the walk continues. That is deliberately *not* + /// "swallowing an error": the item has already been handed over, so there + /// is nothing left to report about it, and the alternative is to lose the + /// rest of the queue as well. A sink that panics is a bug in the caller; + /// this only declines to make it a much larger one. + pub(crate) fn dispose(&mut self, item: T) { + let Some(disposal) = self.disposal.as_mut() else { + // The default. Written as an explicit drop rather than left to fall + // out of the binding going out of scope, because "destroy it here" + // is a decision this type exists to name. + drop(item); + return; + }; + + // `AssertUnwindSafe` is the honest annotation rather than a way past + // the bound: the only state that could be observed after a panic is the + // caller's own closure, and the queue's own invariants do not depend on + // the sink at all -- teardown is already past the point where anything + // could observe them. + let _ = catch_unwind(AssertUnwindSafe(|| (disposal.sink)(item))); + } +} + +impl fmt::Debug for Teardown { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Teardown") + .field("hands_off", &self.disposal.is_some()) + .finish() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/disposal/tests.rs b/crates/windows-waitable-queues/src/disposal/tests.rs new file mode 100644 index 00000000..69809788 --- /dev/null +++ b/crates/windows-waitable-queues/src/disposal/tests.rs @@ -0,0 +1,154 @@ +// Copyright (c) Mike Grier. + +//! Tests for the teardown policy in isolation, with no queue attached. +//! +//! The policy's behaviour *through* a queue is asserted in each shape's own +//! suite, because each walks its own layout to find the survivors and covering +//! one would say nothing about the others. What is tested here is the part +//! they share: that the default destroys, that a sink receives, and that a +//! panicking sink does not strand the items behind it. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::{Disposal, Teardown}; + +/// Counts its own drops, so a test can tell "handed to the sink" from +/// "destroyed where it lay" -- which is the entire distinction this module +/// exists to draw. +#[derive(Debug)] +struct DropCounter(Arc); + +impl Drop for DropCounter { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +#[test] +fn the_default_policy_destroys_the_item() { + let drops = Arc::new(AtomicUsize::new(0)); + let mut teardown = Teardown::drop_in_place(); + + teardown.dispose(DropCounter(Arc::clone(&drops))); + assert_eq!( + drops.load(Ordering::Relaxed), + 1, + "with no sink there is nowhere else for it to go, so it is destroyed here" + ); +} + +#[test] +fn a_sink_receives_the_item_instead_of_it_being_destroyed() { + let drops = Arc::new(AtomicUsize::new(0)); + let collected = Arc::new(AtomicUsize::new(0)); + + let seen = Arc::clone(&collected); + let mut teardown = Teardown::handing_off(Disposal::new(move |item: DropCounter| { + seen.fetch_add(1, Ordering::Relaxed); + // Deliberately kept alive past the sink call, which is the whole point: + // the owner decides when -- and on which thread -- the destructor runs. + std::mem::forget(item); + })); + + teardown.dispose(DropCounter(Arc::clone(&drops))); + + assert_eq!(collected.load(Ordering::Relaxed), 1, "the sink saw it"); + assert_eq!( + drops.load(Ordering::Relaxed), + 0, + "and teardown did not destroy it behind the owner's back" + ); +} + +#[test] +fn every_item_reaches_the_sink_in_order() { + let order = Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = Arc::clone(&order); + let mut teardown = Teardown::handing_off(Disposal::new(move |item: u32| { + seen.lock().expect("no test holds this poisoned").push(item); + })); + + for value in 0..10 { + teardown.dispose(value); + } + + assert_eq!( + *order.lock().expect("no test holds this poisoned"), + (0..10).collect::>(), + "a sink is handed the survivors one at a time, in the order teardown walks them" + ); +} + +#[test] +fn a_panicking_sink_does_not_strand_the_items_behind_it() { + // The property that matters, and the reason the call is wrapped. A sink + // that panics on one item is a caller bug; losing every *later* item to it + // would turn that bug into the exact leak this mechanism exists to + // prevent, and inside an unwind it would abort the process outright. + let disposed = Arc::new(AtomicUsize::new(0)); + let seen = Arc::clone(&disposed); + + let mut teardown = Teardown::handing_off(Disposal::new(move |item: u32| { + seen.fetch_add(1, Ordering::Relaxed); + assert_ne!(item, 3, "deliberate panic from a caller-supplied sink"); + })); + + for value in 0..10 { + teardown.dispose(value); + } + + assert_eq!( + disposed.load(Ordering::Relaxed), + 10, + "the walk must continue past a panicking sink, or one bad item loses the rest" + ); +} + +#[test] +fn a_panicking_sink_still_consumes_the_item_it_panicked_on() { + // The item was moved into the sink before it panicked, so it is destroyed + // by the unwind rather than leaked. Asserted so that "the panic is caught" + // is not mistaken for "the item is still somewhere". + let drops = Arc::new(AtomicUsize::new(0)); + let mut teardown = Teardown::handing_off(Disposal::new(|_item: DropCounter| { + panic!("deliberate panic from a caller-supplied sink"); + })); + + teardown.dispose(DropCounter(Arc::clone(&drops))); + assert_eq!( + drops.load(Ordering::Relaxed), + 1, + "the item was already the sink's, so unwinding destroys it" + ); +} + +#[test] +fn a_sink_may_be_stateful_across_items() { + // `FnMut` rather than `Fn`, because the useful sinks accumulate: pushing + // into a channel, counting, or batching for a reaper. + let mut total = 0_u32; + let sum = Arc::new(AtomicUsize::new(0)); + let report = Arc::clone(&sum); + + let mut teardown = Teardown::handing_off(Disposal::new(move |item: u32| { + total += item; + report.store(total as usize, Ordering::Relaxed); + })); + + for value in 1..=4 { + teardown.dispose(value); + } + assert_eq!(sum.load(Ordering::Relaxed), 10); +} + +#[test] +fn the_debug_form_says_which_policy_is_in_force() { + // Teardown is invisible until something goes wrong, so the one place it can + // be observed should say which of the two it is. + let plain: Teardown = Teardown::drop_in_place(); + assert!(format!("{plain:?}").contains("hands_off: false")); + + let handing: Teardown = Teardown::handing_off(Disposal::new(|_| {})); + assert!(format!("{handing:?}").contains("hands_off: true")); +} diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 905d472c..16672ea7 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -42,9 +42,24 @@ //! a doc comment. //! //! What the shapes have in common is described by the [capability -//! traits](traits) -- [`Producer`], [`Consumer`], [`Bounded`], [`Waitable`] -- -//! each naming one thing a queue can do, so a caller can be generic over -//! exactly what it needs and nothing more. +//! traits](traits) -- [`Producer`], [`Consumer`], [`Bounded`], [`Waitable`], +//! [`Reserving`] -- each naming one thing a queue can do, so a caller can be +//! generic over exactly what it needs and nothing more. +//! +//! # Shutting down +//! +//! A consumer learns that every producer is gone from +//! `is_disconnected`, and a producer learns the consumer is gone from a typed +//! [`PushError::Disconnected`] that hands the item back. The orderly shutdown +//! is therefore: drain to empty, then check. +//! +//! For everything that does not go to plan there is [`disposal`]. A queue torn +//! down with items still in it must do *something* with them, and by default it +//! destroys them inside the last handle's drop -- on whichever thread happened +//! to release it. When an item owns a handle that is a hazard rather than a +//! detail, because closing a handle can block and the dropping thread may be a +//! pool callback that must not. Building the queue with a [`Disposal`] sink +//! hands those items back instead. //! //! # Status //! @@ -60,6 +75,7 @@ mod blocking; mod capacity; +pub mod disposal; mod doorbell; mod error; pub mod mpsc; @@ -69,6 +85,7 @@ pub mod reserving_mpsc; pub mod spsc; pub mod traits; +pub use disposal::Disposal; pub use error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; pub use traits::{Bounded, Consumer, Drain, Producer, Reserving, Waitable}; diff --git a/crates/windows-waitable-queues/src/mpsc.rs b/crates/windows-waitable-queues/src/mpsc.rs index 9339db12..031e9154 100644 --- a/crates/windows-waitable-queues/src/mpsc.rs +++ b/crates/windows-waitable-queues/src/mpsc.rs @@ -80,6 +80,7 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; +use crate::disposal::{Disposal, Teardown}; use crate::doorbell::Doorbell; use crate::error::{CapacityError, PushError, RecvError, RecvTimeoutError}; @@ -144,6 +145,29 @@ const BOUNDS: Bounds = Bounds { /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { + build(capacity, Teardown::drop_in_place()) +} + +/// Creates a queue that hands its undrained items to `disposal` at teardown. +/// +/// Identical to [`bounded`] except for what becomes of items nobody took. See +/// [`Disposal`] for why that decision has to be made here rather than at +/// teardown, and why it matters for items that own a handle. +/// +/// # Errors +/// +/// As [`bounded`]. +pub fn bounded_with_disposal( + capacity: usize, + disposal: Disposal, +) -> Result<(Producer, Consumer), CapacityError> { + build(capacity, Teardown::handing_off(disposal)) +} + +fn build( + capacity: usize, + teardown: Teardown, +) -> Result<(Producer, Consumer), CapacityError> { validate_capacity(capacity, BOUNDS)?; let mut slots = Vec::with_capacity(capacity); @@ -155,6 +179,7 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit } let shared = Arc::new(Shared { + teardown, slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, @@ -194,6 +219,11 @@ struct Slot { } struct Shared { + /// What becomes of undrained items at teardown. + /// + /// Read only by [`Shared::drop`], which holds `&mut self`, so it needs no + /// synchronization and costs the hot paths nothing but its space. + teardown: Teardown, slots: Box<[Slot]>, mask: usize, capacity: usize, @@ -233,7 +263,14 @@ struct Shared { // publishes it. The write of the item therefore happens-before the read, and no // two threads ever touch the same slot's contents at the same time. `T: Send` // is required and sufficient because an item is moved between threads and never -// referenced from both. +// referenced from both.// +// The eardown field is deliberately NOT covered by that argument, because it +// cannot be: it holds a boxed FnMut, which is Send but not Sync, so this +// impl is forcing Sync onto a field that does not have it. That is sound for +// a narrower reason -- the field is unreachable through a shared reference. It +// is private, no method reads it, and the only access is from Drop, which +// holds &mut self and runs when the last handle is already gone. So no two +// threads can reach it at all, concurrently or otherwise. unsafe impl Sync for Shared {} // SAFETY: as above; sending the shared state is sending the items it holds. unsafe impl Send for Shared {} @@ -281,8 +318,13 @@ impl Drop for Shared { fn drop(&mut self) { // Every handle is gone, so no synchronization is needed and the // positions can be read directly. A slot between the two positions - // still holds an item nobody took, and dropping the queue must drop - // those rather than leak them. + // still holds an item nobody took, and tearing the queue down must + // account for them rather than leak them. + // + // Each is *moved out* and handed to the teardown policy rather than + // destroyed where it lies. For the default policy the two are the same + // thing; for a queue whose items own handles they are not, and this is + // the only place that sees every survivor. See `crate::disposal`. // // The sequence is consulted per slot rather than assuming every // position in the range holds an item. A producer cannot be mid-push @@ -299,11 +341,10 @@ impl Drop for Shared { if *slot.sequence.get_mut() == published { // SAFETY: the slot's sequence says the producer finished // writing it and the consumer never took it, so it holds an - // initialized item. It is dropped exactly once, because - // `position` advances every iteration. - unsafe { - slot.value.get_mut().assume_init_drop(); - } + // initialized item. It is read exactly once, because `position` + // advances every iteration and the slot is never read again. + let item = unsafe { slot.value.get_mut().assume_init_read() }; + self.teardown.dispose(item); } position = position.wrapping_add(1); } diff --git a/crates/windows-waitable-queues/src/mpsc/tests.rs b/crates/windows-waitable-queues/src/mpsc/tests.rs index ec70119d..c43bacb8 100644 --- a/crates/windows-waitable-queues/src/mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/mpsc/tests.rs @@ -14,7 +14,8 @@ //! scheduler rather than the queue -- green today, red on a different machine, //! and evidence of nothing either way. -use super::{BOUNDS, Consumer, Producer, bounded, validate_capacity}; +use super::{BOUNDS, Consumer, Producer, bounded, bounded_with_disposal, validate_capacity}; +use crate::Disposal; use crate::race_hooks; use crate::{PushError, RecvError, RecvTimeoutError}; use std::collections::BTreeMap; @@ -975,3 +976,158 @@ fn a_blocking_consumer_receives_every_item_from_every_producer() { "a parked consumer must miss nothing" ); } + +// --------------------------------------------------------------------------- +// Teardown: what becomes of items nobody drained. +// +// The policy itself is covered in `crate::disposal`'s suite. What is asserted +// here is that THIS shape's walk reaches it -- and this walk is the one that +// consults each slot's sequence rather than assuming the whole resident range +// is published, so it has a case the other shapes do not. +// --------------------------------------------------------------------------- + +/// Records that it was destroyed, so a test can tell "handed to the owner" from +/// "destructor run by whichever thread dropped last". +#[derive(Debug)] +struct Tracked { + id: u32, + destroyed: Arc, +} + +impl Drop for Tracked { + fn drop(&mut self) { + self.destroyed.fetch_add(1, Ordering::Relaxed); + } +} + +#[test] +fn undrained_items_reach_the_disposal_sink_instead_of_being_destroyed() { + let destroyed = Arc::new(AtomicUsize::new(0)); + let (undelivered, reaper) = std::sync::mpsc::channel(); + + { + let (tx, _rx) = bounded_with_disposal::( + 8, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("8 is a valid capacity"); + + for id in 0..5 { + tx.push(Tracked { + id, + destroyed: Arc::clone(&destroyed), + }) + .expect("room"); + } + } + + assert_eq!( + reaper.iter().map(|item| item.id).collect::>(), + vec![0, 1, 2, 3, 4], + "every undrained item must reach the sink, in queue order" + ); + assert_eq!(destroyed.load(Ordering::Relaxed), 5); +} + +#[test] +fn items_from_every_producer_reach_the_sink() { + // Multi-producer is what this shape is for, and teardown must not favour + // whichever handle happened to be dropped last. + let (undelivered, reaper) = std::sync::mpsc::channel(); + { + let (tx, _rx) = bounded_with_disposal::<(usize, usize)>( + 16, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("16 is a valid capacity"); + + let threads: Vec<_> = (0..PRODUCERS) + .map(|producer| { + let handle = tx.clone(); + thread::spawn(move || { + for sequence in 0..3 { + push_spinning(&handle, (producer, sequence)); + } + }) + }) + .collect(); + for thread in threads { + thread.join().expect("no producer may panic"); + } + } + + let mut per_producer = [0_usize; PRODUCERS]; + for (producer, _) in reaper.iter() { + per_producer[producer] += 1; + } + assert!( + per_producer.iter().all(|count| *count == 3), + "every producer's abandoned items must be accounted for, not just the last one's" + ); +} + +#[test] +fn the_sink_sees_survivors_after_the_ring_has_wrapped() { + let (undelivered, reaper) = std::sync::mpsc::channel(); + { + let (tx, rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + for round in 0..6 { + tx.push(round).expect("room"); + rx.pop().expect("an item"); + } + for round in 100..103 { + tx.push(round).expect("room"); + } + } + assert_eq!( + reaper.iter().collect::>(), + vec![100, 101, 102], + "the survivors are the resident range, not the whole slot array" + ); +} + +#[test] +fn a_queue_torn_down_by_the_producer_still_reaches_the_sink() { + let (undelivered, reaper) = std::sync::mpsc::channel(); + let (tx, rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + tx.push(1).expect("room"); + tx.push(2).expect("room"); + drop(rx); + drop(tx); + + assert_eq!(reaper.iter().collect::>(), vec![1, 2]); +} + +#[test] +fn without_a_sink_undrained_items_are_destroyed_in_place() { + let destroyed = Arc::new(AtomicUsize::new(0)); + { + let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); + for id in 0..3 { + tx.push(Tracked { + id, + destroyed: Arc::clone(&destroyed), + }) + .expect("room"); + } + } + assert_eq!(destroyed.load(Ordering::Relaxed), 3); +} diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index f24eed72..31028f68 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -86,6 +86,7 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; +use crate::disposal::{Disposal, Teardown}; use crate::doorbell::Doorbell; use crate::error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; @@ -210,6 +211,34 @@ const fn claim_word(reserved: u32, position: u32) -> u64 { /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { + build(capacity, Teardown::drop_in_place()) +} + +/// Creates a queue that hands its undrained items to `disposal` at teardown. +/// +/// Identical to [`bounded`] except for what becomes of items nobody took. See +/// [`Disposal`] for why that decision has to be made here rather than at +/// teardown, and why it matters for items that own a handle. +/// +/// **This is the shape where it matters most.** A reservation exists because +/// its message must not be lost; a message redeemed into a queue that is then +/// torn down undrained would be lost after all, just later and more quietly. +/// Pairing a reservation with a disposal sink is what closes that. +/// +/// # Errors +/// +/// As [`bounded`]. +pub fn bounded_with_disposal( + capacity: usize, + disposal: Disposal, +) -> Result<(Producer, Consumer), CapacityError> { + build(capacity, Teardown::handing_off(disposal)) +} + +fn build( + capacity: usize, + teardown: Teardown, +) -> Result<(Producer, Consumer), CapacityError> { validate_capacity(capacity, BOUNDS)?; let mut slots = Vec::with_capacity(capacity); @@ -225,6 +254,7 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit } let shared = Arc::new(Shared { + teardown, slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, @@ -265,6 +295,11 @@ struct Slot { } struct Shared { + /// What becomes of undrained items at teardown. + /// + /// Read only by [`Shared::drop`], which holds `&mut self`, so it needs no + /// synchronization and costs the hot paths nothing but its space. + teardown: Teardown, slots: Box<[Slot]>, mask: usize, capacity: usize, @@ -301,7 +336,14 @@ struct Shared { // publishes it. The write of the item therefore happens-before the read, and no // two threads ever touch the same slot's contents at the same time. `T: Send` is // required and sufficient because an item is moved between threads and never -// referenced from both. +// referenced from both.// +// The eardown field is deliberately NOT covered by that argument, because it +// cannot be: it holds a boxed FnMut, which is Send but not Sync, so this +// impl is forcing Sync onto a field that does not have it. That is sound for +// a narrower reason -- the field is unreachable through a shared reference. It +// is private, no method reads it, and the only access is from Drop, which +// holds &mut self and runs when the last handle is already gone. So no two +// threads can reach it at all, concurrently or otherwise. unsafe impl Sync for Shared {} // SAFETY: as above; sending the shared state is sending the items it holds. unsafe impl Send for Shared {} @@ -440,11 +482,10 @@ impl Drop for Shared { if *slot.sequence.get_mut() == published { // SAFETY: the slot's sequence says the producer finished writing // it and the consumer never took it, so it holds an initialized - // item. It is dropped exactly once, because `position` advances - // every iteration. - unsafe { - slot.value.get_mut().assume_init_drop(); - } + // item. It is read exactly once, because `position` advances + // every iteration and the slot is never read again. + let item = unsafe { slot.value.get_mut().assume_init_read() }; + self.teardown.dispose(item); } position = position.wrapping_add(1); } diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index 68470d98..746beb00 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -13,8 +13,10 @@ //! slot -- so the interesting cases all put the queue under pressure first. use super::{ - BOUNDS_MAX, Consumer, Producer, Reservation, bounded, claim_word, position_of, reserved_of, + BOUNDS_MAX, Consumer, Producer, Reservation, bounded, bounded_with_disposal, claim_word, + position_of, reserved_of, }; +use crate::Disposal; use crate::race_hooks; // The trait is imported anonymously because this module also names the concrete // `Consumer` type, and only its `drain` method is wanted here. That the two can @@ -756,3 +758,177 @@ fn both_reserving_shapes_satisfy_the_trait() { ); assert_eq!(mpsc_tx.outstanding_reservations(), 0); } + +// --------------------------------------------------------------------------- +// Teardown: what becomes of items nobody drained. +// +// The policy itself is covered in `crate::disposal`'s suite. What this shape +// adds is the interaction with reservations, which is where teardown matters +// most: a reservation exists because its message must not be lost, so a +// message redeemed into a queue that is then abandoned would be lost after +// all -- just later, and more quietly. +// --------------------------------------------------------------------------- + +/// Records that it was destroyed, so a test can tell "handed to the owner" from +/// "destructor run by whichever thread dropped last". +#[derive(Debug)] +struct Tracked { + id: u32, + destroyed: Arc, +} + +impl Drop for Tracked { + fn drop(&mut self) { + self.destroyed.fetch_add(1, Ordering::Relaxed); + } +} + +#[test] +fn undrained_items_reach_the_disposal_sink_instead_of_being_destroyed() { + let destroyed = Arc::new(AtomicUsize::new(0)); + let (undelivered, reaper) = std::sync::mpsc::channel(); + + { + let (tx, _rx) = bounded_with_disposal::( + 8, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("8 is a valid capacity"); + + for id in 0..5 { + tx.push(Tracked { + id, + destroyed: Arc::clone(&destroyed), + }) + .expect("room"); + } + } + + assert_eq!( + reaper.iter().map(|item| item.id).collect::>(), + vec![0, 1, 2, 3, 4] + ); + assert_eq!(destroyed.load(Ordering::Relaxed), 5); +} + +#[test] +fn a_reserved_message_abandoned_at_teardown_is_still_accounted_for() { + // **The case this shape exists to make safe.** A reservation is taken + // precisely because the message must not be lost. Redeeming it into a queue + // that is then torn down undrained would lose it after all, so the sink has + // to see it like any other survivor. + let (undelivered, reaper) = std::sync::mpsc::channel(); + { + let (tx, _rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + let slot = tx.reserve().expect("room"); + tx.push(1).expect("room"); + slot.send(99).expect("the room was ours"); + } + + assert_eq!( + reaper.iter().collect::>(), + vec![1, 99], + "a redeemed reservation is an ordinary queued item, and is accounted for as one" + ); +} + +#[test] +fn an_unredeemed_reservation_hands_nothing_to_the_sink() { + // A reservation holds *capacity*, not an item. There is nothing to dispose + // of, and reporting a phantom would be worse than reporting nothing -- + // the sink is the owner's accounting, and it must not lie in either + // direction. + let (undelivered, reaper) = std::sync::mpsc::channel(); + { + let (tx, _rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + let _slot = tx.reserve().expect("room"); + tx.push(1).expect("room"); + } + + assert_eq!( + reaper.iter().collect::>(), + vec![1], + "the abandoned reservation was capacity, not a message" + ); +} + +#[test] +fn a_queue_torn_down_by_a_reservation_still_reaches_the_sink() { + // A reservation counts as a producer, so it can be the last handle + // standing -- and then its drop is what tears the queue down. + let (undelivered, reaper) = std::sync::mpsc::channel(); + let (tx, rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + tx.push(1).expect("room"); + let slot = tx.reserve().expect("room"); + drop(tx); + drop(rx); + drop(slot); + + assert_eq!( + reaper.iter().collect::>(), + vec![1], + "whichever handle releases last must still account for the survivors" + ); +} + +#[test] +fn the_sink_sees_survivors_after_the_ring_has_wrapped() { + let (undelivered, reaper) = std::sync::mpsc::channel(); + { + let (tx, rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + for round in 0..6 { + tx.push(round).expect("room"); + rx.pop().expect("an item"); + } + for round in 100..103 { + tx.push(round).expect("room"); + } + } + assert_eq!(reaper.iter().collect::>(), vec![100, 101, 102]); +} + +#[test] +fn without_a_sink_undrained_items_are_destroyed_in_place() { + let destroyed = Arc::new(AtomicUsize::new(0)); + { + let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); + for id in 0..3 { + tx.push(Tracked { + id, + destroyed: Arc::clone(&destroyed), + }) + .expect("room"); + } + } + assert_eq!(destroyed.load(Ordering::Relaxed), 3); +} diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 4804f1fd..5373a6f2 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -76,6 +76,7 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; +use crate::disposal::{Disposal, Teardown}; use crate::doorbell::Doorbell; use crate::error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; @@ -117,12 +118,57 @@ const BOUNDS: Bounds = Bounds { /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { + build(capacity, Teardown::drop_in_place()) +} + +/// Creates a ring that hands its undrained items to `disposal` at teardown. +/// +/// Identical to [`bounded`] except for what becomes of items nobody took. See +/// [`Disposal`] for why that decision has to be made here rather than at +/// teardown, and why it matters for items that own a handle. +/// +/// # Errors +/// +/// As [`bounded`]. +/// +/// # Examples +/// +/// ``` +/// use std::sync::mpsc; +/// use windows_waitable_queues::{Disposal, spsc}; +/// +/// let (undelivered, reaper) = mpsc::channel(); +/// let (tx, rx) = spsc::bounded_with_disposal::( +/// 4, +/// Disposal::new(move |item| { +/// let _ = undelivered.send(item); +/// }), +/// )?; +/// +/// tx.push(1).expect("a fresh queue has room"); +/// drop((tx, rx)); +/// +/// assert_eq!(reaper.into_iter().collect::>(), vec![1]); +/// # Ok::<(), windows_waitable_queues::CapacityError>(()) +/// ``` +pub fn bounded_with_disposal( + capacity: usize, + disposal: Disposal, +) -> Result<(Producer, Consumer), CapacityError> { + build(capacity, Teardown::handing_off(disposal)) +} + +fn build( + capacity: usize, + teardown: Teardown, +) -> Result<(Producer, Consumer), CapacityError> { validate_capacity(capacity, BOUNDS)?; let mut slots = Vec::with_capacity(capacity); slots.resize_with(capacity, || UnsafeCell::new(MaybeUninit::uninit())); let shared = Arc::new(Shared { + teardown, slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, @@ -150,6 +196,11 @@ struct Shared { slots: Box<[UnsafeCell>]>, mask: usize, capacity: usize, + /// What becomes of undrained items at teardown. + /// + /// Read only by [`Shared::drop`], which holds `&mut self`, so it needs no + /// synchronization and costs the hot paths nothing but its space. + teardown: Teardown, /// Where the consumer will next read. Owned by the consumer. head: CacheAligned, /// Where the producer will next write. Owned by the producer. @@ -179,7 +230,14 @@ struct Shared { // publishes its position with a release store that the other acquires, so the // write of an item happens-before the read of that item. `T: Send` is required // and sufficient because an item is moved between the threads and never -// referenced from both. +// referenced from both.// +// The eardown field is deliberately NOT covered by that argument, because it +// cannot be: it holds a boxed FnMut, which is Send but not Sync, so this +// impl is forcing Sync onto a field that does not have it. That is sound for +// a narrower reason -- the field is unreachable through a shared reference. It +// is private, no method reads it, and the only access is from Drop, which +// holds &mut self and runs when the last handle is already gone. So no two +// threads can reach it at all, concurrently or otherwise. unsafe impl Sync for Shared {} // SAFETY: as above; sending the shared state is sending the items it holds. unsafe impl Send for Shared {} @@ -237,18 +295,24 @@ impl Drop for Shared { fn drop(&mut self) { // Both handles are gone, so no synchronization is needed and the // positions can be read directly. Every slot in `[head, tail)` still - // holds an initialized item that nobody took, and dropping the queue - // must drop them rather than leak them. + // holds an initialized item that nobody took, and tearing the queue + // down must account for them rather than leak them. + // + // Each is *moved out* and handed to the teardown policy rather than + // destroyed where it lies. For the default policy the two are the same + // thing; for a queue whose items own handles they are not, and this is + // the only place that sees every survivor. See `crate::disposal`. let head = *self.head.0.get_mut(); let tail = *self.tail.0.get_mut(); + let mask = self.mask; let mut pos = head; while pos != tail { // SAFETY: `pos` is in `[head, tail)`, so this slot was written by - // the producer and never read by the consumer. It is dropped - // exactly once, because `pos` advances every iteration. - unsafe { - (*self.slots[pos & self.mask].get()).assume_init_drop(); - } + // the producer and never read by the consumer. It is read exactly + // once, because `pos` advances every iteration, and the slot is + // never read again afterwards. + let item = unsafe { (*self.slots[pos & mask].get()).assume_init_read() }; + self.teardown.dispose(item); pos = pos.wrapping_add(1); } } diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index 95672c58..acd22da8 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -6,7 +6,8 @@ //! joined thread rather than a sleep, so they are deterministic: the assertion //! runs after the peer has finished, not after a guess about how long it takes. -use super::{BOUNDS, Consumer, Producer, bounded, validate_capacity}; +use super::{BOUNDS, Consumer, Producer, bounded, bounded_with_disposal, validate_capacity}; +use crate::Disposal; use crate::race_hooks; use crate::{PushError, RecvError, RecvTimeoutError}; use std::os::windows::io::AsRawHandle; @@ -1184,3 +1185,326 @@ fn dropping_the_queue_drops_a_reserved_item_it_still_holds() { "every undrained item must be dropped, including the reserved one" ); } + +// --------------------------------------------------------------------------- +// Teardown: what becomes of items nobody drained. +// +// The disposal policy's own behaviour is covered in `crate::disposal`'s suite. +// What is asserted here is that THIS shape's teardown walk actually reaches it +// -- each shape finds its survivors by walking its own layout, so covering one +// says nothing about the others. +// --------------------------------------------------------------------------- + +/// Records that it was destroyed, and where. +/// +/// The distinction the whole mechanism turns on is "handed to the owner" versus +/// "destructor run by whichever thread dropped last", so a test needs to be able +/// to tell those apart rather than merely count survivors. +#[derive(Debug)] +struct Tracked { + id: u32, + destroyed: Arc, +} + +impl Drop for Tracked { + fn drop(&mut self) { + self.destroyed.fetch_add(1, Ordering::Relaxed); + } +} + +#[test] +fn undrained_items_reach_the_disposal_sink_instead_of_being_destroyed() { + let destroyed = Arc::new(AtomicUsize::new(0)); + let (undelivered, reaper) = std::sync::mpsc::channel(); + + { + let (tx, _rx) = bounded_with_disposal::( + 8, + Disposal::new(move |item| { + // Moved out of teardown rather than destroyed in it, which is + // the entire point: the owner now decides when and where. + let _ = undelivered.send(item); + }), + ) + .expect("8 is a valid capacity"); + + for id in 0..5 { + tx.push(Tracked { + id, + destroyed: Arc::clone(&destroyed), + }) + .expect("room"); + } + } + + let rescued: Vec = reaper.iter().map(|item| item.id).collect(); + assert_eq!( + rescued, + vec![0, 1, 2, 3, 4], + "every undrained item must reach the sink, in queue order" + ); + assert_eq!( + destroyed.load(Ordering::Relaxed), + 5, + "and be destroyed only once the owner has finished with them" + ); +} + +#[test] +fn only_the_undrained_items_reach_the_sink() { + // What the consumer already took is the consumer's, and must not be + // reported as abandoned. + let (undelivered, reaper) = std::sync::mpsc::channel(); + let destroyed = Arc::new(AtomicUsize::new(0)); + + { + let (tx, rx) = bounded_with_disposal::( + 8, + Disposal::new(move |item: Tracked| { + let _ = undelivered.send(item.id); + }), + ) + .expect("8 is a valid capacity"); + + for id in 0..5 { + tx.push(Tracked { + id, + destroyed: Arc::clone(&destroyed), + }) + .expect("room"); + } + assert_eq!(rx.pop().expect("an item").id, 0); + assert_eq!(rx.pop().expect("an item").id, 1); + } + + assert_eq!( + reaper.iter().collect::>(), + vec![2, 3, 4], + "the two the consumer took are not abandoned items" + ); +} + +#[test] +fn an_empty_queue_hands_nothing_to_the_sink() { + let (undelivered, reaper) = std::sync::mpsc::channel(); + { + let (tx, rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + tx.push(1).expect("room"); + assert_eq!(rx.pop(), Some(1)); + } + assert_eq!( + reaper.iter().collect::>(), + Vec::::new(), + "a queue drained to empty has nothing to account for" + ); +} + +#[test] +fn the_sink_sees_survivors_after_the_ring_has_wrapped() { + // The teardown walk is over a wrapped range, which is where an index error + // would show up as the wrong items rather than as a crash. + let (undelivered, reaper) = std::sync::mpsc::channel(); + { + let (tx, rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + for round in 0..6 { + tx.push(round).expect("room"); + rx.pop().expect("an item"); + } + for round in 100..103 { + tx.push(round).expect("room"); + } + } + assert_eq!( + reaper.iter().collect::>(), + vec![100, 101, 102], + "the survivors are the resident range, not the whole slot array" + ); +} + +#[test] +fn a_queue_torn_down_by_the_producer_still_reaches_the_sink() { + // Which handle happens to die last is not knowable in advance, and the + // guarantee must not depend on it. Here the consumer goes first, so the + // producer's drop is what tears the queue down. + let (undelivered, reaper) = std::sync::mpsc::channel(); + let (tx, rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + tx.push(1).expect("room"); + tx.push(2).expect("room"); + drop(rx); + drop(tx); + + assert_eq!( + reaper.iter().collect::>(), + vec![1, 2], + "teardown accounts for the survivors whichever handle releases last" + ); +} + +#[test] +fn a_queue_torn_down_on_another_thread_still_reaches_the_sink() { + // The dropping thread is whichever one happens to release last, which is + // exactly why disposal cannot be left to it implicitly. + let (undelivered, reaper) = std::sync::mpsc::channel(); + let (tx, rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item| { + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + tx.push(1).expect("room"); + drop(rx); + + thread::spawn(move || drop(tx)) + .join() + .expect("the dropping thread must not panic"); + + assert_eq!(reaper.iter().collect::>(), vec![1]); +} + +#[test] +fn without_a_sink_undrained_items_are_destroyed_in_place() { + // The default, asserted rather than assumed -- it is the behaviour every + // existing caller has, and the reason a queue of `u32` need not think about + // any of this. + let destroyed = Arc::new(AtomicUsize::new(0)); + { + let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); + for id in 0..3 { + tx.push(Tracked { + id, + destroyed: Arc::clone(&destroyed), + }) + .expect("room"); + } + } + assert_eq!( + destroyed.load(Ordering::Relaxed), + 3, + "with no sink there is nowhere else for them to go" + ); +} + +// --------------------------------------------------------------------------- +// The hazard itself, stated as a test rather than as a paragraph. +// +// The claim disposal exists to make good on is not "the sink receives the +// items" -- that is the mechanism. The claim is that **a destructor which +// blocks does not run on whichever thread happened to release the last +// handle**, because that thread may be a pool callback that must not block. So +// these two assert where the destructor actually runs, with a control proving +// the test can tell the difference. +// --------------------------------------------------------------------------- + +/// Records the thread its destructor ran on. +#[derive(Debug)] +struct ThreadWitness(Arc>>); + +impl Drop for ThreadWitness { + fn drop(&mut self) { + self.0 + .lock() + .expect("no test holds this poisoned") + .push(std::thread::current().id()); + } +} + +#[test] +fn a_sink_keeps_the_destructor_off_the_thread_that_tore_the_queue_down() { + let ran_on = Arc::new(std::sync::Mutex::new(Vec::new())); + let (undelivered, reaper) = std::sync::mpsc::channel(); + + let (tx, rx) = bounded_with_disposal::( + 4, + Disposal::new(move |item: ThreadWitness| { + // The sink's whole job: move it somewhere a thread that may block + // will find it. Nothing here runs the destructor. + let _ = undelivered.send(item); + }), + ) + .expect("4 is a valid capacity"); + + tx.push(ThreadWitness(Arc::clone(&ran_on))).expect("room"); + + // Tear the queue down somewhere that is emphatically not this thread, + // standing in for the pool callback that must not block. + let teardown_thread = thread::spawn(move || { + drop(rx); + drop(tx); + std::thread::current().id() + }) + .join() + .expect("the tearing-down thread must not panic"); + + assert!( + ran_on.lock().expect("not poisoned").is_empty(), + "the destructor must not have run yet: the item is the owner's now, and \ + the thread that dropped the queue has already moved on" + ); + + // The owner takes delivery here, and *this* is where the destructor runs. + let rescued = reaper.recv().expect("the sink was handed the survivor"); + drop(rescued); + + let ran_on = ran_on.lock().expect("not poisoned"); + assert_eq!(ran_on.len(), 1); + assert_ne!( + ran_on[0], teardown_thread, + "a blocking destructor must not run on the thread that released the last handle" + ); + assert_eq!( + ran_on[0], + std::thread::current().id(), + "it runs where the owner chose to take delivery" + ); +} + +#[test] +fn without_a_sink_the_destructor_does_run_on_the_thread_that_tore_the_queue_down() { + // The control. Without it the test above could pass for the wrong reason -- + // it would look identical if destructors simply never ran anywhere + // observable. This is also the honest statement of the default: it is not + // that nothing blocks, it is that the blocking lands on a thread nobody + // chose. + let ran_on = Arc::new(std::sync::Mutex::new(Vec::new())); + + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(ThreadWitness(Arc::clone(&ran_on))).expect("room"); + + let teardown_thread = thread::spawn(move || { + drop(rx); + drop(tx); + std::thread::current().id() + }) + .join() + .expect("the tearing-down thread must not panic"); + + let ran_on = ran_on.lock().expect("not poisoned"); + assert_eq!(ran_on.len(), 1, "the item was destroyed at teardown"); + assert_eq!( + ran_on[0], teardown_thread, + "and with no sink it was destroyed on whichever thread released last, \ + which is exactly the behaviour a disposal sink exists to replace" + ); +} From 8de43c9f0133a352d4f6773940cc15634ed3d477 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 08:36:19 -0400 Subject: [PATCH 032/361] feat(waitable-queues): add refusal, doorbell-ring and opt-in high-water counters R9 asks for three numbers, and the interesting thing about them is that they do not cost the same. Each is placed where it is already paid for. Refusals increment only on the failure path. Doorbell rings increment only when SetEvent is actually called -- ~7 ns against a syscall measured at ~81 ns -- and the skipped signals are deliberately not counted, because that increment would land on exactly the path the skip exists to cheapen. Depth needed nothing new: Bounded::len already computes it from positions the queue keeps anyway, which is also why Observable does not restate it. One number with two spellings is two places to drift. High-water is the one that cannot be placed that way, and its cost is uneven in a way that lands squarely on D-16. A peak must observe every change. On spsc that is free, since the producer already reads head and owns tail, and on reserving_mpsc near-free, since its producer reads head for the room check. But mpsc's producer never reads head -- that is the property D-16 built a separate shape to preserve, because head is the one line every thread touches. Always-on would have imposed D-16's refused cost on every mpsc user, to serve a metric most will never read, immediately before M31.5 measures that exact path. Omitting it would have narrowed the shape. So it is opt-in at construction and off by default; when off, mpsc pays one predictable branch on a field written once at construction. Untracked reports None rather than 0, because "nobody was counting" and "it never filled" are different answers and only one of them should make a caller shrink a queue. Two independent switches across three shapes is why Options is now a builder, replacing M31.3's bounded_with_disposal. As constructors that is four per shape and twelve in the crate, with every future switch doubling it. The crate is unreleased, so the replacement cost nothing. One consequence inverts something already written down. sabotage.json carried a CONTROL that removed the skip optimisation expecting "survives", and it had earned its place by proving the suite asserted the contract rather than the implementation. Counting the rings makes the skip observable, so the same patch now has to be caught, and the entry changed sides. That is R9 working rather than a regression -- an optimisation nobody can measure is an assumption -- but it is a trade: the skip is now part of what the queue promises. The vacated control is replaced rather than dropped, by mpsc's tracking guard, which is genuinely an optimisation and must still survive removal. A sweep with no controls left has stopped asking whether its tests describe the contract. record_depth loads before it modifies, so the common case is a plain load of a rarely-written line rather than a read-modify-write on every push. The load may be stale and the fetch_max behind it is what keeps the result correct, which a concurrent test asserts rather than assumes. Completed item: M31.4: Observability (R9): depth, high-water, and a count of doorbells actually rung. That last one is what makes the skip rule measurable rather than assumed, and sabotage-verifiable -- disabling the skip must move the number. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 36 +++- .../windows-waitable-queues/DESIGN-NOTES.md | 85 +++++++- crates/windows-waitable-queues/README.md | 18 ++ crates/windows-waitable-queues/sabotage.json | 48 ++++- .../windows-waitable-queues/src/disposal.rs | 22 +- .../src/disposal/tests.rs | 26 +-- .../windows-waitable-queues/src/doorbell.rs | 31 ++- crates/windows-waitable-queues/src/lib.rs | 5 +- crates/windows-waitable-queues/src/metrics.rs | 129 ++++++++++++ .../src/metrics/tests.rs | 112 ++++++++++ crates/windows-waitable-queues/src/mpsc.rs | 137 ++++++++++-- .../windows-waitable-queues/src/mpsc/tests.rs | 167 +++++++++++++-- crates/windows-waitable-queues/src/options.rs | 110 ++++++++++ .../src/reserving_mpsc.rs | 135 ++++++++++-- .../src/reserving_mpsc/tests.rs | 143 +++++++++++-- crates/windows-waitable-queues/src/spsc.rs | 143 +++++++++++-- .../windows-waitable-queues/src/spsc/tests.rs | 195 +++++++++++++++--- crates/windows-waitable-queues/src/traits.rs | 54 +++++ 18 files changed, 1460 insertions(+), 136 deletions(-) create mode 100644 crates/windows-waitable-queues/src/metrics.rs create mode 100644 crates/windows-waitable-queues/src/metrics/tests.rs create mode 100644 crates/windows-waitable-queues/src/options.rs diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 62283074..d38fbb0c 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -331,9 +331,43 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m 194 unit tests, 7 doctests and 1 `compile_fail` doctest, the whole suite in 0.33s. Thirty-six sabotages, all behaving as declared: five new ones for this milestone. -- [ ] **M31.4** -- Observability (R9): depth, high-water, and **a count of doorbells actually rung**. That +- [x] **M31.4** -- Observability (R9): depth, high-water, and **a count of doorbells actually rung**. That last one is what makes the skip rule measurable rather than assumed, and sabotage-verifiable -- disabling the skip must move the number. + **The interesting thing about the three numbers is that they do not cost the same**, and each was placed + where it is already paid for. Refusals increment only on the failure path. Rings increment only when + `SetEvent` is actually called -- ~7 ns against a syscall measured at ~81 ns -- and the *skipped* signals + are deliberately not counted, because that increment would land on exactly the path the skip exists to + cheapen. Depth needed nothing new at all: `Bounded::len` already computes it from positions the queue + keeps anyway. + **High-water is the one that cannot be placed that way, and the cost is uneven in a way that lands on + D-16.** A peak must observe every change. On `spsc` that is free (the producer already reads `head` and + owns `tail`) and on `reserving_mpsc` near-free (its producer reads `head` for the room check), but + `mpsc`'s producer **never reads `head`** -- that is the property D-16 built a separate shape to + preserve. Always-on would have imposed D-16's refused cost on every `mpsc` user, to serve a metric most + will never read, immediately before M31.5 measures that exact path. Omitting it would have narrowed the + shape. So it is **opt-in at construction**, off by default, and `mpsc` pays one predictable branch on a + read-only field when it is off ([D-23](crates/windows-waitable-queues/DESIGN-NOTES.md#d-23)). The + engineer chose this over the narrow-trait and always-on alternatives when it was raised. + Untracked reports `None` rather than `0`, because "nobody was counting" and "it never filled" are + different answers and only one of them should make a caller shrink a queue. + **Two independent switches across three shapes is why `Options` is now a builder**, replacing M31.3's + `bounded_with_disposal`. As constructors that is four per shape and twelve in the crate, with every + future switch doubling it. The crate is unreleased, so the replacement cost nothing. + **One consequence is worth naming because it inverts something already written down** + ([D-24](crates/windows-waitable-queues/DESIGN-NOTES.md#d-24)). `sabotage.json` carried a *control* that + removed the skip optimisation expecting `survives` -- and it had earned its place, by proving the suite + asserted the contract rather than the implementation. Counting the rings makes the skip observable, so + the same patch now has to be **caught**, and the entry changed sides. That is R9 working rather than a + regression: an optimisation nobody can measure is an assumption. What it costs is that the skip is now + part of what the queue promises, which is the right trade for a queue whose reason to exist is a wakeup + protocol -- but it is a trade. The vacated control is replaced rather than dropped, by `mpsc`'s + tracking guard, which is genuinely an optimisation and must still survive removal. + **`Observable` deliberately does not restate depth** + ([D-25](crates/windows-waitable-queues/DESIGN-NOTES.md#d-25)), though D-2's sketch listed it: `len` + already reports it, and one number with two spellings is two places to drift. + 221 unit tests, 9 doctests and 1 `compile_fail` doctest, the whole suite in 0.30s. Thirty-nine + sabotages: three new, one converted from control to defect, and one new control replacing it. - [ ] **M31.5** -- The contention benchmark that decides whether the deferred shapes are needed: N producer threads pushing, throughput against N. **This is the item that either justifies or kills the linked and diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 6236433a..834b83f2 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -46,7 +46,9 @@ preferred. | D-20 | **Undrained items are handed to a caller-supplied sink at teardown, and the sink is chosen at construction because `Drop` has nowhere to hand them back to.** Without one they are destroyed on whichever thread released the last handle -- which may be a pool callback that must not block, and closing a handle to a dead network path can block for a long time. The default is unchanged; what changes is that it is now a named choice. | | D-21 | **A panicking disposal sink is caught and the teardown walk continues.** The sink is caller code inside a destructor: a panic escaping it abandons every item behind it -- the exact handles the mechanism exists to account for -- and during an unwind aborts the process. Catching declines to turn a caller's bug into a much larger one. | | D-22 | **No `into_remaining`, because it would not close the hole and `drain` already covers what it would do.** A consumer can take everything available, but a producer may push afterwards, so an orderly drain covers only the orderly path. The last handle to drop is the only place that sees every survivor. | - +| D-23 | **High-water tracking is opt-in at construction; refusals and doorbell rings are always on.** The difference is where each can be paid for: refusals sit on the failure path and rings on a path that already costs a syscall, but a peak has to observe *every* change -- and on `mpsc` that means the producer reading the consumer's position, the shared line [D-16](#d-16) built a separate shape to avoid. Untracked reports `None`, not `0`. | +| D-24 | **Counting the doorbell's rings turns the skip optimisation into part of the observable contract, and that is the point rather than a side effect.** R9 asks for the count precisely so "disabling the skip must change the number" -- so the sabotage entry for removing the skip changed from a control expecting `survives` to a defect expecting `caught`. An optimisation nobody can measure is an assumption. | +| D-25 | **`Observable` deliberately does not restate depth.** [D-2](#d-2)'s sketch listed it, but `Bounded::len` already reports it from positions the queue keeps anyway. Naming it twice would give one number two spellings and two places to drift. What belongs on `Observable` is only what must be *accumulated*. | ## D-2: capabilities are sliced, not gathered The first sketch of this crate had one `WaitableQueue` trait carrying push, pop, the doorbell, capacity, @@ -674,3 +676,84 @@ the extra method would be surface without capability. The orderly shutdown therefore stays what it already was: drain to empty, observe `Consumer::is_disconnected`, and take the final item with the receive loop's `finish` step. The sink is for everything that does not go to plan. + +## D-23: high-water is opt-in; refusals and rings are not + +R9 asks for three numbers, and the interesting thing about them is that they do not cost the same. + +**A counter on a hot path is a shared line every thread writes** -- the same false-sharing cost the +positions are carefully padded apart to avoid. So each was placed where it is already paid for, and the +one that could not be placed that way became a switch: + +| Metric | Where it increments | Cost | +|---|---|---| +| Refusals | only when a push is refused | off the success path entirely | +| Doorbell rings | only when `SetEvent` is actually called | ~7 ns against a syscall measured at ~81 ns | +| Peak depth | must observe **every** change | see below -- and it varies by shape | + +Peak depth is the awkward one, and the awkwardness is not uniform: + +- **`spsc`** -- free. The producer already loads `head` to decide there is room and owns `tail`, so the + depth is a subtraction of two values in hand, and the counter's line is producer-owned. +- **`reserving_mpsc`** -- near-free, for the same reason: its producer reads `head` for the room check + that honours reservations. Only the counter's line is shared, and it is written rarely. +- **`mpsc`** -- *not* free. Its producer never reads `head`; that is the whole property + [D-16](#d-16) built a separate shape to preserve, because `head` is the one line every thread touches. + Tracking makes it read that line on every push. + +Making it always-on would have imposed D-16's refused cost on every `mpsc` user to serve a metric most of +them will never read -- and would have done it just before [M31.5](../../CHECKLIST-io-domains.md) measures +exactly that path. Omitting it from `mpsc` would have narrowed the shape. So it is a switch, off by +default, and the cost lands only on queues that asked. Off, `mpsc` pays one predictable branch on a field +written once at construction: the line is shared but read-only, which is the cheap kind. + +**Untracked reports `None`, not `0`.** They are different answers -- "nobody was counting" versus "it +never filled" -- and a caller sizing a queue from the second when the first was true would be reading a +number nobody recorded. + +**Two independent switches across three shapes is why `Options` is a builder.** As constructors that is +four functions per shape and twelve in the crate, and every future switch doubles it. The plain `bounded` +stays, because the default is the common case and should not have to say so. + +`record_depth` loads before it modifies. An unconditional `fetch_max` would be a read-modify-write on a +shared line for every push; a new maximum is rare after a queue warms up, so the common case becomes a +plain load of a rarely-written line and the read-modify-write is reached only when the value is actually +about to change. The load may be stale, and the `fetch_max` behind it is what keeps the result correct +regardless -- which is asserted by a concurrent test rather than argued. + +## D-24: counting the rings makes the skip part of the contract + +R9 asks for the ring count so that "disabling the skip must change the number". Following that literally +has a consequence worth naming, because it inverts something this crate had already written down. + +`sabotage.json` carried an entry that removed the skip optimisation, expecting **`survives`**. It was a +*control*: skipping a redundant `SetEvent` changed no observable behaviour, so a suite that went red on +its removal would have been asserting the implementation instead of the contract -- and +[D-9](#d-9) records that the control earned its place by proving exactly that. + +Once the rings are counted, that stops being true. The count is observable, so the skip is observable, and +the same patch that had to survive now has to be **caught**. The entry changed sides in M31.4. + +**This is the requirement working, not a regression.** An optimisation nobody can measure is an +assumption, and R9's whole point is to stop this one being one. What it costs is that the skip is now part +of what the queue promises rather than a private cleverness -- so removing it later would be a behaviour +change, not a refactor. That is the right trade for a queue whose entire reason to exist is a wakeup +protocol, but it is a trade, and it should be made knowingly. + +The control it vacated is replaced rather than dropped: `mpsc`'s guard around the `head` load is an +optimisation and not a correctness device, so removing *that* must still leave the suite green. A sweep +with no controls left is a sweep that has stopped asking whether its tests describe the contract. + +## D-25: `Observable` does not restate depth + +[D-2](#d-2)'s sketch of this trait read "depth, high-water, doorbells actually rung". Depth was dropped on +the way to shipping it. + +`Bounded::len` already reports depth, computed on demand from positions the queue keeps anyway. Naming it +again on `Observable` would give one number two spellings, two doc comments, and two places to drift -- +which is the restatement problem this workspace has already paid for, recorded in the root +[DESIGN-NOTES.md](../../DESIGN-NOTES.md). The trait carries only what must be **accumulated**: facts about +the past that the queue's present state cannot reconstruct. + +Both handles implement it, because both ends have a question. A producer wants to know how often it was +refused; a consumer wants to know how deep the backlog got and how often it was actually woken. diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 25e34a62..9a49c663 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -94,6 +94,24 @@ again next lap" in a one-slot ring. is refused, with the nearest valid neighbours on the error, rather than silently turned into one the caller did not choose. +## What it will tell you about itself + +Three numbers, through the `Observable` trait on either handle: + +- **`refused()`** -- pushes turned away for want of room. This is the loss count, + and it counts room only: a push refused because the consumer is gone is the end + of the stream, not backpressure. +- **`doorbell_rings()`** -- `SetEvent` calls, not signal attempts. The difference + between the two *is* the skip optimisation, which is what makes this the number + worth reporting. +- **`high_water()`** -- the deepest the queue got, or `None` if nobody asked for + it to be tracked. It is the one metric that cannot be made free, so it is + opt-in via `Options::tracking_high_water`; `None` rather than `0` so you cannot + mistake "nobody was counting" for "it never filled". + +Depth is not on that list because `len()` already reports it, from positions the +queue keeps anyway. + ## Licence Copyright (c) Mike Grier. diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json index 217ba9d2..5597b96c 100644 --- a/crates/windows-waitable-queues/sabotage.json +++ b/crates/windows-waitable-queues/sabotage.json @@ -177,10 +177,10 @@ ] }, { - "name": "CONTROL: signal always syscalls, skipping the flag optimisation", + "name": "signal always syscalls, skipping the flag optimisation", "file": "src/doorbell.rs", - "expect": "survives", - "why": "A control, not a defect. Skipping a redundant SetEvent is an optimisation: removing it costs ~74ns per redundant push and changes no observable behaviour, so the suite MUST stay green. If this is ever reported as caught, a test has started asserting the implementation instead of the contract, and that test is the thing to fix.", + "expect": "caught", + "why": "**This entry changed sides in M31.4, and the reason is the point of the milestone.** It was a CONTROL expecting 'survives': skipping a redundant SetEvent is an optimisation, so removing it changed no observable behaviour and the suite had to stay green. R9 asks for a count of doorbells actually rung precisely so that the skip rule becomes MEASURABLE rather than assumed -- and once the ring count is observable, disabling the skip is observable too, which is what 'disabling the skip must change the number' means. So the same patch that had to survive now has to be caught, by the tests asserting one ring for four pushes. If this is ever reported as survived again, the ring count has stopped counting syscalls.", "find": [ " if self.signalled.swap(true, Ordering::AcqRel) {" ], @@ -415,6 +415,44 @@ " shared.release_producer();" ] }, + { + "name": "high-water records the latest depth rather than the peak", + "file": "src/metrics.rs", + "expect": "caught", + "why": "A high-water mark that follows the depth down is not a high-water mark; it is `len` with extra steps, and a caller sizing a queue from it would read whatever the depth happened to be when they looked. Caught deterministically rather than by a race, because the sabotage removes the comparison as well as the fetch_max.", + "find": [ + " if depth > high_water.load(Ordering::Relaxed) {", + " high_water.fetch_max(depth, Ordering::Relaxed);", + " }" + ], + "replace": [ + " high_water.store(depth, Ordering::Relaxed);" + ] + }, + { + "name": "high-water is tracked even when nobody asked", + "file": "src/metrics.rs", + "expect": "caught", + "why": "Tracking must be genuinely off by default, because it is the one metric that costs the push path something -- on mpsc it makes the producer read the consumer's position, which is the shared line that shape exists to avoid. If the default silently tracked, every mpsc user would be paying for an answer they never asked for, and the only visible symptom would be a number appearing where None belongs.", + "find": [ + " high_water: if track_high_water {" + ], + "replace": [ + " high_water: if true {" + ] + }, + { + "name": "CONTROL: mpsc reads head unconditionally, skipping the tracking guard", + "file": "src/mpsc.rs", + "expect": "survives", + "why": "A control, and the replacement for the one M31.4 converted into a defect. The guard around mpsc's `head` load is an OPTIMISATION, not a correctness device -- `record_depth` already returns early when tracking is off, so reading head regardless changes no observable behaviour and the suite MUST stay green. What it changes is the cost, which is the whole reason the guard is there. If this is ever reported as caught, a test has started asserting the implementation rather than the contract.", + "find": [ + " if self.shared.metrics.tracks_high_water() {" + ], + "replace": [ + " if true {" + ] + }, { "name": "teardown ignores the disposal sink and destroys in place", "file": "src/disposal.rs", @@ -514,11 +552,15 @@ "why": "Full invites a retry and Disconnected does not, and a full queue with no consumer will never drain -- so reporting Full here is telling the caller to spin forever. The preference has to be stated at the fullness branch specifically, because that branch returns before the general disconnection check below it is ever reached.", "find": [ " if !self.shared.consumer_live.load(Ordering::Acquire) {", + " // Not counted as a refusal: this is the end of the stream,", + " // not backpressure.", " return Err(PushError::Disconnected(item));", " }", + " self.shared.metrics.record_refusal();", " return Err(PushError::Full(item));" ], "replace": [ + " self.shared.metrics.record_refusal();", " return Err(PushError::Full(item));" ] } diff --git a/crates/windows-waitable-queues/src/disposal.rs b/crates/windows-waitable-queues/src/disposal.rs index dd005543..12583838 100644 --- a/crates/windows-waitable-queues/src/disposal.rs +++ b/crates/windows-waitable-queues/src/disposal.rs @@ -61,15 +61,15 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; /// /// ``` /// use std::sync::mpsc; -/// use windows_waitable_queues::{Disposal, spsc}; +/// use windows_waitable_queues::{Disposal, Options, spsc}; /// /// let (undelivered, reaper) = mpsc::channel(); -/// let (tx, rx) = spsc::bounded_with_disposal::( +/// let (tx, rx) = spsc::bounded_with::( /// 4, -/// Disposal::new(move |item| { +/// Options::new().disposal(Disposal::new(move |item| { /// // Cheap and non-blocking: the reaper thread does the real work. /// let _ = undelivered.send(item); -/// }), +/// })), /// )?; /// /// tx.push(1).expect("a fresh queue has room"); @@ -119,16 +119,10 @@ pub(crate) struct Teardown { } impl Teardown { - /// The default: destroy each surviving item where it lies. - pub(crate) const fn drop_in_place() -> Self { - Self { disposal: None } - } - - /// Hand each surviving item to `disposal` instead. - pub(crate) const fn handing_off(disposal: Disposal) -> Self { - Self { - disposal: Some(disposal), - } + /// Hand each surviving item to `disposal`, or destroy it where it lies if + /// there is none. + pub(crate) const fn new(disposal: Option>) -> Self { + Self { disposal } } /// Dispose of one surviving item. diff --git a/crates/windows-waitable-queues/src/disposal/tests.rs b/crates/windows-waitable-queues/src/disposal/tests.rs index 69809788..f0de7872 100644 --- a/crates/windows-waitable-queues/src/disposal/tests.rs +++ b/crates/windows-waitable-queues/src/disposal/tests.rs @@ -28,7 +28,7 @@ impl Drop for DropCounter { #[test] fn the_default_policy_destroys_the_item() { let drops = Arc::new(AtomicUsize::new(0)); - let mut teardown = Teardown::drop_in_place(); + let mut teardown = Teardown::new(None); teardown.dispose(DropCounter(Arc::clone(&drops))); assert_eq!( @@ -44,12 +44,12 @@ fn a_sink_receives_the_item_instead_of_it_being_destroyed() { let collected = Arc::new(AtomicUsize::new(0)); let seen = Arc::clone(&collected); - let mut teardown = Teardown::handing_off(Disposal::new(move |item: DropCounter| { + let mut teardown = Teardown::new(Some(Disposal::new(move |item: DropCounter| { seen.fetch_add(1, Ordering::Relaxed); // Deliberately kept alive past the sink call, which is the whole point: // the owner decides when -- and on which thread -- the destructor runs. std::mem::forget(item); - })); + }))); teardown.dispose(DropCounter(Arc::clone(&drops))); @@ -65,9 +65,9 @@ fn a_sink_receives_the_item_instead_of_it_being_destroyed() { fn every_item_reaches_the_sink_in_order() { let order = Arc::new(std::sync::Mutex::new(Vec::new())); let seen = Arc::clone(&order); - let mut teardown = Teardown::handing_off(Disposal::new(move |item: u32| { + let mut teardown = Teardown::new(Some(Disposal::new(move |item: u32| { seen.lock().expect("no test holds this poisoned").push(item); - })); + }))); for value in 0..10 { teardown.dispose(value); @@ -89,10 +89,10 @@ fn a_panicking_sink_does_not_strand_the_items_behind_it() { let disposed = Arc::new(AtomicUsize::new(0)); let seen = Arc::clone(&disposed); - let mut teardown = Teardown::handing_off(Disposal::new(move |item: u32| { + let mut teardown = Teardown::new(Some(Disposal::new(move |item: u32| { seen.fetch_add(1, Ordering::Relaxed); assert_ne!(item, 3, "deliberate panic from a caller-supplied sink"); - })); + }))); for value in 0..10 { teardown.dispose(value); @@ -111,9 +111,9 @@ fn a_panicking_sink_still_consumes_the_item_it_panicked_on() { // by the unwind rather than leaked. Asserted so that "the panic is caught" // is not mistaken for "the item is still somewhere". let drops = Arc::new(AtomicUsize::new(0)); - let mut teardown = Teardown::handing_off(Disposal::new(|_item: DropCounter| { + let mut teardown = Teardown::new(Some(Disposal::new(|_item: DropCounter| { panic!("deliberate panic from a caller-supplied sink"); - })); + }))); teardown.dispose(DropCounter(Arc::clone(&drops))); assert_eq!( @@ -131,10 +131,10 @@ fn a_sink_may_be_stateful_across_items() { let sum = Arc::new(AtomicUsize::new(0)); let report = Arc::clone(&sum); - let mut teardown = Teardown::handing_off(Disposal::new(move |item: u32| { + let mut teardown = Teardown::new(Some(Disposal::new(move |item: u32| { total += item; report.store(total as usize, Ordering::Relaxed); - })); + }))); for value in 1..=4 { teardown.dispose(value); @@ -146,9 +146,9 @@ fn a_sink_may_be_stateful_across_items() { fn the_debug_form_says_which_policy_is_in_force() { // Teardown is invisible until something goes wrong, so the one place it can // be observed should say which of the two it is. - let plain: Teardown = Teardown::drop_in_place(); + let plain: Teardown = Teardown::new(None); assert!(format!("{plain:?}").contains("hands_off: false")); - let handing: Teardown = Teardown::handing_off(Disposal::new(|_| {})); + let handing: Teardown = Teardown::new(Some(Disposal::new(|_| {}))); assert!(format!("{handing:?}").contains("hands_off: true")); } diff --git a/crates/windows-waitable-queues/src/doorbell.rs b/crates/windows-waitable-queues/src/doorbell.rs index 8878717b..2c5fcf52 100644 --- a/crates/windows-waitable-queues/src/doorbell.rs +++ b/crates/windows-waitable-queues/src/doorbell.rs @@ -163,7 +163,7 @@ use std::io; use std::os::windows::io::{AsHandle, AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}; use std::ptr; use std::sync::OnceLock; -use std::sync::atomic::{AtomicBool, Ordering, fence}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering, fence}; use windows_sys::Win32::Foundation::{DUPLICATE_SAME_ACCESS, DuplicateHandle, FALSE, TRUE}; use windows_sys::Win32::System::Threading::{ @@ -181,6 +181,12 @@ pub(crate) struct Doorbell { /// Mirrors the event's state so a redundant [`Doorbell::signal`] can skip /// its syscall. Only [`Doorbell::signal`] and [`Doorbell::clear`] write it. signalled: AtomicBool, + /// How many times a real `SetEvent` has been issued. + /// + /// See [`Doorbell::rings`] for why this counts syscalls rather than calls, + /// and the [module documentation](self) for why the skipped signals are not + /// counted alongside it. + rings: AtomicU64, } impl Doorbell { @@ -189,6 +195,7 @@ impl Doorbell { Self { event: OnceLock::new(), signalled: AtomicBool::new(false), + rings: AtomicU64::new(0), } } @@ -281,6 +288,18 @@ impl Doorbell { // setting it again would change nothing. return; } + // Counted here and nowhere else, which is what makes it free. This + // branch already costs a `SetEvent` -- measured at ~81 ns on this + // crate's reference machine against ~7 ns for an uncontended atomic -- + // so the increment is under a tenth of a cost that was already being + // paid, and it happens only on the rare path. + // + // **The skipped signals are deliberately not counted.** That would put + // a second read-modify-write on precisely the path the skip exists to + // cheapen, which is the one place in this type where an atomic is the + // whole cost rather than a rounding error on a syscall. + self.rings.fetch_add(1, Ordering::Relaxed); + // SAFETY: a live manual-reset event owned by this type for as long as // it exists; `SetEvent` has no other precondition. unsafe { @@ -288,6 +307,16 @@ impl Doorbell { } } + /// How many times this doorbell has actually rung. + /// + /// Counts `SetEvent` calls, not [`Doorbell::signal`] calls. The difference + /// between the two *is* the skip optimisation, which is why this number is + /// the one worth reporting: it makes the skip rule measurable rather than + /// assumed, and turning the skip off has to move it. + pub(crate) fn rings(&self) -> u64 { + self.rings.load(Ordering::Relaxed) + } + /// Report that the queue appears to have nothing to take. /// /// **The caller must re-check emptiness after this returns**, and must not diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 16672ea7..c04af197 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -78,7 +78,9 @@ mod capacity; pub mod disposal; mod doorbell; mod error; +mod metrics; pub mod mpsc; +mod options; #[cfg(test)] mod race_hooks; pub mod reserving_mpsc; @@ -87,7 +89,8 @@ pub mod traits; pub use disposal::Disposal; pub use error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; -pub use traits::{Bounded, Consumer, Drain, Producer, Reserving, Waitable}; +pub use options::Options; +pub use traits::{Bounded, Consumer, Drain, Observable, Producer, Reserving, Waitable}; /// Pads and aligns a value onto its own cache line. /// diff --git a/crates/windows-waitable-queues/src/metrics.rs b/crates/windows-waitable-queues/src/metrics.rs new file mode 100644 index 00000000..e0793c1f --- /dev/null +++ b/crates/windows-waitable-queues/src/metrics.rs @@ -0,0 +1,129 @@ +// Copyright (c) Mike Grier. + +//! Counters a queue keeps about itself. +//! +//! # What is here, and what is deliberately not +//! +//! Three numbers, and each is here because it answers a question the queue's +//! own state cannot: +//! +//! - **Refusals**, so backpressure is *measured* rather than inferred from a +//! caller's error handling. +//! - **Doorbell rings**, so the skip rule is measurable rather than assumed. +//! - **Peak depth**, so a bound can be chosen from evidence. +//! +//! **Depth itself is not here**, and its absence is a decision. `Bounded::len` +//! already reports it, computed on demand from positions the queue keeps +//! anyway, so restating it as a metric would give one number two names and two +//! places to drift. What belongs here is only what has to be *accumulated*. +//! +//! # Why two of the three are free and one is not +//! +//! A counter on a hot path is a shared line every thread writes, which is the +//! same false-sharing cost the queues are carefully padded to avoid. So each +//! counter is placed where it is already paid for: +//! +//! - **Refusals** increment only when a push is *refused*, which is off the +//! success path entirely. +//! - **Rings** increment only when the doorbell actually calls `SetEvent`, +//! which is a syscall measured at ~81 ns against ~7 ns for an uncontended +//! atomic. The skipped signals -- the hot ones -- are deliberately *not* +//! counted, because that increment would land on exactly the path the skip +//! exists to cheapen. +//! - **Peak depth** cannot be placed that way, because it must observe every +//! change. It is therefore **opt-in**, and off by default; see +//! [`Metrics::record_depth`] and +//! [D-23](../../DESIGN-NOTES.md#d-23). + +use core::fmt; +use core::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + +/// The counters one queue keeps. +pub(crate) struct Metrics { + /// Pushes refused for want of room. + /// + /// Written only on the failure path, so it costs a successful push nothing. + refused: AtomicU64, + /// The deepest the queue has been observed to get, if it is being tracked. + /// + /// `Option` rather than a sentinel because "not tracked" and "never got + /// past empty" are different answers, and a caller acting on a `0` that + /// meant the former would be reading a number nobody recorded. + high_water: Option, +} + +impl Metrics { + /// Counters with peak depth left untracked, which is the default. + pub(crate) const fn new(track_high_water: bool) -> Self { + Self { + refused: AtomicU64::new(0), + high_water: if track_high_water { + Some(AtomicUsize::new(0)) + } else { + None + }, + } + } + + /// Whether peak depth is being tracked. + /// + /// Read on the push path by shapes whose producer does not otherwise know + /// the depth, so that they only pay for the load that computes it when + /// somebody asked for the answer. The field is written once at construction + /// and never again, so the line is shared but read-only -- which is the + /// cheap kind. + pub(crate) fn tracks_high_water(&self) -> bool { + self.high_water.is_some() + } + + /// Record that the queue reached `depth`. + /// + /// # Why this loads before it modifies + /// + /// The obvious spelling is an unconditional [`AtomicUsize::fetch_max`], and + /// it would be a read-modify-write on a shared line for **every push** -- + /// the cost this crate pads its positions apart to avoid. + /// + /// A new maximum is rare: it happens while a queue is filling and then + /// almost never again. So the common case is turned into a plain load of a + /// line that is written rarely and read often, and the read-modify-write is + /// reached only when the value is actually about to change. The load can be + /// stale, and the `fetch_max` that follows is what makes the result correct + /// anyway -- a racing pair of producers may both see an old maximum, but + /// `fetch_max` keeps the larger of the two regardless of which lands first. + pub(crate) fn record_depth(&self, depth: usize) { + let Some(high_water) = self.high_water.as_ref() else { + return; + }; + if depth > high_water.load(Ordering::Relaxed) { + high_water.fetch_max(depth, Ordering::Relaxed); + } + } + + /// Record that a push was refused for want of room. + pub(crate) fn record_refusal(&self) { + self.refused.fetch_add(1, Ordering::Relaxed); + } + + /// How many pushes have been refused for want of room. + pub(crate) fn refused(&self) -> u64 { + self.refused.load(Ordering::Relaxed) + } + + /// The deepest the queue has been observed to get, if tracked. + pub(crate) fn high_water(&self) -> Option { + Some(self.high_water.as_ref()?.load(Ordering::Relaxed)) + } +} + +impl fmt::Debug for Metrics { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Metrics") + .field("refused", &self.refused()) + .field("high_water", &self.high_water()) + .finish() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/metrics/tests.rs b/crates/windows-waitable-queues/src/metrics/tests.rs new file mode 100644 index 00000000..9b4487b8 --- /dev/null +++ b/crates/windows-waitable-queues/src/metrics/tests.rs @@ -0,0 +1,112 @@ +// Copyright (c) Mike Grier. + +//! Tests for the counters in isolation, with no queue attached. +//! +//! Their behaviour *through* a queue is asserted in each shape's own suite, +//! because each records depth from a different place -- and on `mpsc` records +//! it only when asked. What is tested here is the arithmetic they share. + +use super::Metrics; + +#[test] +fn refusals_start_at_zero_and_accumulate() { + let metrics = Metrics::new(false); + assert_eq!(metrics.refused(), 0); + + for expected in 1..=5 { + metrics.record_refusal(); + assert_eq!(metrics.refused(), expected); + } +} + +#[test] +fn an_untracked_high_water_reports_none_rather_than_zero() { + // The distinction the `Option` exists to draw. A caller sizing a queue from + // `Some(0)` would conclude it never filled; from `None` it learns that + // nobody was counting, which is a different fact and demands a different + // response. + let metrics = Metrics::new(false); + assert!(!metrics.tracks_high_water()); + assert_eq!(metrics.high_water(), None); + + // And recording into it is a no-op rather than an error, so the shapes can + // call it unconditionally where the depth is free. + metrics.record_depth(9); + assert_eq!(metrics.high_water(), None); +} + +#[test] +fn a_tracked_high_water_starts_at_some_zero() { + // Distinct from `None`: this queue *is* counting, and has seen nothing. + let metrics = Metrics::new(true); + assert!(metrics.tracks_high_water()); + assert_eq!(metrics.high_water(), Some(0)); +} + +#[test] +fn high_water_keeps_the_peak_rather_than_the_latest() { + let metrics = Metrics::new(true); + + metrics.record_depth(3); + assert_eq!(metrics.high_water(), Some(3)); + + metrics.record_depth(7); + assert_eq!(metrics.high_water(), Some(7)); + + // The point of a high-water mark: it does not fall when the queue drains. + metrics.record_depth(1); + assert_eq!( + metrics.high_water(), + Some(7), + "a peak that receded is still a peak that happened" + ); + + metrics.record_depth(7); + assert_eq!(metrics.high_water(), Some(7), "and equal is not greater"); +} + +#[test] +fn concurrent_recorders_do_not_lose_the_peak() { + // `record_depth` loads before it modifies, so two threads can both observe + // a stale maximum. The `fetch_max` that follows is what makes the result + // correct anyway, and this is the test that says so: without it, the + // load-then-modify shortcut would be a lost update rather than an + // optimisation. + use std::sync::Arc; + use std::thread; + + const THREADS: usize = 4; + const PER_THREAD: usize = 500; + + let metrics = Arc::new(Metrics::new(true)); + let threads: Vec<_> = (0..THREADS) + .map(|offset| { + let metrics = Arc::clone(&metrics); + thread::spawn(move || { + for depth in 0..PER_THREAD { + metrics.record_depth(depth + offset * PER_THREAD); + } + }) + }) + .collect(); + for thread in threads { + thread.join().expect("no recorder may panic"); + } + + assert_eq!( + metrics.high_water(), + Some(THREADS * PER_THREAD - 1), + "the largest value any thread recorded must survive every race" + ); +} + +#[test] +fn the_debug_form_reports_both_counters() { + let metrics = Metrics::new(true); + metrics.record_refusal(); + metrics.record_depth(4); + + let shown = format!("{metrics:?}"); + assert!(shown.contains("refused: 1"), "{shown}"); + assert!(shown.contains("Some(4)"), "{shown}"); +} diff --git a/crates/windows-waitable-queues/src/mpsc.rs b/crates/windows-waitable-queues/src/mpsc.rs index 031e9154..473f8fde 100644 --- a/crates/windows-waitable-queues/src/mpsc.rs +++ b/crates/windows-waitable-queues/src/mpsc.rs @@ -80,9 +80,11 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; -use crate::disposal::{Disposal, Teardown}; +use crate::disposal::Teardown; use crate::doorbell::Doorbell; use crate::error::{CapacityError, PushError, RecvError, RecvTimeoutError}; +use crate::metrics::Metrics; +use crate::options::Options; /// What this shape accepts as a capacity. /// @@ -145,28 +147,33 @@ const BOUNDS: Bounds = Bounds { /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { - build(capacity, Teardown::drop_in_place()) + build(capacity, Options::new()) } -/// Creates a queue that hands its undrained items to `disposal` at teardown. +/// Creates a queue with something other than the default behaviour. /// -/// Identical to [`bounded`] except for what becomes of items nobody took. See -/// [`Disposal`] for why that decision has to be made here rather than at -/// teardown, and why it matters for items that own a handle. +/// Identical to [`bounded`] except for what [`Options`] asks for. +/// +/// **Note which switch costs this shape something.** +/// [`Options::tracking_high_water`] makes the producer read the consumer's +/// position on every push -- the single shared line this shape's push is built +/// to avoid touching, and the reason +/// [`reserving_mpsc`](crate::reserving_mpsc) exists as a separate shape at all. +/// Off, which is the default, it costs one predictable branch on a field that +/// is written once at construction. /// /// # Errors /// /// As [`bounded`]. -pub fn bounded_with_disposal( +pub fn bounded_with( capacity: usize, - disposal: Disposal, + options: Options, ) -> Result<(Producer, Consumer), CapacityError> { - build(capacity, Teardown::handing_off(disposal)) + build(capacity, options) } - fn build( capacity: usize, - teardown: Teardown, + options: Options, ) -> Result<(Producer, Consumer), CapacityError> { validate_capacity(capacity, BOUNDS)?; @@ -179,7 +186,8 @@ fn build( } let shared = Arc::new(Shared { - teardown, + teardown: Teardown::new(options.disposal), + metrics: Metrics::new(options.track_high_water), slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, @@ -224,6 +232,8 @@ struct Shared { /// Read only by [`Shared::drop`], which holds `&mut self`, so it needs no /// synchronization and costs the hot paths nothing but its space. teardown: Teardown, + /// The counters this queue keeps about itself. See [`crate::metrics`]. + metrics: Metrics, slots: Box<[Slot]>, mask: usize, capacity: usize, @@ -398,8 +408,11 @@ impl Producer { // whose consumer is gone will never drain, and telling the // caller to retry would be telling it to spin forever. if !self.shared.consumer_live.load(Ordering::Acquire) { + // Not counted as a refusal: this is the end of the stream, + // not backpressure. return Err(PushError::Disconnected(item)); } + self.shared.metrics.record_refusal(); return Err(PushError::Full(item)); } if difference > 0 { @@ -445,6 +458,22 @@ impl Producer { slot.sequence .store(position.wrapping_add(1), Ordering::Release); + // **Guarded, and this branch is the whole reason high-water is a + // switch.** This shape's producer never reads `head` -- that property + // is what keeps its push off the one line every thread touches, and it + // is why `reserving_mpsc` is a separate shape rather than a method + // here. Depth cannot be known without that read, so the read is taken + // only when somebody asked for the answer. + // + // Off, the cost is one predictable branch on a field written once at + // construction, so the line is shared but read-only -- the cheap kind. + if self.shared.metrics.tracks_high_water() { + let head = self.shared.head.0.load(Ordering::Acquire); + self.shared + .metrics + .record_depth(position.wrapping_sub(head).wrapping_add(1)); + } + // After the publication, never before: the doorbell says "there is // something to take", and that must not become true before the item is // actually takeable. A consumer woken early would find nothing, clear @@ -905,6 +934,90 @@ impl crate::Bounded for Consumer { } } +impl Shared { + /// The counters, as the [`Observable`](crate::Observable) trait reports + /// them. Written once so the two handles cannot drift apart. + fn refused(&self) -> u64 { + self.metrics.refused() + } + + fn doorbell_rings(&self) -> u64 { + self.doorbell.rings() + } + + fn high_water(&self) -> Option { + self.metrics.high_water() + } +} + +impl Producer { + /// How many pushes have been refused for want of room. + #[must_use] + pub fn refused(&self) -> u64 { + self.shared.refused() + } + + /// How many times the doorbell has actually rung. + #[must_use] + pub fn doorbell_rings(&self) -> u64 { + self.shared.doorbell_rings() + } + + /// The deepest this queue has been, if tracking was asked for. + #[must_use] + pub fn high_water(&self) -> Option { + self.shared.high_water() + } +} + +impl Consumer { + /// How many pushes have been refused for want of room. + #[must_use] + pub fn refused(&self) -> u64 { + self.shared.refused() + } + + /// How many times the doorbell has actually rung. + #[must_use] + pub fn doorbell_rings(&self) -> u64 { + self.shared.doorbell_rings() + } + + /// The deepest this queue has been, if tracking was asked for. + #[must_use] + pub fn high_water(&self) -> Option { + self.shared.high_water() + } +} + +impl crate::Observable for Producer { + fn refused(&self) -> u64 { + Self::refused(self) + } + + fn doorbell_rings(&self) -> u64 { + Self::doorbell_rings(self) + } + + fn high_water(&self) -> Option { + Self::high_water(self) + } +} + +impl crate::Observable for Consumer { + fn refused(&self) -> u64 { + Self::refused(self) + } + + fn doorbell_rings(&self) -> u64 { + Self::doorbell_rings(self) + } + + fn high_water(&self) -> Option { + Self::high_water(self) + } +} + impl crate::Waitable for Consumer { fn doorbell(&self) -> io::Result> { Self::doorbell(self) diff --git a/crates/windows-waitable-queues/src/mpsc/tests.rs b/crates/windows-waitable-queues/src/mpsc/tests.rs index c43bacb8..48b24439 100644 --- a/crates/windows-waitable-queues/src/mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/mpsc/tests.rs @@ -14,9 +14,9 @@ //! scheduler rather than the queue -- green today, red on a different machine, //! and evidence of nothing either way. -use super::{BOUNDS, Consumer, Producer, bounded, bounded_with_disposal, validate_capacity}; -use crate::Disposal; +use super::{BOUNDS, Consumer, Producer, bounded, bounded_with, validate_capacity}; use crate::race_hooks; +use crate::{Disposal, Options}; use crate::{PushError, RecvError, RecvTimeoutError}; use std::collections::BTreeMap; use std::os::windows::io::AsRawHandle; @@ -1006,11 +1006,11 @@ fn undrained_items_reach_the_disposal_sink_instead_of_being_destroyed() { let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, _rx) = bounded_with_disposal::( + let (tx, _rx) = bounded_with::( 8, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("8 is a valid capacity"); @@ -1037,11 +1037,11 @@ fn items_from_every_producer_reach_the_sink() { // whichever handle happened to be dropped last. let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, _rx) = bounded_with_disposal::<(usize, usize)>( + let (tx, _rx) = bounded_with::<(usize, usize)>( 16, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("16 is a valid capacity"); @@ -1074,11 +1074,11 @@ fn items_from_every_producer_reach_the_sink() { fn the_sink_sees_survivors_after_the_ring_has_wrapped() { let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -1100,11 +1100,11 @@ fn the_sink_sees_survivors_after_the_ring_has_wrapped() { #[test] fn a_queue_torn_down_by_the_producer_still_reaches_the_sink() { let (undelivered, reaper) = std::sync::mpsc::channel(); - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -1131,3 +1131,142 @@ fn without_a_sink_undrained_items_are_destroyed_in_place() { } assert_eq!(destroyed.load(Ordering::Relaxed), 3); } + +// --------------------------------------------------------------------------- +// Observability. +// +// This shape's high-water is the one that costs something, so what matters +// here is that it is genuinely off unless asked for and genuinely right when +// it is. +// --------------------------------------------------------------------------- + +#[test] +fn refusals_are_counted_but_disconnections_are_not() { + let (tx, rx) = bounded::(2).expect("2 is a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + assert!(tx.push(3).is_err()); + assert_eq!(tx.refused(), 1); + assert_eq!(rx.refused(), 1, "both handles report the same queue"); + + drop(rx); + assert!(matches!(tx.push(4), Err(PushError::Disconnected(4)))); + assert_eq!( + tx.refused(), + 1, + "the end of the stream is not backpressure and must not be counted as it" + ); +} + +#[test] +fn every_producer_counts_into_the_same_refusal_total() { + let (tx, _rx) = bounded::(2).expect("2 is a valid capacity"); + let second = tx.clone(); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + + assert!(tx.push(3).is_err()); + assert!(second.push(4).is_err()); + assert_eq!( + tx.refused(), + 2, + "refusals are a property of the queue, not of whichever handle saw them" + ); +} + +#[test] +fn high_water_is_untracked_by_default() { + // **This shape's default matters most**, because tracking makes its + // producer read the consumer's position on every push -- the single shared + // line the design avoids, and the reason `reserving_mpsc` exists. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + + assert_eq!(tx.high_water(), None); + assert_eq!(rx.high_water(), None); +} + +#[test] +fn high_water_records_the_peak_when_asked_for() { + let (tx, rx) = bounded_with::(8, Options::new().tracking_high_water()) + .expect("8 is a valid capacity"); + assert_eq!(tx.high_water(), Some(0)); + + for value in 0..5 { + tx.push(value).expect("room"); + } + assert_eq!(tx.high_water(), Some(5)); + + while rx.pop().is_some() {} + assert_eq!( + rx.high_water(), + Some(5), + "the mark is the deepest it got, not the depth right now" + ); +} + +#[test] +fn high_water_survives_contention_from_many_producers() { + // The peak has to be the real one, not whichever producer happened to + // write last. `record_depth` loads before it modifies, so this is the test + // that says the `fetch_max` behind that shortcut is doing its job. + const PER_PRODUCER: usize = 200; + let (tx, rx) = bounded_with::(64, Options::new().tracking_high_water()) + .expect("64 is a valid capacity"); + + let threads: Vec<_> = (0..PRODUCERS) + .map(|_| { + let handle = tx.clone(); + thread::spawn(move || { + for value in 0..PER_PRODUCER { + push_spinning(&handle, value); + } + }) + }) + .collect(); + drop(tx); + + let mut received = 0; + while rx.recv().is_ok() { + received += 1; + } + for thread in threads { + thread.join().expect("no producer may panic"); + } + + assert_eq!(received, PRODUCERS * PER_PRODUCER); + let peak = rx.high_water().expect("tracking was asked for"); + assert!( + (1..=64).contains(&peak), + "the peak must be a depth the queue could actually reach, got {peak}" + ); +} + +#[test] +fn the_ring_count_reports_syscalls_rather_than_signal_attempts() { + // The number the skip rule is measured by; see the same test on + // `reserving_mpsc` for the full argument. + let (tx, rx) = bounded::(8).expect("8 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + for value in 0..4 { + tx.push(value).expect("room"); + } + + assert_eq!( + rx.doorbell_rings(), + 1, + "the first push lit it; the other three had nothing to do" + ); +} + +#[test] +fn a_poll_only_consumer_rings_no_doorbells() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + for value in 0..4 { + tx.push(value).expect("room"); + } + while rx.pop().is_some() {} + assert_eq!(rx.doorbell_rings(), 0); +} diff --git a/crates/windows-waitable-queues/src/options.rs b/crates/windows-waitable-queues/src/options.rs new file mode 100644 index 00000000..adde38e6 --- /dev/null +++ b/crates/windows-waitable-queues/src/options.rs @@ -0,0 +1,110 @@ +// Copyright (c) Mike Grier. + +//! The switches a queue is built with. +//! +//! # Why a builder rather than more constructors +//! +//! There are two independent choices at construction -- what becomes of +//! undrained items, and whether peak depth is tracked -- across three shapes. +//! Spelled as constructors that is four functions per shape and twelve in the +//! crate, and every future switch doubles it again. Spelled as one value passed +//! to one `bounded_with`, a new switch is a new method and nothing else moves. +//! +//! The plain [`bounded`](crate::spsc::bounded) constructor stays, because the +//! default is the overwhelmingly common case and it should not have to say so. +//! +//! # Both switches are off by default, for different reasons +//! +//! **Disposal** is off because destroying an item that owns nothing, where it +//! lies, is exactly right -- a queue of `u32` should not have to think about +//! teardown at all. See [`Disposal`]. +//! +//! **High-water tracking** is off because it is the one metric that cannot be +//! made free. Refusals and doorbell rings sit on paths that were already paying +//! for themselves, but a peak has to observe every change, and on +//! [`mpsc`](crate::mpsc) observing the depth means the producer reading the +//! consumer's position -- the single shared line that shape's push is built to +//! avoid touching. So it is a switch, and the cost lands only on queues that +//! asked for the answer. + +use core::fmt; + +use crate::disposal::Disposal; + +/// What a queue is built with, beyond its capacity. +/// +/// # Examples +/// +/// ``` +/// use windows_waitable_queues::{Options, spsc}; +/// +/// let (tx, rx) = spsc::bounded_with::(4, Options::new().tracking_high_water())?; +/// +/// tx.push(1).expect("a fresh queue has room"); +/// tx.push(2).expect("a fresh queue has room"); +/// assert_eq!(rx.pop(), Some(1)); +/// +/// // The peak, not the depth right now. +/// assert_eq!(rx.len(), 1); +/// assert_eq!(rx.high_water(), Some(2)); +/// # Ok::<(), windows_waitable_queues::CapacityError>(()) +/// ``` +pub struct Options { + pub(crate) disposal: Option>, + pub(crate) track_high_water: bool, +} + +impl Options { + /// The defaults: undrained items destroyed in place, peak depth untracked. + #[must_use] + pub fn new() -> Self { + Self { + disposal: None, + track_high_water: false, + } + } + + /// Hand undrained items to `disposal` at teardown instead of destroying + /// them where they lie. + /// + /// See [`Disposal`] for why this has to be decided here rather than asked + /// for at teardown. + #[must_use] + pub fn disposal(mut self, disposal: Disposal) -> Self { + self.disposal = Some(disposal); + self + } + + /// Track the deepest the queue gets, readable from + /// [`Observable::high_water`](crate::Observable::high_water). + /// + /// **This is the one option that costs the push path something**, which is + /// why it is off by default. A peak has to observe every change, so on + /// `mpsc` it makes the producer read the consumer's position -- the shared + /// line that shape's push exists to avoid. On `spsc` and `reserving_mpsc` + /// the producer already knows the depth, so it costs those two almost + /// nothing. + /// + /// Untracked, `high_water` reports `None` rather than `0`, so a caller + /// cannot mistake "nobody was counting" for "it never filled". + #[must_use] + pub fn tracking_high_water(mut self) -> Self { + self.track_high_water = true; + self + } +} + +impl Default for Options { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Debug for Options { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Options") + .field("disposal", &self.disposal.is_some()) + .field("track_high_water", &self.track_high_water) + .finish() + } +} diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 31028f68..6bb75bbc 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -86,9 +86,11 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; -use crate::disposal::{Disposal, Teardown}; +use crate::disposal::Teardown; use crate::doorbell::Doorbell; use crate::error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; +use crate::metrics::Metrics; +use crate::options::Options; /// How many of the claim word's bits carry the position. /// @@ -211,33 +213,35 @@ const fn claim_word(reserved: u32, position: u32) -> u64 { /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { - build(capacity, Teardown::drop_in_place()) + build(capacity, Options::new()) } -/// Creates a queue that hands its undrained items to `disposal` at teardown. +/// Creates a queue with something other than the default behaviour. /// -/// Identical to [`bounded`] except for what becomes of items nobody took. See -/// [`Disposal`] for why that decision has to be made here rather than at -/// teardown, and why it matters for items that own a handle. +/// Identical to [`bounded`] except for what [`Options`] asks for. /// -/// **This is the shape where it matters most.** A reservation exists because -/// its message must not be lost; a message redeemed into a queue that is then -/// torn down undrained would be lost after all, just later and more quietly. -/// Pairing a reservation with a disposal sink is what closes that. +/// **This is the shape where disposal matters most.** A reservation exists +/// because its message must not be lost; a message redeemed into a queue that +/// is then torn down undrained would be lost after all, just later and more +/// quietly. Pairing a reservation with a disposal sink is what closes that. +/// +/// [`Options::tracking_high_water`] costs this shape almost nothing, unlike +/// [`mpsc`](crate::mpsc): the producer already reads the consumer's position +/// to decide whether there is room beyond the reservations, so the depth is a +/// subtraction of two numbers it is already holding. /// /// # Errors /// /// As [`bounded`]. -pub fn bounded_with_disposal( +pub fn bounded_with( capacity: usize, - disposal: Disposal, + options: Options, ) -> Result<(Producer, Consumer), CapacityError> { - build(capacity, Teardown::handing_off(disposal)) + build(capacity, options) } - fn build( capacity: usize, - teardown: Teardown, + options: Options, ) -> Result<(Producer, Consumer), CapacityError> { validate_capacity(capacity, BOUNDS)?; @@ -254,7 +258,8 @@ fn build( } let shared = Arc::new(Shared { - teardown, + teardown: Teardown::new(options.disposal), + metrics: Metrics::new(options.track_high_water), slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, @@ -300,6 +305,8 @@ struct Shared { /// Read only by [`Shared::drop`], which holds `&mut self`, so it needs no /// synchronization and costs the hot paths nothing but its space. teardown: Teardown, + /// The counters this queue keeps about itself. See [`crate::metrics`]. + metrics: Metrics, slots: Box<[Slot]>, mask: usize, capacity: usize, @@ -432,6 +439,15 @@ impl Shared { /// must not have published it already. A position is claimed by exactly one /// producer, so this is the only writer of the slot. unsafe fn publish(&self, position: u32, item: T) { + // Near-free on this shape, unlike `mpsc`: the producer has already + // read `head` to decide there was room beyond the reservations, so the + // depth is a subtraction of two numbers it is holding. Only the + // counter's line is shared, and it is written rarely -- see + // `Metrics::record_depth` for why the load comes before the modify. + let head = self.head.0.load(Ordering::Relaxed); + self.metrics + .record_depth(position.wrapping_sub(head).wrapping_add(1) as usize); + let slot = &self.slots[position as usize & self.mask]; // SAFETY: the caller's claim makes this thread the only writer, and the // room check that permitted the claim means the consumer has finished @@ -530,8 +546,11 @@ impl Producer { // whose consumer is gone will never drain, and telling the // caller to retry would be telling it to spin forever. if !self.shared.consumer_live.load(Ordering::Acquire) { + // Not counted as a refusal: this is the end of the stream, + // not backpressure. return Err(PushError::Disconnected(item)); } + self.shared.metrics.record_refusal(); return Err(PushError::Full(item)); } if !self.shared.consumer_live.load(Ordering::Acquire) { @@ -1107,6 +1126,90 @@ impl crate::Bounded for Consumer { } } +impl Shared { + /// The counters, as the [`Observable`](crate::Observable) trait reports + /// them. Written once so the two handles cannot drift apart. + fn refused(&self) -> u64 { + self.metrics.refused() + } + + fn doorbell_rings(&self) -> u64 { + self.doorbell.rings() + } + + fn high_water(&self) -> Option { + self.metrics.high_water() + } +} + +impl Producer { + /// How many pushes have been refused for want of room. + #[must_use] + pub fn refused(&self) -> u64 { + self.shared.refused() + } + + /// How many times the doorbell has actually rung. + #[must_use] + pub fn doorbell_rings(&self) -> u64 { + self.shared.doorbell_rings() + } + + /// The deepest this queue has been, if tracking was asked for. + #[must_use] + pub fn high_water(&self) -> Option { + self.shared.high_water() + } +} + +impl Consumer { + /// How many pushes have been refused for want of room. + #[must_use] + pub fn refused(&self) -> u64 { + self.shared.refused() + } + + /// How many times the doorbell has actually rung. + #[must_use] + pub fn doorbell_rings(&self) -> u64 { + self.shared.doorbell_rings() + } + + /// The deepest this queue has been, if tracking was asked for. + #[must_use] + pub fn high_water(&self) -> Option { + self.shared.high_water() + } +} + +impl crate::Observable for Producer { + fn refused(&self) -> u64 { + Self::refused(self) + } + + fn doorbell_rings(&self) -> u64 { + Self::doorbell_rings(self) + } + + fn high_water(&self) -> Option { + Self::high_water(self) + } +} + +impl crate::Observable for Consumer { + fn refused(&self) -> u64 { + Self::refused(self) + } + + fn doorbell_rings(&self) -> u64 { + Self::doorbell_rings(self) + } + + fn high_water(&self) -> Option { + Self::high_water(self) + } +} + impl crate::Waitable for Consumer { fn doorbell(&self) -> io::Result> { Self::doorbell(self) diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index 746beb00..ec869938 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -13,11 +13,11 @@ //! slot -- so the interesting cases all put the queue under pressure first. use super::{ - BOUNDS_MAX, Consumer, Producer, Reservation, bounded, bounded_with_disposal, claim_word, - position_of, reserved_of, + BOUNDS_MAX, Consumer, Producer, Reservation, bounded, bounded_with, claim_word, position_of, + reserved_of, }; -use crate::Disposal; use crate::race_hooks; +use crate::{Disposal, Options}; // The trait is imported anonymously because this module also names the concrete // `Consumer` type, and only its `drain` method is wanted here. That the two can // coexist is the point made in `traits`: the trait is named for the role and the @@ -789,11 +789,11 @@ fn undrained_items_reach_the_disposal_sink_instead_of_being_destroyed() { let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, _rx) = bounded_with_disposal::( + let (tx, _rx) = bounded_with::( 8, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("8 is a valid capacity"); @@ -821,11 +821,11 @@ fn a_reserved_message_abandoned_at_teardown_is_still_accounted_for() { // to see it like any other survivor. let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, _rx) = bounded_with_disposal::( + let (tx, _rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -849,11 +849,11 @@ fn an_unredeemed_reservation_hands_nothing_to_the_sink() { // direction. let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, _rx) = bounded_with_disposal::( + let (tx, _rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -873,11 +873,11 @@ fn a_queue_torn_down_by_a_reservation_still_reaches_the_sink() { // A reservation counts as a producer, so it can be the last handle // standing -- and then its drop is what tears the queue down. let (undelivered, reaper) = std::sync::mpsc::channel(); - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -898,11 +898,11 @@ fn a_queue_torn_down_by_a_reservation_still_reaches_the_sink() { fn the_sink_sees_survivors_after_the_ring_has_wrapped() { let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -932,3 +932,110 @@ fn without_a_sink_undrained_items_are_destroyed_in_place() { } assert_eq!(destroyed.load(Ordering::Relaxed), 3); } + +// --------------------------------------------------------------------------- +// Observability. +// --------------------------------------------------------------------------- + +#[test] +fn refusals_are_counted_but_disconnections_are_not() { + let (tx, rx) = bounded::(2).expect("2 is a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + assert!(tx.push(3).is_err()); + assert_eq!(tx.refused(), 1); + assert_eq!(rx.refused(), 1, "both handles report the same queue"); + + drop(rx); + assert!(matches!(tx.push(4), Err(PushError::Disconnected(4)))); + assert_eq!( + tx.refused(), + 1, + "the end of the stream is not backpressure and must not be counted as it" + ); +} + +#[test] +fn a_push_refused_because_a_slot_is_reserved_counts_as_a_refusal() { + // It is backpressure like any other from the caller's side: the queue had + // no room for *this* push, and the reason is the queue's business rather + // than the refused producer's. + let (tx, _rx) = bounded::(2).expect("2 is a valid capacity"); + let _slot = tx.reserve().expect("room"); + tx.push(1).expect("one slot is unreserved"); + + assert!(tx.push(2).is_err()); + assert_eq!(tx.refused(), 1); +} + +#[test] +fn high_water_is_untracked_by_default() { + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + assert_eq!(tx.high_water(), None); + assert_eq!(rx.high_water(), None); +} + +#[test] +fn high_water_records_the_peak_when_asked_for() { + let (tx, rx) = bounded_with::(8, Options::new().tracking_high_water()) + .expect("8 is a valid capacity"); + assert_eq!(tx.high_water(), Some(0)); + + for value in 0..5 { + tx.push(value).expect("room"); + } + assert_eq!(tx.high_water(), Some(5)); + + while rx.pop().is_some() {} + assert_eq!(rx.high_water(), Some(5)); +} + +#[test] +fn an_unredeemed_reservation_does_not_count_towards_the_peak() { + // A reservation holds capacity, not an item. Counting it as depth would + // report a backlog that does not exist, and the whole point of the mark is + // to size a queue from evidence. + let (tx, _rx) = bounded_with::(8, Options::new().tracking_high_water()) + .expect("8 is a valid capacity"); + + let _slot = tx.reserve().expect("room"); + tx.push(1).expect("room"); + + assert_eq!( + tx.high_water(), + Some(1), + "one item is one item, whatever else is promised" + ); +} + +#[test] +fn the_ring_count_reports_syscalls_rather_than_signal_attempts() { + let (tx, rx) = bounded::(8).expect("8 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + for value in 0..4 { + tx.push(value).expect("room"); + } + + assert_eq!( + rx.doorbell_rings(), + 1, + "the first push lit it; the other three had nothing to do" + ); +} + +#[test] +fn a_reserved_delivery_rings_like_any_other() { + let (tx, rx) = bounded::(8).expect("8 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + let slot = tx.reserve().expect("room"); + slot.send(1).expect("the room was ours"); + + assert_eq!( + rx.doorbell_rings(), + 1, + "the message a reservation exists to protect must wake a parked consumer" + ); +} diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 5373a6f2..5fa84649 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -76,9 +76,11 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; -use crate::disposal::{Disposal, Teardown}; +use crate::disposal::Teardown; use crate::doorbell::Doorbell; use crate::error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; +use crate::metrics::Metrics; +use crate::options::Options; /// What this shape accepts as a capacity. /// @@ -118,14 +120,16 @@ const BOUNDS: Bounds = Bounds { /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { - build(capacity, Teardown::drop_in_place()) + build(capacity, Options::new()) } -/// Creates a ring that hands its undrained items to `disposal` at teardown. +/// Creates a ring with something other than the default behaviour. /// -/// Identical to [`bounded`] except for what becomes of items nobody took. See -/// [`Disposal`] for why that decision has to be made here rather than at -/// teardown, and why it matters for items that own a handle. +/// Identical to [`bounded`] except for what [`Options`] asks for. See +/// [`Disposal`](crate::Disposal) for why undrained items need a decision made +/// here rather than at teardown, and +/// [`Options::tracking_high_water`] for the one switch that costs the push path +/// anything. /// /// # Errors /// @@ -135,32 +139,35 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit /// /// ``` /// use std::sync::mpsc; -/// use windows_waitable_queues::{Disposal, spsc}; +/// use windows_waitable_queues::{Disposal, Options, spsc}; /// /// let (undelivered, reaper) = mpsc::channel(); -/// let (tx, rx) = spsc::bounded_with_disposal::( +/// let (tx, rx) = spsc::bounded_with::( /// 4, -/// Disposal::new(move |item| { -/// let _ = undelivered.send(item); -/// }), +/// Options::new() +/// .disposal(Disposal::new(move |item| { +/// let _ = undelivered.send(item); +/// })) +/// .tracking_high_water(), /// )?; /// /// tx.push(1).expect("a fresh queue has room"); -/// drop((tx, rx)); +/// tx.push(2).expect("a fresh queue has room"); +/// assert_eq!(rx.high_water(), Some(2)); /// -/// assert_eq!(reaper.into_iter().collect::>(), vec![1]); +/// drop((tx, rx)); +/// assert_eq!(reaper.into_iter().collect::>(), vec![1, 2]); /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` -pub fn bounded_with_disposal( +pub fn bounded_with( capacity: usize, - disposal: Disposal, + options: Options, ) -> Result<(Producer, Consumer), CapacityError> { - build(capacity, Teardown::handing_off(disposal)) + build(capacity, options) } - fn build( capacity: usize, - teardown: Teardown, + options: Options, ) -> Result<(Producer, Consumer), CapacityError> { validate_capacity(capacity, BOUNDS)?; @@ -168,7 +175,8 @@ fn build( slots.resize_with(capacity, || UnsafeCell::new(MaybeUninit::uninit())); let shared = Arc::new(Shared { - teardown, + teardown: Teardown::new(options.disposal), + metrics: Metrics::new(options.track_high_water), slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, @@ -201,6 +209,8 @@ struct Shared { /// Read only by [`Shared::drop`], which holds `&mut self`, so it needs no /// synchronization and costs the hot paths nothing but its space. teardown: Teardown, + /// The counters this queue keeps about itself. See [`crate::metrics`]. + metrics: Metrics, /// Where the consumer will next read. Owned by the consumer. head: CacheAligned, /// Where the producer will next write. Owned by the producer. @@ -267,6 +277,13 @@ impl Shared { /// The caller must have established that the slot at `tail` is free: either /// by the room check in `push`, or by holding a reservation. unsafe fn publish(&self, tail: usize, item: T) { + // Free on this shape: the producer owns `tail` and already loaded + // `head` to decide there was room, so the depth is a subtraction of two + // values it is holding. The counter's line is producer-owned too, since + // nothing else writes it. + self.metrics + .record_depth(tail.wrapping_sub(self.head.0.load(Ordering::Relaxed)) + 1); + // SAFETY: the caller's precondition says this slot holds no initialized // item, so writing a `MaybeUninit` over it drops nothing. unsafe { @@ -361,8 +378,12 @@ impl Producer { // whose consumer is gone will never drain, and telling the caller // to retry would be telling it to spin forever. if !self.shared.consumer_live.load(Ordering::Acquire) { + // Not counted as a refusal: this is the end of the stream, not + // backpressure, and folding the two together would make a + // shutting-down queue look like an overloaded one. return Err(PushError::Disconnected(item)); } + self.shared.metrics.record_refusal(); return Err(PushError::Full(item)); } if !self.shared.consumer_live.load(Ordering::Acquire) { @@ -917,6 +938,90 @@ impl crate::Bounded for Consumer { } } +impl Shared { + /// The counters, as the [`Observable`](crate::Observable) trait reports + /// them. Written once so the two handles cannot drift apart. + fn refused(&self) -> u64 { + self.metrics.refused() + } + + fn doorbell_rings(&self) -> u64 { + self.doorbell.rings() + } + + fn high_water(&self) -> Option { + self.metrics.high_water() + } +} + +impl Producer { + /// How many pushes have been refused for want of room. + #[must_use] + pub fn refused(&self) -> u64 { + self.shared.refused() + } + + /// How many times the doorbell has actually rung. + #[must_use] + pub fn doorbell_rings(&self) -> u64 { + self.shared.doorbell_rings() + } + + /// The deepest this queue has been, if tracking was asked for. + #[must_use] + pub fn high_water(&self) -> Option { + self.shared.high_water() + } +} + +impl Consumer { + /// How many pushes have been refused for want of room. + #[must_use] + pub fn refused(&self) -> u64 { + self.shared.refused() + } + + /// How many times the doorbell has actually rung. + #[must_use] + pub fn doorbell_rings(&self) -> u64 { + self.shared.doorbell_rings() + } + + /// The deepest this queue has been, if tracking was asked for. + #[must_use] + pub fn high_water(&self) -> Option { + self.shared.high_water() + } +} + +impl crate::Observable for Producer { + fn refused(&self) -> u64 { + Self::refused(self) + } + + fn doorbell_rings(&self) -> u64 { + Self::doorbell_rings(self) + } + + fn high_water(&self) -> Option { + Self::high_water(self) + } +} + +impl crate::Observable for Consumer { + fn refused(&self) -> u64 { + Self::refused(self) + } + + fn doorbell_rings(&self) -> u64 { + Self::doorbell_rings(self) + } + + fn high_water(&self) -> Option { + Self::high_water(self) + } +} + impl crate::Waitable for Consumer { fn doorbell(&self) -> io::Result> { Self::doorbell(self) diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index acd22da8..8b6fc00d 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -6,9 +6,9 @@ //! joined thread rather than a sleep, so they are deterministic: the assertion //! runs after the peer has finished, not after a guess about how long it takes. -use super::{BOUNDS, Consumer, Producer, bounded, bounded_with_disposal, validate_capacity}; -use crate::Disposal; +use super::{BOUNDS, Consumer, Producer, bounded, bounded_with, validate_capacity}; use crate::race_hooks; +use crate::{Disposal, Options}; use crate::{PushError, RecvError, RecvTimeoutError}; use std::os::windows::io::AsRawHandle; use std::sync::Arc; @@ -1218,13 +1218,13 @@ fn undrained_items_reach_the_disposal_sink_instead_of_being_destroyed() { let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, _rx) = bounded_with_disposal::( + let (tx, _rx) = bounded_with::( 8, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { // Moved out of teardown rather than destroyed in it, which is // the entire point: the owner now decides when and where. let _ = undelivered.send(item); - }), + })), ) .expect("8 is a valid capacity"); @@ -1258,11 +1258,11 @@ fn only_the_undrained_items_reach_the_sink() { let destroyed = Arc::new(AtomicUsize::new(0)); { - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 8, - Disposal::new(move |item: Tracked| { + Options::new().disposal(Disposal::new(move |item: Tracked| { let _ = undelivered.send(item.id); - }), + })), ) .expect("8 is a valid capacity"); @@ -1288,11 +1288,11 @@ fn only_the_undrained_items_reach_the_sink() { fn an_empty_queue_hands_nothing_to_the_sink() { let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); tx.push(1).expect("room"); @@ -1311,11 +1311,11 @@ fn the_sink_sees_survivors_after_the_ring_has_wrapped() { // would show up as the wrong items rather than as a crash. let (undelivered, reaper) = std::sync::mpsc::channel(); { - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -1340,11 +1340,11 @@ fn a_queue_torn_down_by_the_producer_still_reaches_the_sink() { // guarantee must not depend on it. Here the consumer goes first, so the // producer's drop is what tears the queue down. let (undelivered, reaper) = std::sync::mpsc::channel(); - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -1365,11 +1365,11 @@ fn a_queue_torn_down_on_another_thread_still_reaches_the_sink() { // The dropping thread is whichever one happens to release last, which is // exactly why disposal cannot be left to it implicitly. let (undelivered, reaper) = std::sync::mpsc::channel(); - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 4, - Disposal::new(move |item| { + Options::new().disposal(Disposal::new(move |item| { let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -1435,13 +1435,13 @@ fn a_sink_keeps_the_destructor_off_the_thread_that_tore_the_queue_down() { let ran_on = Arc::new(std::sync::Mutex::new(Vec::new())); let (undelivered, reaper) = std::sync::mpsc::channel(); - let (tx, rx) = bounded_with_disposal::( + let (tx, rx) = bounded_with::( 4, - Disposal::new(move |item: ThreadWitness| { + Options::new().disposal(Disposal::new(move |item: ThreadWitness| { // The sink's whole job: move it somewhere a thread that may block // will find it. Nothing here runs the destructor. let _ = undelivered.send(item); - }), + })), ) .expect("4 is a valid capacity"); @@ -1508,3 +1508,152 @@ fn without_a_sink_the_destructor_does_run_on_the_thread_that_tore_the_queue_down which is exactly the behaviour a disposal sink exists to replace" ); } + +// --------------------------------------------------------------------------- +// Observability. +// +// The counters' arithmetic is covered in `crate::metrics`. What is asserted +// here is that this shape *feeds* them from the right places -- and, for the +// doorbell, that the number reports syscalls rather than signal attempts, +// which is what makes the skip rule measurable rather than assumed. +// --------------------------------------------------------------------------- + +#[test] +fn refusals_are_counted_but_disconnections_are_not() { + // The two are different facts and must not be summed. A full queue is + // backpressure; a departed consumer is the end of the stream, and a queue + // shutting down should not read as an overloaded one. + let (tx, rx) = bounded::(2).expect("2 is a valid capacity"); + assert_eq!(tx.refused(), 0); + + tx.push(1).expect("room"); + tx.push(2).expect("room"); + assert!(tx.push(3).is_err()); + assert!(tx.push(4).is_err()); + assert_eq!(tx.refused(), 2, "two pushes were refused for want of room"); + assert_eq!(rx.refused(), 2, "and both handles report the same queue"); + + drop(rx); + assert!(matches!(tx.push(5), Err(PushError::Disconnected(5)))); + assert_eq!( + tx.refused(), + 2, + "a push refused because the consumer is gone is not a loss to backpressure" + ); +} + +#[test] +fn high_water_is_untracked_by_default() { + // Off unless asked for, because it is the one metric that cannot be made + // free. `None` rather than `Some(0)` so a caller cannot mistake "nobody was + // counting" for "it never filled". + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + + assert_eq!(tx.high_water(), None); + assert_eq!(rx.high_water(), None); +} + +#[test] +fn high_water_records_the_peak_when_asked_for() { + let (tx, rx) = bounded_with::(8, Options::new().tracking_high_water()) + .expect("8 is a valid capacity"); + assert_eq!(tx.high_water(), Some(0), "counting, and nothing seen yet"); + + for value in 0..5 { + tx.push(value).expect("room"); + } + assert_eq!(tx.high_water(), Some(5)); + + // Draining does not lower it: the peak is a fact about the past. + while rx.pop().is_some() {} + assert_eq!(rx.len(), 0); + assert_eq!( + rx.high_water(), + Some(5), + "the mark is the deepest it got, not the depth right now" + ); + + // And a smaller later burst does not replace it. + tx.push(0).expect("room"); + tx.push(1).expect("room"); + assert_eq!(tx.high_water(), Some(5)); +} + +#[test] +fn high_water_counts_reserved_deliveries_like_any_other() { + let (tx, rx) = bounded_with::(4, Options::new().tracking_high_water()) + .expect("4 is a valid capacity"); + + let slot = tx.reserve().expect("room"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + slot.send(3).expect("the room was ours"); + + assert_eq!( + tx.high_water(), + Some(3), + "a redeemed reservation is an ordinary queued item, and counts as depth like one" + ); + assert_eq!(rx.len(), 3); +} + +#[test] +fn a_poll_only_consumer_rings_no_doorbells() { + // The laziness being visible rather than a gap: a consumer that never asks + // for the handle never creates the event, so there is nothing to ring. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + for value in 0..4 { + tx.push(value).expect("room"); + } + while rx.pop().is_some() {} + + assert_eq!( + rx.doorbell_rings(), + 0, + "no kernel object was created, so no signal was ever issued" + ); +} + +#[test] +fn the_ring_count_reports_syscalls_rather_than_signal_attempts() { + // **The number the skip rule is measured by.** Four pushes against a + // doorbell nobody clears is one real `SetEvent` and three skips, because a + // manual-reset event does not count and setting an already-set one changes + // nothing. If this ever reported four, the skip would have stopped + // happening -- which is exactly what the sabotage entry for it asserts. + let (tx, rx) = bounded::(8).expect("8 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + for value in 0..4 { + tx.push(value).expect("room"); + } + + assert_eq!( + rx.doorbell_rings(), + 1, + "the first push lit it; the other three had nothing to do" + ); +} + +#[test] +fn clearing_the_doorbell_makes_the_next_push_ring_again() { + // The complement: the count must not be stuck at one. Each drain-and-arm + // cycle costs exactly one more ring, which is the shape a parked consumer + // actually produces. + let (tx, rx) = bounded::(8).expect("8 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + + for round in 1..=3 { + tx.push(round).expect("room"); + tx.push(round).expect("room"); + assert_eq!( + rx.doorbell_rings(), + round as u64, + "round {round}: one ring per cycle, not one per push" + ); + while rx.pop().is_some() {} + assert!(rx.arm().expect("arming must succeed")); + } +} diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs index 6e186b5f..534d30f9 100644 --- a/crates/windows-waitable-queues/src/traits.rs +++ b/crates/windows-waitable-queues/src/traits.rs @@ -226,6 +226,60 @@ pub trait Reserving { fn outstanding_reservations(&self) -> usize; } +/// What a queue can report about its own history. +/// +/// # Why depth is not here +/// +/// [D-2](../../DESIGN-NOTES.md#d-2)'s sketch of this trait listed "depth, +/// high-water, doorbells actually rung", and depth has been left off +/// deliberately. [`Bounded::len`] already reports it, computed on demand from +/// positions the queue keeps anyway. Naming it again here would give one number +/// two spellings and two places to drift apart, which is the restatement +/// problem this workspace has paid for before. What belongs here is only what +/// has to be **accumulated** -- facts about the past that the queue's current +/// state cannot reconstruct. +/// +/// # Implemented by both ends +/// +/// A producer wants to know how often it was refused; a consumer wants to know +/// how deep the backlog got and how often it was actually woken. Both are +/// asking about the same queue, so both handles answer. +pub trait Observable { + /// How many pushes have been refused for want of room. + /// + /// **This is the loss count**, and it is the part of the file watcher's + /// coalesced loss latch that generalises: a latch can only coalesce losses + /// that are *idempotent*, which is a property of the payload rather than of + /// the queue, but counting them needs nothing of the payload at all. See + /// [D-19](../../DESIGN-NOTES.md#d-19). + /// + /// Counts refusals for **room** only. A push refused because every consumer + /// is gone is the end of the stream rather than a loss, and folding the two + /// together would make a shutting-down queue look like an overloaded one. + fn refused(&self) -> u64; + + /// How many times the doorbell has actually rung. + /// + /// Counts `SetEvent` calls rather than signal attempts, and the difference + /// between the two *is* the skip optimisation. That is what makes this the + /// number worth reporting: the skip rule becomes measurable rather than + /// assumed, and turning the skip off has to move it. + /// + /// A queue whose consumer only ever polls never creates its doorbell, so + /// this stays zero -- which is the laziness being visible rather than a + /// gap. + fn doorbell_rings(&self) -> u64; + + /// The deepest the queue has been, if it is being tracked. + /// + /// `None` means nobody was counting, which is **not** the same answer as + /// `Some(0)`. Tracking is off unless + /// [`Options::tracking_high_water`](crate::Options::tracking_high_water) + /// asked for it, because a peak has to observe every change and that is the + /// one metric here which cannot be made free. + fn high_water(&self) -> Option; +} + /// A queue whose readiness can be waited on as a Windows `HANDLE`. /// /// This is the capability the crate is named for, and the reason it exists From 29532ddbe9d2f1453052f42fc596b90771baa73a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 09:14:33 -0400 Subject: [PATCH 033/361] feat(probes): measure queue contention, and record that it inverts D-16 M31.5 asked whether the array queue's tail claim contends badly enough to justify the linked and sharded MPSC shapes, and what reserving_mpsc's extra read of the consumer's position costs. Both answers were surprises. Measured in release on an AMD EPYC 7763 (8C/16T, x64), median of five with a discarded warm-up; three invocations agreed. Note the architecture: every previous measurement in this workspace was ARM64, so this fills the x64 gap rather than extending the record. M31.7 closes the other half. The tail claim contends, and severely -- aggregate throughput FALLS as producers are added, mpsc from 111M to 4.2M pushes/sec, against a bare contended fetch_add that falls only to a third. So the licence M31.5 offered to close M-inf.1 outright was not granted. But contending and being the bottleneck are different things, so M-inf.1 stays parked with its gate now quantified: build a sharded queue when a consumer appears whose per-item cost is on the order of the ~39 ns/push the claim costs at eight producers. The I/O domain is nowhere near that. reserving_mpsc is up to 4x FASTER than mpsc under contention, which inverts D-16's premise. That decision shipped the two as peers because reading the consumer's position was assumed to make the reserving shape expensive; it is the cheaper one at every producer count from two upward, and the premise survives only at a single producer against a live consumer -- where spsc is the right answer anyway. Investigated before concluding, at the engineer's direction, and the gap is intrinsic rather than a fixable flaw. Both protocols do one CAS plus one load per attempt; the difference is which load. mpsc must read the slot's own sequence before claiming -- an address that marches through memory as the tail advances, written by the producers it is racing -- where reserving_mpsc reads one fixed head. The false-sharing hypothesis was tested and rejected: padding each slot onto its own cache line recovers about a fifth at eight producers for four times the memory, and leaves the shape 2.8x slower. The padding was reverted, which makes the existing note on Slot correct for a measured reason rather than an assumed one. The probe is deliberately absent from the CI probe job, and that is a measurement rather than a preference: that job runs debug, and in debug the two shapes measure 249.7 and 254.0 ns/push at sixteen producers -- indistinguishable, against 193.5 and 52.2 in release. A debug run would not lose precision, it would report the shapes as equivalent. The merge-or-delete decision M31.2 deferred here is queued as M31.8 rather than taken, because the investigation changed what it is about: from "is the extra read cheap" to "which claim protocol should survive". Its three candidates are written out with what each costs, and the sweep of D-16's now-falsified premise through the shapes' own documentation is part of that item. Also fixes a table defect this work surfaced: D-16 and D-17 had been joined onto one line since M31.2, leaving D-17 duplicated and the row unrendered. Completed item: M31.5: The contention benchmark that decides whether the deferred shapes are needed: N producer threads pushing, throughput against N. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 71 +++- Cargo.lock | 1 + crates/windows-platform-probes/Cargo.toml | 8 + .../windows-platform-probes/DESIGN-NOTES.md | 36 ++ .../src/bin/queue_contention.rs | 111 ++++++ crates/windows-platform-probes/src/lib.rs | 1 + .../src/queue_contention.rs | 344 ++++++++++++++++++ .../windows-waitable-queues/DESIGN-NOTES.md | 75 +++- 8 files changed, 644 insertions(+), 3 deletions(-) create mode 100644 crates/windows-platform-probes/src/bin/queue_contention.rs create mode 100644 crates/windows-platform-probes/src/queue_contention.rs diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index d38fbb0c..968ebb18 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -369,7 +369,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m 221 unit tests, 9 doctests and 1 `compile_fail` doctest, the whole suite in 0.30s. Thirty-nine sabotages: three new, one converted from control to defect, and one new control replacing it. -- [ ] **M31.5** -- The contention benchmark that decides whether the deferred shapes are needed: N producer +- [x] **M31.5** -- The contention benchmark that decides whether the deferred shapes are needed: N producer threads pushing, throughput against N. **This is the item that either justifies or kills the linked and sharded MPSC shapes**, and it is deliberately a measurement rather than a judgement, for the same reason C-1 was. If the tail CAS does not contend at realistic producer counts, the array queue is the only MPSC @@ -388,6 +388,61 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m vindicated. This item exists because a duplicated path silently becoming permanent -- because nobody circled back -- is the failure mode the duplicate-then-decide rule actually warns about, and an intention recorded only in a design note is not scheduled work. + **Done, and both answers were surprises.** The probe is + [queue_contention.rs](crates/windows-platform-probes/src/queue_contention.rs), run by hand in release on + an AMD EPYC 7763 (8C/16T, x64), median of five with a discarded warm-up; three invocations agreed. + **Note the architecture -- every previous measurement in this workspace was ARM64**, so this fills the + x64 gap rather than extending the record, and M31.7 exists to close the other half. + **The tail claim contends, so the licence to close M-inf.1 was not granted** -- but the gate there is + now a number rather than a judgement, because contending and being the bottleneck are different things. + See M-inf.1 for the quantified trigger. + **`reserving_mpsc` is up to 4x FASTER than `mpsc` under contention, which inverts D-16's premise** + ([D-26](crates/windows-waitable-queues/DESIGN-NOTES.md#d-26)). The split shipped on the reasoning that + reading the consumer's position made the reserving shape the expensive one; it is the cheaper one at + every producer count from two upward, and the premise survives only at a single producer against a live + consumer -- where `spsc` is the right answer anyway. + **Investigated before concluding, at the engineer's direction, and the gap is intrinsic rather than a + fixable flaw** ([D-27](crates/windows-waitable-queues/DESIGN-NOTES.md#d-27)). Both protocols do one CAS + plus one load per attempt; the difference is *which* load. `mpsc` must read the slot's own sequence + before claiming -- an address that marches through memory as the tail advances, written by the producers + it is racing -- where `reserving_mpsc` reads one fixed `head`. The false-sharing hypothesis was tested + and rejected: padding each slot onto its own cache line recovers about a fifth at eight producers for + four times the memory, and leaves the shape 2.8x slower. The padding was reverted. + **A methodological trap worth keeping: a debug build reports the two shapes as identical** (249.7 vs + 254.0 ns at sixteen producers, against 193.5 vs 52.2 in release). That is why this probe is deliberately + *not* in the CI probe job, which runs debug -- it would produce a confident wrong answer rather than a + noisy one. + **The merge-or-delete decision is now live with data behind it and is queued as M31.8**, not taken + here: the investigation changed what the decision is *about*, from "is the extra read cheap" to "which + claim protocol should survive", and that is the engineer's call. + +- [ ] **M31.7** -- Re-run `probe-queue-contention` on the ARM64 development machine and record the curve + beside the x64 one. **Not a formality.** M31.5's finding is a statement about cache-coherence + behaviour, and this workspace has already been bitten once by measuring only on ARM64 -- + [windows-platform-probes](crates/windows-platform-probes/DESIGN-NOTES.md) records that case. M31.5 + inverted a design premise on x64 evidence alone; if ARM64 disagrees, the merge decision in M31.8 changes + with it, and so does M-inf.1's threshold. + Run it in **release**: a debug build reports the two shapes as identical, which is why the probe is not + in CI. + +- [ ] **M31.8** -- Decide merge-or-delete for `mpsc` and `reserving_mpsc`, now that M31.5 has measured + them and M31.7 will have checked the other architecture. + **The decision changed shape once the investigation ran.** M31.2 framed it as "if the shared-line read + is cheap, the two merge and the non-reserving one goes". The read is not merely cheap -- it is cheaper + than the read it replaces -- so the real question is **which claim protocol survives**: Vyukov's + sequence, which reads a marching slot, or the head-based one, which reads a fixed line. + The candidates, with what each costs: + - **Delete `mpsc`, keep `reserving_mpsc`.** Simplest surface, and the faster shape under contention. + Loses the 2x advantage `mpsc` holds at one producer with a live consumer, and lowers the maximum + capacity from 2^63 to 2^31 for every caller. + - **Keep both**, and correct their documentation, which currently states D-16's falsified premise as + the reason the split exists. The split would then be justified by *profile* -- one shape for few + producers, one for many -- which is a real distinction but a harder one to explain. + - **Change `mpsc`'s protocol** to decide freedom from `head`, closing the gap. This makes the two + shapes genuinely "one queue with and without reservations", which is what D-16 assumed they already + were, and is the only option that removes the surprise rather than documenting it. + Whichever is chosen, D-16's and `mpsc`'s own documentation must be corrected in the same change: they + currently assert a cost relationship the measurement reversed. That sweep is part of this item. - [ ] **M31.6** -- Verify the memory orderings with a model checker, because stress testing demonstrably cannot. **Measured, not assumed:** during M30.3's sabotage sweep, weakening the producer's `Acquire` @@ -484,6 +539,20 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio - [ ] **M-inf.1** -- The linked and sharded MPSC shapes, if and only if M31.5 shows the array queue's tail CAS contends at realistic producer counts. + **M31.5 has run, and the gate is now quantified rather than open.** The tail claim *does* contend, on + x64: aggregate throughput falls with every producer added, `mpsc` from 111M to 4.2M pushes/sec and + `reserving_mpsc` from 116M to 17.6M, against a bare contended atomic that falls only to a third. So the + licence M31.5 offered to close this item outright -- "if the tail CAS does not contend, the array queue + is the only MPSC this crate ever needs" -- was **not** granted. + **But contending is not the same as being the bottleneck, and this stays parked on that distinction.** + At eight producers `reserving_mpsc` still sustains ~26M pushes/sec, or ~39 ns per push. A sharded queue + is worth building only for a consumer whose per-item work is *smaller* than the contention it would + remove, and the I/O domain this crate was written for is nowhere near that: C-1 already established + that a real request dwarfs the queue's mechanics. + So the trigger is now a number rather than a judgement: **build these when a consumer appears whose + per-item cost is on the order of the ~39 ns/push (8 producers) or ~57 ns/push (32 producers) that the + array queue's claim costs under contention.** Until then a sharded queue would optimise the small half. + Re-measuring on ARM64 is the cheap way to find out whether that threshold moves; see M31.7. - [ ] **M-inf.2** -- The eventcount, if and only if a measurement against real I/O shows the doorbell costs enough to be worth its lost-wakeup risk. C-1 showed batching alone drives it below the atomic push diff --git a/Cargo.lock b/Cargo.lock index 872743f8..8c4be92e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,6 +232,7 @@ dependencies = [ "windows-sys", "windows-threadpool-sys", "windows-topology-sys", + "windows-waitable-queues", "wtf-string", ] diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index ca687452..0de97097 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -59,6 +59,10 @@ path = "src/bin/request_cost.rs" name = "probe-topology" path = "src/bin/topology.rs" +[[bin]] +name = "probe-queue-contention" +path = "src/bin/queue_contention.rs" + [dependencies] # The pool-growth probe measures the shipping API rather than a # reimplementation of the SDK's inline environment helpers, so it depends on the @@ -71,6 +75,10 @@ windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys" } # The request-cost probe measures the real request types the design would put on # a queue, not a stand-in, for the same reason. windows-namespace-request-sys = { version = "0.2.0", path = "../windows-namespace-request-sys" } +# The contention probe measures the shipping queue shapes rather than a +# reimplementation, for the same reason: a stand-in would only measure itself, +# and the whole question is what the real tail claim costs. +windows-waitable-queues = { version = "0.1.0", path = "../windows-waitable-queues" } wtf-string = { version = "0.1.0", path = "../wtf-string" } [dependencies.windows-sys] diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index b3d530db..197f7bee 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -457,3 +457,39 @@ characters, so no mangling occurred -- an accident of environment, not of architecture. It would fail on any host with a longer user name, on either architecture. Recorded here because it is exactly the kind of result this comparison exists to classify correctly: a red build that is **not** a finding. + +## The queue-contention probe, and why it must not run in the CI probe job + +`probe-queue-contention` measures what M31.5 of +[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) exists to decide: whether the bounded array +queue's tail claim contends badly enough to justify the linked and sharded MPSC shapes, and what +`reserving_mpsc`'s extra read of the consumer's position actually costs. + +**It is deliberately absent from the `platform-probes` CI job, unlike every other probe, and the reason is +a measurement rather than a preference.** That job runs `cargo run` without `--release`. Measured in a +debug build, `mpsc` and `reserving_mpsc` come out at 249.7 and 254.0 ns/push at sixteen producers -- +indistinguishable. In release, on the same machine in the same minute, they are 193.5 and 52.2. The +un-inlined overhead of a debug build swamps the cache-coherence effects that *are* the finding, so a debug +run of this probe does not merely lose precision: it reports the two shapes as equivalent, which is a +confident wrong answer of exactly the kind this crate's `doorbell_cost` notes warn about. + +Two further reasons it stays out. A contention curve needs more cores than a hosted runner has, and the +32-producer rows on a four-core runner would measure the scheduler. And the run costs about two minutes in +release, against a job whose other probes are seconds. + +So this one is run by hand, on a known machine, and its numbers are recorded with the machine attached. + +### Reading it + +Two regimes, and the pair is the point. + +**Isolated** gives producers a capacity large enough that nothing is ever refused and runs no consumer, so +whatever curve appears against N is the claim and nothing else. **Drained** runs a consumer popping +continuously, which is the only regime that can price `reserving_mpsc`'s read of `head` -- that read is +cheap until a consumer is *writing* the line, and measuring it in isolation would report it as free. + +The drained regime has a **single** consumer, because that is what MPSC means, so at high producer counts +it becomes consumer-bound and a plateau there says nothing about the claim. Each row carries the refusal +count from the queue's own `Observable` counters precisely so that is visible as a fact rather than +mistaken for contention: the sixteen- and thirty-two-producer drained rows show millions of refusals and +should be read as measurements of the consumer. diff --git a/crates/windows-platform-probes/src/bin/queue_contention.rs b/crates/windows-platform-probes/src/bin/queue_contention.rs new file mode 100644 index 00000000..668eea04 --- /dev/null +++ b/crates/windows-platform-probes/src/bin/queue_contention.rs @@ -0,0 +1,111 @@ +// Copyright (c) Mike Grier. + +//! Prints how the array queue's tail claim behaves as producers are added. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! This decides two things that are otherwise decided by taste: whether the +//! linked and sharded MPSC shapes are ever needed, and whether `mpsc` and +//! `reserving_mpsc` should merge. See `queue_contention`'s module docs. + +use windows_platform_probes::queue_contention::{PRODUCER_COUNTS, Run, measure}; + +fn main() { + println!("== does the array queue's tail claim contend? ==\n"); + + let observation = measure(); + println!( + "host reports {} logical processors\n", + observation.logical_processors + ); + + println!("-- isolated: producers only, capacity large enough that nothing is refused --"); + print_table(&observation.isolated); + + println!("\n-- drained: a consumer popping continuously, capacity 1024 --"); + print_table(&observation.drained); + + println!("\ninterpretation:\n"); + + // Question 1: does the claim collapse as producers are added? + println!(" 1. tail-claim contention (isolated regime)\n"); + println!( + " {:<18} {:>12} {:>12} {:>14}", + "producers", "mpsc x1thr", "reserving", "atomic floor" + ); + for &producers in PRODUCER_COUNTS { + let mpsc = observation.scaling(&observation.isolated, "mpsc", producers); + let reserving = observation.scaling(&observation.isolated, "reserving_mpsc", producers); + let floor = observation.scaling(&observation.isolated, "baseline_fetch_add", producers); + println!( + " {producers:<18} {:>12} {:>12} {:>14}", + format_scaling(mpsc), + format_scaling(reserving), + format_scaling(floor) + ); + } + println!("\n Read as: throughput at N producers divided by throughput at one."); + println!(" 1.00 means N threads together push no faster than one did."); + println!(" The atomic floor is the cheapest possible contended operation,"); + println!(" so it says how much of any curve is the queue and how much is"); + println!(" simply what this processor does to a fought-over cache line."); + + // Question 2: what does reserving_mpsc's read of `head` actually cost? + println!("\n 2. the price of reservation (drained regime, where `head` is written)\n"); + println!( + " {:<18} {:>14} {:>14} {:>10}", + "producers", "mpsc ns/push", "reserving", "ratio" + ); + for &producers in PRODUCER_COUNTS { + let plain = observation.find(&observation.drained, "mpsc", producers); + let reserving = observation.find(&observation.drained, "reserving_mpsc", producers); + let ratio = match (plain, reserving) { + (Some(plain), Some(reserving)) if plain.nanos_per_push > 0.0 => { + format!("{:.2}x", reserving.nanos_per_push / plain.nanos_per_push) + } + _ => "--".to_owned(), + }; + println!( + " {producers:<18} {:>14} {:>14} {:>10}", + format_nanos(plain), + format_nanos(reserving), + ratio + ); + } + println!("\n `reserving_mpsc` reads the consumer's position on every push and"); + println!(" `mpsc` does not, which is the entire reason they ship as two"); + println!(" shapes. This regime is the one that can price that read, because"); + println!(" a consumer is writing the line being read."); + + println!("\n CAUTION: the drained regime has ONE consumer, because that is what"); + println!(" MPSC means. At high producer counts it is expected to become"); + println!(" consumer-bound, and a plateau there says nothing about the claim."); + println!(" The refusal counts above are what make that visible: a run with"); + println!(" many refusals was waiting for the consumer, not for the tail."); +} + +fn print_table(runs: &[Run]) { + println!( + "{:<18} {:>10} {:>14} {:>16} {:>14}", + "shape", "producers", "ns/push", "pushes/sec", "refusals" + ); + for run in runs { + println!( + "{:<18} {:>10} {:>14.1} {:>16.0} {:>14}", + run.shape, run.producers, run.nanos_per_push, run.pushes_per_second, run.refusals + ); + } +} + +fn format_scaling(scaling: Option) -> String { + scaling.map_or_else(|| "--".to_owned(), |value| format!("{value:.2}x")) +} + +fn format_nanos(run: Option) -> String { + run.map_or_else( + || "--".to_owned(), + |run| format!("{:.1}", run.nanos_per_push), + ) +} diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index f62c5005..1470d76e 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -117,6 +117,7 @@ pub mod error_mode; pub mod handle_state; pub mod ioring; pub mod pool_growth; +pub mod queue_contention; pub mod request_cost; pub mod topology; pub mod worker_context; diff --git a/crates/windows-platform-probes/src/queue_contention.rs b/crates/windows-platform-probes/src/queue_contention.rs new file mode 100644 index 00000000..25468c51 --- /dev/null +++ b/crates/windows-platform-probes/src/queue_contention.rs @@ -0,0 +1,344 @@ +// Copyright (c) Mike Grier. + +//! Does the array queue's tail claim contend at realistic producer counts? +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! # The two decisions this exists to force +//! +//! **1. Are the linked and sharded MPSC shapes needed at all?** They are parked +//! in `CHECKLIST-io-domains.md` as `M-inf.1`, gated on this measurement rather +//! than on taste. If N threads compare-and-swapping one tail does not collapse +//! at the producer counts a real system reaches, the bounded array queue is the +//! only MPSC the queue crate ever needs, and two speculative shapes never get +//! written. +//! +//! **2. Should `mpsc` and `reserving_mpsc` merge?** They ship as peers because +//! honouring a reservation costs the producer a read of the consumer's +//! position -- one line every thread touches -- and *how much* that costs was a +//! judgement rather than a measurement. If it is cheap, the two shapes merge and +//! the non-reserving one goes; if it is expensive, the split is vindicated. +//! +//! # Two regimes, because one of them cannot answer the second question +//! +//! Producers are timed twice, and the pair is the point. +//! +//! - **Isolated** -- capacity large enough that nothing is ever refused, and no +//! consumer running. This is the *cleanest* measurement of tail-claim +//! contention: nothing else touches the queue, so whatever curve appears +//! against N is the compare-and-swap and nothing else. +//! +//! - **Drained** -- a consumer popping continuously while the producers push. +//! This is the one that can price `reserving_mpsc`, because its producer reads +//! `head`, and `head` is only expensive to read when a consumer is *writing* +//! it. Measured in isolation that read hits a clean, shared line and looks +//! free -- which would be a confident wrong answer. +//! +//! # What is deliberately not claimed +//! +//! The drained regime has a **single** consumer, because that is what MPSC +//! means. At high producer counts it is therefore expected to become +//! consumer-bound, and a throughput plateau there says nothing about the tail +//! claim. The probe reports each run's refusal count -- from the queue's own +//! `Observable` counters -- so a backpressure-bound run is visible as a fact +//! rather than mistaken for contention. Read the isolated regime for the +//! contention question, and the drained one for the cost of `head`. + +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::thread; +use std::time::Instant; + +use windows_waitable_queues::{Options, mpsc, reserving_mpsc}; + +/// How many pushes each producer thread performs in one timed run. +const PUSHES_PER_PRODUCER: usize = 50_000; + +/// How many times each configuration is repeated; the median is reported. +/// +/// Odd, so the median is an observed value rather than an average of two. Five +/// because these probes run on a virtual machine, where a single run can be +/// perturbed by something entirely outside the process. +const REPETITIONS: usize = 5; + +/// The producer counts measured, in order. +/// +/// Fixed rather than derived from the host's processor count, so two runs on +/// different machines produce comparable rows. The host's own count is reported +/// alongside, since the interesting region is around and beyond it. +pub const PRODUCER_COUNTS: &[usize] = &[1, 2, 4, 8, 16, 32]; + +/// One configuration's result. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Run { + /// Which queue shape, or the baseline. + pub shape: &'static str, + /// How many producer threads pushed concurrently. + pub producers: usize, + /// Median nanoseconds per successful push, across all producers. + pub nanos_per_push: f64, + /// Successful pushes per second, summed across producers. + pub pushes_per_second: f64, + /// Pushes refused for want of room during the median run. + /// + /// Non-zero means the run was at least partly bounded by the consumer + /// rather than by the claim, which is a fact about the measurement and not + /// about the queue. + pub refusals: u64, +} + +/// Everything one invocation measured. +#[derive(Debug, Clone)] +pub struct Observation { + /// Producers timed with no consumer and no possibility of refusal. + pub isolated: Vec, + /// Producers timed against a continuously draining consumer. + pub drained: Vec, + /// Logical processors the host reports. + pub logical_processors: usize, +} + +impl Observation { + /// Look one run up. + #[must_use] + pub fn find(&self, regime: &[Run], shape: &str, producers: usize) -> Option { + regime + .iter() + .find(|run| run.shape == shape && run.producers == producers) + .copied() + } + + /// How far throughput scaled from one producer to `producers`. + /// + /// 1.0 means N producers together push no faster than one did, which is + /// what a badly contended claim looks like. Perfect scaling would be N, + /// which no shared-tail queue can reach. + #[must_use] + pub fn scaling(&self, regime: &[Run], shape: &str, producers: usize) -> Option { + let one = self.find(regime, shape, 1)?; + let many = self.find(regime, shape, producers)?; + Some(many.pushes_per_second / one.pushes_per_second) + } +} + +/// Time every configuration. +#[must_use] +pub fn measure() -> Observation { + let mut isolated = Vec::new(); + let mut drained = Vec::new(); + + for &producers in PRODUCER_COUNTS { + isolated.push(median_run("baseline_fetch_add", producers, |count| { + time_contended_atomic(count) + })); + isolated.push(median_run("mpsc", producers, |count| { + time_isolated_mpsc(count) + })); + isolated.push(median_run("reserving_mpsc", producers, |count| { + time_isolated_reserving(count) + })); + + drained.push(median_run("mpsc", producers, |count| { + time_drained_mpsc(count) + })); + drained.push(median_run("reserving_mpsc", producers, |count| { + time_drained_reserving(count) + })); + } + + Observation { + isolated, + drained, + logical_processors: thread::available_parallelism().map_or(0, std::num::NonZeroUsize::get), + } +} + +/// Raw result of one timed repetition: elapsed nanoseconds and refusals. +type Repetition = (f64, u64); + +/// Run one configuration [`REPETITIONS`] times and keep the median. +/// +/// The median rather than the mean, because on a virtual machine the failure +/// mode is one run being hugely slower rather than a spread around a centre, +/// and a mean would carry that outlier into the reported number. +fn median_run( + shape: &'static str, + producers: usize, + mut timer: impl FnMut(usize) -> Repetition, +) -> Run { + // One untimed pass first: the first touch of a fresh allocation faults + // pages in, and that cost belongs to the allocator rather than the queue. + let _ = timer(producers); + + let mut results: Vec = (0..REPETITIONS).map(|_| timer(producers)).collect(); + results.sort_by(|left, right| left.0.total_cmp(&right.0)); + let (elapsed_nanos, refusals) = results[REPETITIONS / 2]; + + let pushes = (producers * PUSHES_PER_PRODUCER) as f64; + Run { + shape, + producers, + nanos_per_push: elapsed_nanos / pushes, + pushes_per_second: pushes / (elapsed_nanos / 1e9), + refusals, + } +} + +/// The floor: N threads incrementing one shared counter. +/// +/// Not a queue, and not trying to be. It is the cheapest possible operation on +/// a contended line, so it says how much of a queue's scaling curve is the +/// queue and how much is simply what this processor does when N cores fight +/// over one cache line. +fn time_contended_atomic(producers: usize) -> Repetition { + let counter = Arc::new(AtomicU64::new(0)); + let started = Instant::now(); + thread::scope(|scope| { + for _ in 0..producers { + let counter = Arc::clone(&counter); + scope.spawn(move || { + for _ in 0..PUSHES_PER_PRODUCER { + counter.fetch_add(1, Ordering::Relaxed); + } + }); + } + }); + (started.elapsed().as_nanos() as f64, 0) +} + +/// Capacity big enough that a whole run fits, so nothing is ever refused. +fn capacity_for(producers: usize) -> usize { + (producers * PUSHES_PER_PRODUCER).next_power_of_two() +} + +fn time_isolated_mpsc(producers: usize) -> Repetition { + let (tx, rx) = mpsc::bounded::(capacity_for(producers)).expect("a valid capacity"); + let started = Instant::now(); + thread::scope(|scope| { + for producer in 0..producers { + let tx = tx.clone(); + scope.spawn(move || { + for index in 0..PUSHES_PER_PRODUCER { + tx.push((producer * PUSHES_PER_PRODUCER + index) as u64) + .expect("the run fits in the capacity"); + } + }); + } + }); + let elapsed = started.elapsed().as_nanos() as f64; + let refusals = tx.refused(); + // Drain before dropping: teardown would otherwise walk every slot, and that + // is not part of what is being timed. + while rx.pop().is_some() {} + (elapsed, refusals) +} + +fn time_isolated_reserving(producers: usize) -> Repetition { + let (tx, rx) = + reserving_mpsc::bounded::(capacity_for(producers)).expect("a valid capacity"); + let started = Instant::now(); + thread::scope(|scope| { + for producer in 0..producers { + let tx = tx.clone(); + scope.spawn(move || { + for index in 0..PUSHES_PER_PRODUCER { + tx.push((producer * PUSHES_PER_PRODUCER + index) as u64) + .expect("the run fits in the capacity"); + } + }); + } + }); + let elapsed = started.elapsed().as_nanos() as f64; + let refusals = tx.refused(); + while rx.pop().is_some() {} + (elapsed, refusals) +} + +/// A capacity a real system would choose, so the drained regime exercises +/// backpressure the way a real one would. +const DRAINED_CAPACITY: usize = 1024; + +fn time_drained_mpsc(producers: usize) -> Repetition { + let (tx, rx) = mpsc::bounded::(DRAINED_CAPACITY).expect("a valid capacity"); + let done = Arc::new(AtomicBool::new(false)); + let consumer_done = Arc::clone(&done); + + let consumer = thread::spawn(move || { + // Spin rather than park: the doorbell's cost is `doorbell_cost`'s + // question, and parking here would measure that instead of the claim. + while !consumer_done.load(Ordering::Relaxed) { + while rx.pop().is_some() {} + std::hint::spin_loop(); + } + while rx.pop().is_some() {} + rx.refused() + }); + + let started = Instant::now(); + thread::scope(|scope| { + for producer in 0..producers { + let tx = tx.clone(); + scope.spawn(move || { + for index in 0..PUSHES_PER_PRODUCER { + let mut item = (producer * PUSHES_PER_PRODUCER + index) as u64; + // Retry on a full queue, which is what a real producer + // does. The refusal count is what makes that visible. + while let Err(error) = tx.push(item) { + item = error.into_inner(); + std::hint::spin_loop(); + } + } + }); + } + }); + let elapsed = started.elapsed().as_nanos() as f64; + + done.store(true, Ordering::Relaxed); + drop(tx); + let refusals = consumer.join().expect("the consumer must not panic"); + (elapsed, refusals) +} + +fn time_drained_reserving(producers: usize) -> Repetition { + let (tx, rx) = reserving_mpsc::bounded_with::( + DRAINED_CAPACITY, + // Tracking on, so this row also prices the switch M31.4 made opt-in. + Options::new().tracking_high_water(), + ) + .expect("a valid capacity"); + let done = Arc::new(AtomicBool::new(false)); + let consumer_done = Arc::clone(&done); + + let consumer = thread::spawn(move || { + while !consumer_done.load(Ordering::Relaxed) { + while rx.pop().is_some() {} + std::hint::spin_loop(); + } + while rx.pop().is_some() {} + rx.refused() + }); + + let started = Instant::now(); + thread::scope(|scope| { + for producer in 0..producers { + let tx = tx.clone(); + scope.spawn(move || { + for index in 0..PUSHES_PER_PRODUCER { + let mut item = (producer * PUSHES_PER_PRODUCER + index) as u64; + while let Err(error) = tx.push(item) { + item = error.into_inner(); + std::hint::spin_loop(); + } + } + }); + } + }); + let elapsed = started.elapsed().as_nanos() as f64; + + done.store(true, Ordering::Relaxed); + drop(tx); + let refusals = consumer.join().expect("the consumer must not panic"); + (elapsed, refusals) +} diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 834b83f2..c7da3099 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -39,7 +39,7 @@ preferred. | D-13 | **The arming protocol is written once, in `blocking.rs`, and a shape binds to it by implementing a crate-private `Parked` trait.** The blocking receive loop *is* [D-9](#d-9), not glue around it; a second shape spelling it out again would be a second copy of a rule -- the exact mistake this crate has already paid for once. | | D-14 | **`mpsc`'s arming asks "would `pop` find something", not "is `len` zero".** The two disagree over a slot a producer has claimed but not published, and only the first answer lets the consumer park on it instead of spinning until that producer is rescheduled. | | D-15 | **`Doorbell::clear` resets the event *before* clearing the flag that mirrors it, and the original order was a lost wakeup.** A producer signalling between the two lines set the flag and issued a real `SetEvent`; the `ResetEvent` that followed erased the signal and left the flag set, wedging the doorbell dark while it claimed to be lit. **Amends [D-9](#d-9)**, whose "there is no third case" holds only for a queue whose emptiness is one position comparison. | -| D-16 | **Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `mpsc` rather than replacing it.** Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `mpsc`'s push deliberately never reads. Rather than charge every caller for a capability not every caller wants, both ship. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. || D-17 | **The reservation count and the claim position live in one word, because a check-and-claim over both must be a single atomic operation.** Two atomics cannot be made correct with any amount of fencing: the pushing producer is load-then-store and the reserving one store-then-load, so the Dekker argument does not apply and both can miss each other. The 32/32 split is forced by the arithmetic, and caps this shape at 2^31 items. | +| D-16 | **Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `mpsc` rather than replacing it.** Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `mpsc`'s push deliberately never reads. Rather than charge every caller for a capability not every caller wants, both ship. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. | | D-17 | **The reservation count and the claim position live in one word, because a check-and-claim over both must be a single atomic operation.** Two atomics cannot be made correct with any amount of fencing: the pushing producer is load-then-store and the reserving one store-then-load, so the Dekker argument does not apply and both can miss each other. The 32/32 split is forced by the arithmetic, and caps this shape at 2^31 items. | | D-18 | **A 128-bit compare-and-swap is refused.** It would lift the 2^31 cap and nothing else -- the consumer's position still has to be read -- at the cost of a dependency, a target-feature floor not in the x86-64 baseline, and a different instruction on the ARM64 machine this workspace measures on. Revisit only for a tagged pointer, which is what [M-inf.1](../../CHECKLIST-io-domains.md)'s linked and sharded shapes would need. | | D-19 | **The coalesced loss latch is deliberately not generalised from the file watcher.** Coalescing there is sound because a desync is *idempotent* -- two mean the same as one, and the answer to both is a re-scan. A queue of arbitrary `T` has no such property, so what generalises is a loss *count*, which is [M31.4](../../CHECKLIST-io-domains.md)'s observability rather than a policy. | @@ -49,7 +49,8 @@ preferred. | D-23 | **High-water tracking is opt-in at construction; refusals and doorbell rings are always on.** The difference is where each can be paid for: refusals sit on the failure path and rings on a path that already costs a syscall, but a peak has to observe *every* change -- and on `mpsc` that means the producer reading the consumer's position, the shared line [D-16](#d-16) built a separate shape to avoid. Untracked reports `None`, not `0`. | | D-24 | **Counting the doorbell's rings turns the skip optimisation into part of the observable contract, and that is the point rather than a side effect.** R9 asks for the count precisely so "disabling the skip must change the number" -- so the sabotage entry for removing the skip changed from a control expecting `survives` to a defect expecting `caught`. An optimisation nobody can measure is an assumption. | | D-25 | **`Observable` deliberately does not restate depth.** [D-2](#d-2)'s sketch listed it, but `Bounded::len` already reports it from positions the queue keeps anyway. Naming it twice would give one number two spellings and two places to drift. What belongs on `Observable` is only what must be *accumulated*. | -## D-2: capabilities are sliced, not gathered +| D-26 | **Measured: the tail claim contends badly, and `reserving_mpsc` is up to 4x FASTER than `mpsc` under contention -- the opposite of what [D-16](#d-16) assumed.** Aggregate throughput *falls* as producers are added, for both shapes and far more than a bare contended atomic explains. D-16's premise, that reading the consumer's position makes the reserving shape the expensive one, is falsified everywhere except a single producer with a live consumer. | +| D-27 | **The gap is intrinsic to Vyukov's sequence protocol, not a fixable flaw in `mpsc`'s retry loop.** Its producer must read a slot's sequence *before* claiming, and that slot marches through memory as the tail advances while other producers write it. Padding slots onto their own cache lines was tested and rejected: it recovers about a fifth at eight producers, for four times the memory, and leaves the shape still 2.8x slower. |## D-2: capabilities are sliced, not gathered The first sketch of this crate had one `WaitableQueue` trait carrying push, pop, the doorbell, capacity, and the loss latch. The engineer's observation that the shapes would be "sliced and diced by various @@ -757,3 +758,73 @@ the past that the queue's present state cannot reconstruct. Both handles implement it, because both ends have a question. A producer wants to know how often it was refused; a consumer wants to know how deep the backlog got and how often it was actually woken. + +## D-26: the measurement, and D-16's premise falsified + +Measured by `probe-queue-contention` in a **release** build on an AMD EPYC 7763, 8 cores / 16 logical +processors, Windows 11 Enterprise 10.0.26200, `x86_64`. Median of five repetitions after a discarded +warm-up; three independent invocations agreed to within noise. **Note the architecture**: every previous +measurement in this workspace was taken on the ARM64 development machine, so these numbers fill the x64 +gap rather than extending the ARM64 record, and the two are not interchangeable. + +Isolated regime -- producers only, capacity large enough that nothing is refused, so the curve is the +claim and nothing else: + +| producers | `mpsc` ns/push | `reserving_mpsc` ns/push | contended `fetch_add` | +|---|---|---|---| +| 1 | 9.0 | 8.6 | 5.0 | +| 2 | 49.0 | 28.0 | 8.1 | +| 4 | 84.4 | 33.3 | 12.2 | +| 8 | 140.8 | 38.5 | 13.7 | +| 16 | 193.5 | 52.2 | 14.5 | +| 32 | 239.7 | 56.9 | 15.1 | + +**Two findings, and the second one was not the expected result.** + +**The tail claim contends, and severely.** Aggregate throughput *falls* as producers are added: `mpsc` +from 111M to 4.2M pushes per second, `reserving_mpsc` from 116M to 17.6M. A bare contended `fetch_add` +falls only to a third and then plateaus, so most of both curves is the queue rather than what this +processor does to a fought-over line. + +**`reserving_mpsc` is up to 4x faster than `mpsc` under contention**, which inverts [D-16](#d-16). That +decision shipped the two as peers on the reasoning that honouring a reservation costs the producer a read +of the consumer's position, making the reserving shape the expensive one. It is the cheaper one at every +producer count from two upward. The premise survives in exactly one place: a *single* producer against a +live consumer, where the drained regime measures 13.6 ns against 28.1 -- and at one producer the honest +answer is [`spsc`](crate::spsc) anyway. + +The drained regime otherwise shows the two within 16% of each other at two, four and eight producers, and +its sixteen- and thirty-two-producer rows are consumer-bound -- millions of refusals -- so they measure the +single consumer rather than the claim. + +## D-27: why, and why it is not a bug to fix + +The obvious response to D-26 is that `mpsc` must have a defect. It does not, and the difference is worth +understanding because it is a property of the two *protocols* rather than of two implementations of one. + +Both do one compare-and-swap plus one load per attempt. The load is what differs: + +- **`mpsc` reads `slots[tail & mask].sequence`** -- and must, because in Vyukov's protocol the slot's own + sequence is what says the slot is free. That address **marches through memory as the tail advances**, + and the slots it walks are being written by the very producers it is racing. +- **`reserving_mpsc` reads `head`** -- one fixed address, which stays hot in every core's cache and, in + the isolated regime, is never written at all. + +So the reserving shape's extra read is cheaper than the read it *replaces*, which is why the measurement +came out backwards from the prediction. + +**The false-sharing hypothesis was tested and rejected.** `Slot` is sixteen bytes, so four +consecutive positions share a cache line, and the obvious fix is to pad each slot onto its own. Measured: +at eight producers that moves `mpsc` from 140.8 to 109.1 ns -- about a fifth -- for four times the +memory, and leaves it 2.8x slower than `reserving_mpsc`'s 38.5. False sharing between neighbouring slots +is a contributor, not the cause. The padding was reverted; the note on `Slot` that says slots deliberately +share lines is therefore correct, and now correct for a measured reason rather than an assumed one. + +The remedy that *would* close the gap is to stop reading the slot before claiming and decide freedom from +`head` instead -- which is precisely `reserving_mpsc`'s protocol. There is no third design here to +discover: the two shapes are not "one queue with and without reservations", they are two different claim +protocols, and this measurement is the comparison between them. + +**The merge-or-delete decision is therefore live and is the engineer's**, with the data above as its +basis. It is tracked as a checklist item rather than left here, because a decision recorded only in a +design note is not scheduled work. From 371d5e4d154f8b701c6cca8479e57f0f3a253353 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 09:32:31 -0400 Subject: [PATCH 034/361] perf(probes): measure peer-index caching, and reject it with the reads counted The engineer recalled a technique that took queue throughput from millions to hundreds of millions of ops/sec: a load from the waiting side of the shared index. That is peer-index caching, and it is real -- each side keeps a plain copy of the peer's position and touches the shared line once per batch instead of once per item. None of our three shapes did it. Measured rather than adopted. It makes our ring about 1.8x SLOWER. The probe counts shared reads as well as time, and that is what makes the result readable: consumer reads fall 3.6x, so the optimisation demonstrably engaged and still lost. It trades freshness for fewer reads, which is free only over a deep batch; ours is 3.6 items deep because a spinning consumer keeps the ring near empty, so each side idles on a stale bound it could have refreshed. On the producer side the count goes up, not down. A prefetch-only "warming" variant is kept as a control and confirms the null: it removes no read and moves no time, so warming is not the mechanism. The calibration row is load-bearing. The model runs 3x faster than the shipping spsc, and the probe prints a CAUTION saying so, rather than letting the deltas be read as statements about the shipped queue. That guard fired on the first run and is the reason this reports a model result honestly instead of a wrong queue result confidently. Also repairs the D-27 row, which a prior splice had joined to the D-2 heading. Completed item: none -- this answers a research question rather than a checklist item, and D-28 schedules no work. Its one consequence for planned work is recorded in M31.8: peer-index caching is NOT a differentiator between the two mpsc protocols and must not be argued as one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 4 + crates/windows-platform-probes/Cargo.toml | 4 + .../windows-platform-probes/DESIGN-NOTES.md | 26 ++ .../src/bin/peer_index_cache.rs | 114 ++++++ crates/windows-platform-probes/src/lib.rs | 1 + .../src/peer_index_cache.rs | 365 ++++++++++++++++++ .../windows-waitable-queues/DESIGN-NOTES.md | 70 +++- 7 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 crates/windows-platform-probes/src/bin/peer_index_cache.rs create mode 100644 crates/windows-platform-probes/src/peer_index_cache.rs diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 968ebb18..3cca2e41 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -443,6 +443,10 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m were, and is the only option that removes the surprise rather than documenting it. Whichever is chosen, D-16's and `mpsc`'s own documentation must be corrected in the same change: they currently assert a cost relationship the measurement reversed. That sweep is part of this item. + **One input that was expected to matter turned out not to.** Peer-index caching is available to the + head-based protocol and structurally unavailable to Vyukov's, which looked like it would weigh against + `mpsc`. `probe-peer-index-cache` measured it and it makes our ring *slower* (D-28), so it is not a + differentiator and must not be argued as one here. - [ ] **M31.6** -- Verify the memory orderings with a model checker, because stress testing demonstrably cannot. **Measured, not assumed:** during M30.3's sabotage sweep, weakening the producer's `Acquire` diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index 0de97097..f851805d 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -63,6 +63,10 @@ path = "src/bin/topology.rs" name = "probe-queue-contention" path = "src/bin/queue_contention.rs" +[[bin]] +name = "probe-peer-index-cache" +path = "src/bin/peer_index_cache.rs" + [dependencies] # The pool-growth probe measures the shipping API rather than a # reimplementation of the SDK's inline environment helpers, so it depends on the diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 197f7bee..4ba9848f 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -493,3 +493,29 @@ it becomes consumer-bound and a plateau there says nothing about the claim. Each count from the queue's own `Observable` counters precisely so that is visible as a fact rather than mistaken for contention: the sixteen- and thirty-two-producer drained rows show millions of refusals and should be read as measurements of the consumer. + +## `probe-peer-index-cache`: a rejection, kept because the rejection is the value + +This probe measures peer-index caching -- each side of an SPSC ring keeping a plain copy of the other +side's position, so the shared line is read once per batch instead of once per item -- against the +`windows-waitable-queues` `spsc` shape. It found the technique makes our ring **slower**, and the full +reasoning lives with the queue as +[DESIGN-NOTES.md](../windows-waitable-queues/DESIGN-NOTES.md) -> `D-28`. + +Two things about its construction are deliberate and worth keeping if it is ever edited. + +**It counts shared reads, not just time.** A timing-only result would have been unreadable: "caching is +slower" is indistinguishable from "the caching was implemented wrongly and never engaged". The read +counters settle that directly -- consumer reads fall 3.6x, so the optimisation demonstrably engaged and +still lost. Any future variant added here must keep the counters for the same reason. + +**It carries a calibration row and a warming control.** The calibration times the real shipping `spsc` +beside the model, and the probe prints a CAUTION when they diverge by more than 25% -- which they +currently do, so the probe says out loud that its rows describe the model rather than the shipped +queue. That guard earned its place immediately: the first run's 3x gap would otherwise have been read +straight past. The warming variant is a control for the hypothesis that a discarded prefetch could +substitute for the real thing; it removes no read and moves no time, which is exactly what a control +that confirms the null should do. + +Like `probe-queue-contention`, this probe is **absent from the CI probe job**, and for the same +measured reason: the effects it studies are coherence effects that a debug build's overhead buries. diff --git a/crates/windows-platform-probes/src/bin/peer_index_cache.rs b/crates/windows-platform-probes/src/bin/peer_index_cache.rs new file mode 100644 index 00000000..60e4fc81 --- /dev/null +++ b/crates/windows-platform-probes/src/bin/peer_index_cache.rs @@ -0,0 +1,114 @@ +// Copyright (c) Mike Grier. + +//! Prints what caching the peer's index buys an SPSC ring. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. + +use windows_platform_probes::peer_index_cache::{CAPACITY, ITEMS, Strategy, measure}; + +fn main() { + println!("== what does caching the peer's index buy an SPSC ring? ==\n"); + + let observation = measure(); + + println!( + "{:<24} {:>10} {:>14} {:>14} {:>14}", + "configuration", "ns/item", "items/sec", "cons. reads", "prod. reads" + ); + for run in std::iter::once(&observation.calibration).chain(&observation.strategies) { + println!( + "{:<24} {:>10.1} {:>14.0} {:>14} {:>14}", + run.label, + run.nanos_per_item, + run.items_per_second, + run.consumer_refreshes, + run.producer_refreshes + ); + } + println!( + " + ({ITEMS} items, capacity {CAPACITY}. The two read columns count how often each + \ + side actually loaded the *other* side's position -- the shared line the + \ + technique exists to avoid touching.)" + ); + + println!("\ninterpretation:\n"); + + let Some(baseline) = observation.get(Strategy::Baseline) else { + return; + }; + + // The model has to reproduce the shipping queue before anything it says + // about variants is worth reading. + let drift = (baseline.nanos_per_item - observation.calibration.nanos_per_item).abs() + / observation.calibration.nanos_per_item; + println!( + " calibration: the model's baseline differs from the shipping spsc by + \ + {:.0}% ({:.1} vs {:.1} ns/item).", + drift * 100.0, + baseline.nanos_per_item, + observation.calibration.nanos_per_item + ); + if drift > 0.25 { + println!(" CAUTION: that is a wide gap, so the rows below describe the MODEL"); + println!(" and not the shipping queue. The model has only the ring mechanics;"); + println!(" the shipping push also consults the reservation count, updates the"); + println!(" depth metric and rings the doorbell. This probe does NOT attribute"); + println!(" the gap between those, and no such attribution should be read into"); + println!(" it. What the gap does establish is a floor: whatever the shared"); + println!(" read costs, it is a minority of what the shipping queue spends per"); + println!(" item, so removing it cannot be the large win."); + } else { + println!(" Close enough to treat the model as a stand-in for the real ring."); + } + + for strategy in [Strategy::Cached, Strategy::Warmed] { + let Some(run) = observation.get(strategy) else { + continue; + }; + let speedup = baseline.nanos_per_item / run.nanos_per_item; + println!( + "\n {:<22} {:.2}x the baseline ({:.1} -> {:.1} ns/item)", + match strategy { + Strategy::Cached => "peer-index caching:", + Strategy::Warmed => "warming load only:", + Strategy::Baseline => unreachable!(), + }, + speedup, + baseline.nanos_per_item, + run.nanos_per_item + ); + } + + println!( + " + The read columns say the technique WORKED and still lost. Caching" + ); + println!(" cut the consumer's shared reads by roughly 3.6x -- it is not that"); + println!(" the optimisation failed to engage. It engaged and cost throughput."); + println!(); + println!(" The reason is in the same columns: the batch it amortises over is"); + println!(" only about 3.6 items deep, because a spinning consumer keeps the"); + println!(" ring near empty. And on the producer side the count goes UP, not"); + println!(" down -- a cached index is only consulted when it says 'no room', so"); + println!(" a producer that is genuinely blocked refreshes on every spin and"); + println!(" gains nothing at all."); + println!(); + println!(" That is the trade the technique actually makes: it exchanges"); + println!(" freshness for fewer reads. When a real backlog exists the exchange"); + println!(" is free, because a stale index is still far behind the peer. When"); + println!(" the ring hovers near empty or near full it is not free -- each side"); + println!(" idles on a stale bound it could have refreshed, and that idling"); + println!(" costs more than the reads it saved."); + println!(); + println!(" The warming load is the control, and it behaves as a control should:"); + println!(" it removes no shared read (its count matches the baseline) and it"); + println!(" changes no throughput. Warming cannot help here because the"); + println!(" authoritative load still happens, and in a tight handoff loop the"); + println!(" prefetch has no time to land before it."); +} diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index 1470d76e..19115ee9 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -116,6 +116,7 @@ pub mod doorbell_cost; pub mod error_mode; pub mod handle_state; pub mod ioring; +pub mod peer_index_cache; pub mod pool_growth; pub mod queue_contention; pub mod request_cost; diff --git a/crates/windows-platform-probes/src/peer_index_cache.rs b/crates/windows-platform-probes/src/peer_index_cache.rs new file mode 100644 index 00000000..0d4c2923 --- /dev/null +++ b/crates/windows-platform-probes/src/peer_index_cache.rs @@ -0,0 +1,365 @@ +// Copyright (c) Mike Grier. + +//! What does caching the peer's index buy an SPSC ring? +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! # The technique under test +//! +//! In the ring this workspace ships, each side reads the *other's* index on +//! every single operation: the producer acquire-loads `head` to find room, the +//! consumer acquire-loads `tail` to find work. Both of those lines are written +//! by the opposite core, so every operation is a guaranteed cross-core +//! coherence miss and the line ping-pongs at interconnect speed. +//! +//! **Peer-index caching** removes almost all of them. Each side keeps a plain, +//! non-atomic copy of the peer's index and consults the shared line *only* when +//! its cached copy says the queue looks full (producer) or empty (consumer). +//! In the common case -- neither full nor empty -- there is no shared read at +//! all, so one acquire load is amortised across a whole batch. It is the +//! central trick in Erik Rigtorp's "Optimizing a ring buffer for throughput", +//! and the same idea appears in Boost's `spsc_queue` and DPDK's rings. +//! +//! It is safe because both indices are **monotonic**. A stale cached `head` +//! under-reports free space and a stale cached `tail` under-reports available +//! items, so the error is always conservative: a spurious "full" or "empty", +//! never a wrong write or a double read. +//! +//! # And a control, because the alternative memory deserves testing +//! +//! A second reading of the same technique is that the extra load is *only* +//! there to warm the cache line -- that its value cannot be used, and the +//! authoritative load still has to happen. That is a different mechanism with a +//! different ceiling, so it is measured rather than argued about: +//! [`Strategy::Warmed`] issues a discarded relaxed load of the peer index and +//! then does exactly the work the baseline does. +//! +//! # Why this measures a model rather than the shipping queue +//! +//! This crate's rule is that a probe measures the real API, because a stand-in +//! only measures itself. That rule cannot apply here: the variants do not exist +//! in the shipping crate, and the whole question is whether one of them should. +//! +//! So the model is kept structurally identical to `spsc` -- same split indices, +//! same padding, same orderings, same release/acquire pairing -- and the +//! **baseline strategy is calibrated against the real queue** in the same run. +//! If the model's baseline does not reproduce the shipping queue's number, the +//! model is wrong and the variant comparison means nothing. That calibration +//! row is printed first for exactly that reason. + +use std::cell::UnsafeCell; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::Instant; + +use windows_waitable_queues::spsc; + +/// Items handed across the ring in one timed run. +pub const ITEMS: usize = 2_000_000; + +/// Ring capacity, in items. Deep enough that a consumer keeping up leaves the +/// producer's cached index valid for long stretches, which is the regime the +/// technique is for. +pub const CAPACITY: usize = 1024; + +/// Repetitions per configuration; the median is reported. +const REPETITIONS: usize = 5; + +/// Which peer-index strategy a run uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Strategy { + /// Read the peer's index on every operation. What `spsc` does today. + Baseline, + /// Keep a local copy and consult the shared line only when it says the + /// queue looks full or empty. + Cached, + /// Issue a discarded load of the peer's index, then do exactly what the + /// baseline does. Tests whether the benefit is cache warming rather than + /// the avoided load. + Warmed, +} + +impl Strategy { + fn label(self) -> &'static str { + match self { + Self::Baseline => "model: baseline", + Self::Cached => "model: cached index", + Self::Warmed => "model: warming load", + } + } +} + +/// One configuration's result. +#[derive(Debug, Clone, Copy)] +pub struct Run { + /// What was measured. + pub label: &'static str, + /// Median nanoseconds per item handed across the ring. + pub nanos_per_item: f64, + /// Items per second. + pub items_per_second: f64, + /// How many times the consumer actually read the shared `tail`. + /// + /// **This is the number that says whether the technique was even + /// exercised.** Peer-index caching only avoids a shared read when the + /// cached copy says there is work; a consumer that finds the ring empty + /// every time refreshes every time, and caching can do nothing for it. A + /// count near [`ITEMS`] means the ring never had a backlog to batch over, + /// and any comparison drawn from that run is a comparison of branches + /// rather than of cache traffic. + pub consumer_refreshes: u64, + /// How many times the producer actually read the shared `head`. + pub producer_refreshes: u64, +} + +/// Everything one invocation measured. +#[derive(Debug, Clone)] +pub struct Observation { + /// The shipping `spsc`, so the model can be checked against it. + pub calibration: Run, + /// The model under each strategy. + pub strategies: Vec, +} + +impl Observation { + /// Look one strategy's run up. + #[must_use] + pub fn get(&self, strategy: Strategy) -> Option { + self.strategies + .iter() + .find(|run| run.label == strategy.label()) + .copied() + } +} + +/// Time the shipping queue and every model strategy. +#[must_use] +pub fn measure() -> Observation { + Observation { + calibration: median("shipping spsc", time_real_spsc), + strategies: [Strategy::Baseline, Strategy::Cached, Strategy::Warmed] + .into_iter() + .map(|strategy| median(strategy.label(), || time_model(strategy))) + .collect(), + } +} + +/// One timed pass, with the shared-read counts that pass performed. +#[derive(Debug, Clone, Copy)] +struct Sample { + nanos: f64, + consumer_refreshes: u64, + producer_refreshes: u64, +} + +fn median(label: &'static str, mut timer: impl FnMut() -> Sample) -> Run { + // One untimed pass: first touch of a fresh allocation faults pages in, and + // that belongs to the allocator rather than to the ring. + let _ = timer(); + + let mut samples: Vec = (0..REPETITIONS).map(|_| timer()).collect(); + samples.sort_by(|left, right| left.nanos.total_cmp(&right.nanos)); + let sample = samples[REPETITIONS / 2]; + + Run { + label, + nanos_per_item: sample.nanos / ITEMS as f64, + items_per_second: ITEMS as f64 / (sample.nanos / 1e9), + consumer_refreshes: sample.consumer_refreshes, + producer_refreshes: sample.producer_refreshes, + } +} + +/// The shipping queue, driven the same way the model is. +fn time_real_spsc() -> Sample { + let (tx, rx) = spsc::bounded::(CAPACITY).expect("a valid capacity"); + let started = Instant::now(); + // The producer handle is deliberately not `Sync`, so it cannot be borrowed + // into a scoped thread -- it has to move. That is the single-producer + // guarantee being enforced by the compiler, and it is why this reads + // differently from the model below. + let producer = thread::spawn(move || { + for item in 0..ITEMS as u64 { + let mut item = item; + while let Err(error) = tx.push(item) { + item = error.into_inner(); + std::hint::spin_loop(); + } + } + }); + let mut taken = 0; + while taken < ITEMS { + if rx.pop().is_some() { + taken += 1; + } else { + std::hint::spin_loop(); + } + } + let elapsed = started.elapsed().as_nanos() as f64; + producer.join().expect("the producer must not panic"); + Sample { + nanos: elapsed, + // The shipping queue caches nothing, so both sides read the shared line + // at least once per item. These are floors, not exact counts: `push` + // also consults `reserved` and the depth metric, and `pop` re-reads on + // a retry. + consumer_refreshes: ITEMS as u64, + producer_refreshes: ITEMS as u64, + } +} + +/// Pads a value onto its own cache line, as `spsc` does. +#[repr(align(128))] +struct CacheAligned(T); + +/// A minimal SPSC ring, structurally identical to `spsc`'s. +struct Ring { + slots: Box<[UnsafeCell]>, + mask: usize, + capacity: usize, + head: CacheAligned, + tail: CacheAligned, +} + +// SAFETY: the two positions partition the slots between the threads exactly as +// `spsc` does -- a slot in `[head, tail)` belongs to the consumer, one outside +// it to the producer -- and each side publishes its position with a release +// store the other acquires. +unsafe impl Sync for Ring {} + +impl Ring { + fn new(capacity: usize) -> Self { + let mut slots = Vec::with_capacity(capacity); + slots.resize_with(capacity, || UnsafeCell::new(0)); + Self { + slots: slots.into_boxed_slice(), + mask: capacity - 1, + capacity, + head: CacheAligned(AtomicUsize::new(0)), + tail: CacheAligned(AtomicUsize::new(0)), + } + } +} + +fn time_model(strategy: Strategy) -> Sample { + let ring = Ring::new(CAPACITY); + let started = Instant::now(); + let (consumer_refreshes, producer_refreshes) = thread::scope(|scope| { + let producer = scope.spawn(|| produce(&ring, strategy)); + let consumer_refreshes = consume(&ring, strategy); + let producer_refreshes = producer.join().expect("the producer must not panic"); + (consumer_refreshes, producer_refreshes) + }); + Sample { + nanos: started.elapsed().as_nanos() as f64, + consumer_refreshes, + producer_refreshes, + } +} + +/// Fills the ring, returning how many times it read the consumer's position. +fn produce(ring: &Ring, strategy: Strategy) -> u64 { + // The producer's local copy of the consumer's position. Plain, not atomic: + // it is this thread's alone, and it is only ever a conservative + // under-estimate of how much room there is. + let mut cached_head = 0_usize; + let mut refreshes = 0_u64; + + for item in 0..ITEMS as u64 { + loop { + // The producer owns `tail`, so this never leaves its own core. + let tail = ring.tail.0.load(Ordering::Relaxed); + + let full = match strategy { + Strategy::Baseline => { + refreshes += 1; + tail.wrapping_sub(ring.head.0.load(Ordering::Acquire)) == ring.capacity + } + Strategy::Warmed => { + refreshes += 1; + // Discarded: warms the line, and then the authoritative + // load happens anyway. `black_box` stops the compiler from + // noticing the first load is dead and removing it. + black_box(ring.head.0.load(Ordering::Relaxed)); + tail.wrapping_sub(ring.head.0.load(Ordering::Acquire)) == ring.capacity + } + Strategy::Cached => { + // The shared line is touched only when the cached copy says + // there is no room, which is the whole optimisation. + if tail.wrapping_sub(cached_head) == ring.capacity { + refreshes += 1; + cached_head = ring.head.0.load(Ordering::Acquire); + } + tail.wrapping_sub(cached_head) == ring.capacity + } + }; + + if full { + std::hint::spin_loop(); + continue; + } + + // SAFETY: `tail` is outside `[head, tail)`, so this slot belongs to + // the producer and no other thread reads it before the release + // store below publishes it. + unsafe { + *ring.slots[tail & ring.mask].get() = item; + } + ring.tail.0.store(tail.wrapping_add(1), Ordering::Release); + break; + } + } + + refreshes +} + +/// Drains the ring, returning how many times it read the producer's position. +fn consume(ring: &Ring, strategy: Strategy) -> u64 { + // The consumer's local copy of the producer's position; see `produce`. + let mut cached_tail = 0_usize; + let mut taken = 0_usize; + let mut refreshes = 0_u64; + + while taken < ITEMS { + let head = ring.head.0.load(Ordering::Relaxed); + + let empty = match strategy { + Strategy::Baseline => { + refreshes += 1; + head == ring.tail.0.load(Ordering::Acquire) + } + Strategy::Warmed => { + refreshes += 1; + black_box(ring.tail.0.load(Ordering::Relaxed)); + head == ring.tail.0.load(Ordering::Acquire) + } + Strategy::Cached => { + // One acquire load of `tail` per *batch*: everything that + // snapshot made visible is then drained with no shared reads at + // all. This is the load the technique is named for. + if head == cached_tail { + refreshes += 1; + cached_tail = ring.tail.0.load(Ordering::Acquire); + } + head == cached_tail + } + }; + + if empty { + std::hint::spin_loop(); + continue; + } + + // SAFETY: `head` is in `[head, tail)`, so the producer wrote this slot + // and released it; the acquire above makes that write visible here. + let item = unsafe { *ring.slots[head & ring.mask].get() }; + black_box(item); + ring.head.0.store(head.wrapping_add(1), Ordering::Release); + taken += 1; + } + + refreshes +} diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index c7da3099..fb513716 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -50,7 +50,10 @@ preferred. | D-24 | **Counting the doorbell's rings turns the skip optimisation into part of the observable contract, and that is the point rather than a side effect.** R9 asks for the count precisely so "disabling the skip must change the number" -- so the sabotage entry for removing the skip changed from a control expecting `survives` to a defect expecting `caught`. An optimisation nobody can measure is an assumption. | | D-25 | **`Observable` deliberately does not restate depth.** [D-2](#d-2)'s sketch listed it, but `Bounded::len` already reports it from positions the queue keeps anyway. Naming it twice would give one number two spellings and two places to drift. What belongs on `Observable` is only what must be *accumulated*. | | D-26 | **Measured: the tail claim contends badly, and `reserving_mpsc` is up to 4x FASTER than `mpsc` under contention -- the opposite of what [D-16](#d-16) assumed.** Aggregate throughput *falls* as producers are added, for both shapes and far more than a bare contended atomic explains. D-16's premise, that reading the consumer's position makes the reserving shape the expensive one, is falsified everywhere except a single producer with a live consumer. | -| D-27 | **The gap is intrinsic to Vyukov's sequence protocol, not a fixable flaw in `mpsc`'s retry loop.** Its producer must read a slot's sequence *before* claiming, and that slot marches through memory as the tail advances while other producers write it. Padding slots onto their own cache lines was tested and rejected: it recovers about a fifth at eight producers, for four times the memory, and leaves the shape still 2.8x slower. |## D-2: capabilities are sliced, not gathered +| D-27 | **The gap is intrinsic to Vyukov's sequence protocol, not a fixable flaw in `mpsc`'s retry loop.** Its producer must read a slot's sequence *before* claiming, and that slot marches through memory as the tail advances while other producers write it. Padding slots onto their own cache lines was tested and rejected: it recovers about a fifth at eight producers, for four times the memory, and leaves the shape still 2.8x slower. | +| D-28 | **Measured and rejected: caching the peer's index makes our SPSC ring slower, so no shape adopts it.** The technique is real and well documented, and it engaged as designed -- it cut the consumer's shared reads by 3.6x. It still cost about 1.8x throughput, because it trades freshness for fewer reads and our ring hovers near empty, where a stale bound makes each side idle on information it could have refreshed. A prefetch-only "warming" variant was measured as a control and changed nothing. | + +## D-2: capabilities are sliced, not gathered The first sketch of this crate had one `WaitableQueue` trait carrying push, pop, the doorbell, capacity, and the loss latch. The engineer's observation that the shapes would be "sliced and diced by various @@ -828,3 +831,68 @@ protocols, and this measurement is the comparison between them. **The merge-or-delete decision is therefore live and is the engineer's**, with the data above as its basis. It is tracked as a checklist item rather than left here, because a decision recorded only in a design note is not scheduled work. + + +## D-28: caching the peer's index was measured and rejected + +The engineer recalled a technique credited with taking queue throughput from millions to hundreds of +millions of operations per second: a load on the waiting side of the shared index. That memory is real +and it names a real optimisation -- **peer-index caching**, the standard trick in a high-performance +SPSC ring (Rigtorp). Each side keeps a plain, non-atomic copy of the *other* side's position. A +consumer whose cached `tail` says items are available drains them without touching the shared line at +all, and refreshes only when the cached copy says the ring is empty. One acquire load is amortised +over a whole batch, and the producer's release store stops invalidating a line the consumer reads +every iteration. + +None of the three shapes did this. `spsc::push` acquire-loads `head` on every push, `spsc::pop` +acquire-loads `tail` on every pop, and `reserving_mpsc::push` acquire-loads `head` on every push. + +**It was measured rather than adopted, and the measurement says do not adopt it.** +`probe-peer-index-cache` builds a minimal SPSC ring structurally identical to `spsc`'s and runs it +under three strategies -- baseline, peer-index caching, and a prefetch-only "warming" load kept as a +control -- while counting how many times each side actually reads the peer's position. Four release +runs on the x64 host agree: + +| strategy | ns/item | consumer reads | producer reads | +|---|---|---|---| +| baseline | 18.7 - 27.7 | ~2.03 M | ~2.03 M | +| peer-index caching | 36.6 - 39.0 | ~0.56 M | ~2.2 - 2.6 M | +| warming load only | 20.0 - 24.0 | ~2.03 M | ~2.04 M | + +The read counts are what make this conclusive, and they are the reason the probe counts them. **The +optimisation engaged**: consumer reads fell 3.6x. It engaged and still lost about 1.8x of throughput, +so this is not a failed implementation of the technique but a real result about our shape. + +The mechanism is visible in the same columns. Peer-index caching trades *freshness* for fewer reads. +That trade is free when a genuine backlog exists, because a stale index is still far behind the peer +and the batch it amortises over is deep. Here the batch is only about 3.6 items deep -- a spinning +consumer keeps the ring near empty -- so each side repeatedly idles on a stale bound it could have +refreshed, and the idling costs more than the reads saved. On the producer side the count goes *up*: +a cached index is consulted only when it says "no room", so a producer that is genuinely blocked +refreshes on every spin iteration and gains nothing whatsoever. + +The warming variant behaved exactly as a control should, which is what makes it worth having kept: it +removed no shared read (its counts match the baseline) and it moved no throughput. A discarded load +cannot help, because the authoritative load still happens and in a tight handoff loop the prefetch has +no time to land before it. **The engineer's "it is just for cache warming" reading is therefore not +the mechanism** -- the technique works by removing the load, not by warming the line for it. + +Two further reasons this stays rejected even if a deeper-batching workload were found: + +- **The shared read is a minority of the cost.** The model runs at 18.7-27.7 ns/item while the + shipping `spsc` runs at 58.6-62.8. This probe deliberately does not attribute that gap (the shipping + push also consults the reservation count, updates the depth metric and rings the doorbell), but it + does put a floor under the argument: whatever the shared read costs, removing it cannot be the large + win. +- **It would be a correctness hazard at the arming boundary.** `Consumer::arm` decides whether to + park, and that decision must be made against a fresh acquire load. A cached `tail` that says "empty" + when the producer has already published is a lost wakeup -- the same defect class as + [D-9](#d-9) and [D-15](#d-15), which this crate has now been bitten by twice. + +The technique is not wrong; it is right for a ring with a standing backlog, and this crate's is not +that ring. If a later workload does show deep batching, the measurement to repeat is this probe with +the consumer throttled, and the arming path must be exempted from any cache regardless of the result. + +**No work is scheduled by this decision.** The finding is a rejection: no shape changes, and there is +no follow-up item. It is recorded so the technique is not re-proposed without the measurement, and so +the re-measurement conditions are written down if the workload ever changes. From 51d21cc471ff84755221cc897ee43b1e59e62793 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Mon, 31 Aug 2026 10:11:42 -0400 Subject: [PATCH 035/361] perf(probes): record the ARM64 curve, and reverse D-28 on the second architecture M31.7 asked for the contention curve on ARM64 because M31.5 had inverted a design premise on x64 evidence alone. Snapdragon X2 Elite, 12 cores, no SMT, no L3, two L2 clusters of six; release build, median of three runs. Both of M31.5's claims hold, and the reserving advantage is larger rather than smaller: throughput falls as producers are added (mpsc costs 30x more per push at 32 producers than at one), and reserving_mpsc leads by up to 6.4x here against 4.2x on x64. M31.8's merge decision is therefore not weakened by the second architecture -- the evidence for the head-based protocol is stronger. Two host differences are recorded rather than smoothed away. mpsc plateaus at ~195 ns from 16 producers up, where x64 kept climbing, because this host has 12 cores and no SMT so those points are oversubscribed. And N=4 is much the noisiest point (49.5 to 104.1 across three runs, against under 2% at N=16): with two six-core L2 clusters and no L3, whether four threads land in one cluster or straddle both changes the answer, and at N>=8 straddling is forced. That row is a range, not a point. THE UNEXPECTED RESULT. probe-peer-index-cache was run because it was offered as a low-risk extra, with the expectation that it would hold. It does not. The same release binary measures peer-index caching at 17x FASTER on ARM64 (31.2 -> 1.8 ns/item), where x64 had it 1.8x slower. Producer reads fall ~580x rather than rising. The explanation in D-28 survives intact and is what makes the reversal intelligible: batch depth decides the trade, and batch depth is a property of how the two threads interleave on a given host, not of our code. x64 held them lock-step at a depth near 1; ARM64 lets them decouple to ~150. So the mechanism was right and the conclusion drawn from it did not travel. D-28's blanket "no shape adopts it" no longer follows from the evidence, and D-28's closing "no work is scheduled by this decision" is no longer true. Both amended, with a status marker adjacent to the title. The open question is queued as M-inf.4, which asks for a POLICY for an optimisation whose sign depends on the host rather than for more measurement -- we have the measurement twice and it disagrees with itself. M31.8's instruction that peer-index caching "must not be argued as" a differentiator is reversed in the same sweep, since that instruction was the thing this run contradicts. THE PROBE WAS ASSERTING ITS CONCLUSION. probe-peer-index-cache printed the x64 finding as fixed prose -- "the technique WORKED and still lost", "roughly 3.6x", "on the producer side the count goes UP" -- unconditionally. Only the speedup ratio was computed. On ARM64 it printed all three while its own table three lines above showed the opposite, and the contradiction was caught by reading rather than by the tool. The interpretation is now derived from the run: it computes both batch depths and the read reductions, distinguishes "did not engage" from "engaged and won" from "engaged and lost", verifies the warming control still behaves as a control, and states plainly that this verdict has inverted by host and must not be carried between machines. An instrument that reports its finding regardless of what it measured is worse than none, because it is believed. Completed item: M31.7: Re-run probe-queue-contention on the ARM64 development machine and record the curve beside the x64 one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 68 ++++++++++- .../windows-platform-probes/DESIGN-NOTES.md | 26 +++-- .../src/bin/peer_index_cache.rs | 108 ++++++++++++++---- .../windows-waitable-queues/DESIGN-NOTES.md | 57 +++++++-- 4 files changed, 218 insertions(+), 41 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 3cca2e41..fab4f031 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -416,7 +416,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m here: the investigation changed what the decision is *about*, from "is the extra read cheap" to "which claim protocol should survive", and that is the engineer's call. -- [ ] **M31.7** -- Re-run `probe-queue-contention` on the ARM64 development machine and record the curve +- [x] **M31.7** -- Re-run `probe-queue-contention` on the ARM64 development machine and record the curve beside the x64 one. **Not a formality.** M31.5's finding is a statement about cache-coherence behaviour, and this workspace has already been bitten once by measuring only on ARM64 -- [windows-platform-probes](crates/windows-platform-probes/DESIGN-NOTES.md) records that case. M31.5 @@ -424,6 +424,33 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m with it, and so does M-inf.1's threshold. Run it in **release**: a debug build reports the two shapes as identical, which is why the probe is not in CI. + **Done. Both of M31.5's claims hold on ARM64, and the reserving advantage is larger, not smaller.** + Host: Snapdragon X2 Elite (Qualcomm Oryon), 12 cores, no SMT, no L3, two L2 clusters of six. Release + build, median of three runs of the binary, isolated regime, ns/push: + + | producers | mpsc | reserving | atomic floor | mpsc/reserving | x64 ratio for comparison | + |---|---|---|---|---|---| + | 1 | 6.5 | 6.1 | 2.7 | 1.1x | 1.0x | + | 2 | 29.8 | 9.4 | 3.6 | 3.2x | 1.8x | + | 4 | 60.6 | 12.9 | 5.2 | 4.7x | 2.5x | + | 8 | 167.4 | 29.8 | 8.8 | 5.6x | 3.7x | + | 16 | 194.9 | 30.6 | 10.6 | 6.4x | 3.7x | + | 32 | 195.0 | 30.6 | 9.9 | 6.4x | 4.2x | + + Claim 1 (throughput falls as producers are added) holds: `mpsc` costs 30x more per push at 32 + producers than at one. Claim 2 (`reserving_mpsc` is up to 4x faster) holds and is exceeded -- **6.4x + here against 4.2x on x64**. So M31.8's merge decision is not weakened by the second architecture; the + evidence for the head-based protocol is stronger on ARM64 than it was on x64. + Two differences worth having on the record rather than smoothing away. `mpsc` **plateaus at ~195 ns + from 16 producers upward** where x64 kept climbing to 239.7 -- expected, since this host has 12 cores + and no SMT, so 16 and 32 are oversubscribed and the curve saturates. And **N=4 is by far the noisiest + point** (`mpsc` ranged 49.5 to 104.1 across the three runs, against under 2% spread at N=16 and above); + with two six-core L2 clusters and no L3, whether four threads land inside one cluster or straddle both + changes the answer, and at N>=8 straddling is forced so the variance disappears. Read the N=4 row as a + range, not a point. + + > **-> CROSS-COMPONENT NOTE:** this run also contradicted D-28, which is recorded against that decision + > and against M31.8's use of it below, not here. - [ ] **M31.8** -- Decide merge-or-delete for `mpsc` and `reserving_mpsc`, now that M31.5 has measured them and M31.7 will have checked the other architecture. @@ -443,10 +470,20 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m were, and is the only option that removes the surprise rather than documenting it. Whichever is chosen, D-16's and `mpsc`'s own documentation must be corrected in the same change: they currently assert a cost relationship the measurement reversed. That sweep is part of this item. - **One input that was expected to matter turned out not to.** Peer-index caching is available to the - head-based protocol and structurally unavailable to Vyukov's, which looked like it would weigh against - `mpsc`. `probe-peer-index-cache` measured it and it makes our ring *slower* (D-28), so it is not a - differentiator and must not be argued as one here. + **An input that was written off has come back, and this paragraph previously said the opposite.** + Peer-index caching is available to the head-based protocol and structurally unavailable to Vyukov's. + This item used to record that `probe-peer-index-cache` had measured it as making our ring *slower* + (D-28), and instructed that it "must not be argued as" a differentiator. **That instruction was based + on x64 evidence alone, and ARM64 reverses it**: the same binary measures caching at **17x faster** + there (31.2 -> 1.8 ns/item), with the mechanism D-28 itself names -- batch depth -- coming out at ~150 + items per shared read instead of the ~3.6 that made it lose on x64. See D-28, now amended. + So this **is** live as a differentiator, and it points the same way M31.7's contention curve does: it + is an optimisation only the head-based protocol can adopt, and on one of our two architectures it is + worth an order of magnitude. Do not resolve M31.8 by reinstating the old "it does not matter" line. + What it is *not* is settled. The technique wins on one host and loses on the other, so adopting it + unconditionally is as unsupported as rejecting it was. The decision this item owes is about the + protocol; whether any shape then *adopts* caching is a separate question that needs a policy for a + measurement that inverts by host, and that question is M-inf.4 rather than this item. - [ ] **M31.6** -- Verify the memory orderings with a model checker, because stress testing demonstrably cannot. **Measured, not assumed:** during M30.3's sabotage sweep, weakening the producer's `Acquire` @@ -566,3 +603,24 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio Bounded before anyone builds it: `prepare` is dominated by `GetFullPathNameW`, a Win32 call no allocator removes, and cloning already-prepared units is 95 ns of a 453 ns request. That 95 ns is the ceiling on the win, and only for a caller that can reuse a resolved path. + +- [ ] **M-inf.4** -- Peer-index caching in the head-based shapes, and more importantly **a policy for an + optimisation whose sign depends on the host.** Gated on that policy, not on more measurement -- we + already have the measurement, twice, and it disagrees with itself. + D-28 rejected the technique on x64, where the producer and consumer stayed lock-step at a batch depth + near 1 and caching cost ~1.8x. M31.7 re-ran the same binary on ARM64 and got a batch depth around 150 + and a **17x speedup**. Both are real; the variable is how the two threads interleave, which is a + property of the host (core count, SMT, cluster layout, scheduler placement) rather than of our code. + So the question this item owes is not "is it faster" but **what do we ship when a technique is a large + win on one supported machine and a loss on another.** The candidates, none of them free: + - **Ship it off**, as today. Costs ARM64 an order of magnitude on a shape that could have it. + - **Ship it on.** Costs x64 roughly 1.8x on the same shape. + - **Adapt at run time** from an observed batch depth, which is the only option that could win on both + and is also the only one that puts a heuristic in the push path -- and a mispredicting heuristic is + worse than either fixed choice. + - **Make it a construction-time option**, pushing the decision to a caller who may know their + producer/consumer coupling better than we do, at the cost of a knob nobody can set well without + running the probe themselves. + Whichever is chosen, it must be stated as a *policy* the crate owns rather than as a fact about a + processor -- see PLATFORM INTEGRITY: this is exactly a lower baseline that must not be quietly dropped + because the machine on the desk today prefers the other answer. diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 4ba9848f..2e80cfe4 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -494,20 +494,32 @@ count from the queue's own `Observable` counters precisely so that is visible as mistaken for contention: the sixteen- and thirty-two-producer drained rows show millions of refusals and should be read as measurements of the consumer. -## `probe-peer-index-cache`: a rejection, kept because the rejection is the value +## `probe-peer-index-cache`: a result that inverts by host, which is why it is kept This probe measures peer-index caching -- each side of an SPSC ring keeping a plain copy of the other side's position, so the shared line is read once per batch instead of once per item -- against the -`windows-waitable-queues` `spsc` shape. It found the technique makes our ring **slower**, and the full -reasoning lives with the queue as -[DESIGN-NOTES.md](../windows-waitable-queues/DESIGN-NOTES.md) -> `D-28`. +`windows-waitable-queues` `spsc` shape. **It gives opposite answers on our two architectures**: roughly +1.8x slower on x64, roughly 17x faster on ARM64, because the batch depth it amortises over is set by how +the two threads interleave on that host rather than by our code. The full reasoning lives with the queue +as [DESIGN-NOTES.md](../windows-waitable-queues/DESIGN-NOTES.md) -> `D-28`. -Two things about its construction are deliberate and worth keeping if it is ever edited. +This section previously described the probe as recording a settled rejection, on x64 evidence alone. + +Three things about its construction are deliberate and worth keeping if it is ever edited. **It counts shared reads, not just time.** A timing-only result would have been unreadable: "caching is slower" is indistinguishable from "the caching was implemented wrongly and never engaged". The read -counters settle that directly -- consumer reads fall 3.6x, so the optimisation demonstrably engaged and -still lost. Any future variant added here must keep the counters for the same reason. +counters settle that directly, and they are also what made the two hosts comparable -- the reads reveal +a batch depth near 1 on x64 against roughly 150 on ARM64, which is the mechanism rather than the +symptom. Any future variant added here must keep the counters for the same reason. + +**Its interpretation is derived from the run, and must never go back to prose.** It used to print the +x64 conclusion as a fixed paragraph -- "the technique WORKED and still lost", "roughly 3.6x", "the +producer count goes UP" -- with only the speedup ratio computed. Run on ARM64 it printed all three while +its own table three lines above showed the opposite, and the contradiction was noticed by a reader +rather than by the tool. A probe that states its finding regardless of what it measured is worse than no +probe, because it is believed. The interpretation now computes the batch depths and says outright that +this verdict is host-dependent. **It carries a calibration row and a warming control.** The calibration times the real shipping `spsc` beside the model, and the probe prints a CAUTION when they diverge by more than 25% -- which they diff --git a/crates/windows-platform-probes/src/bin/peer_index_cache.rs b/crates/windows-platform-probes/src/bin/peer_index_cache.rs index 60e4fc81..3d5705fd 100644 --- a/crates/windows-platform-probes/src/bin/peer_index_cache.rs +++ b/crates/windows-platform-probes/src/bin/peer_index_cache.rs @@ -85,30 +85,96 @@ fn main() { ); } + // Everything below is DERIVED from this run's numbers, and none of it may + // go back to being prose. + // + // It used to be a fixed paragraph concluding that the technique "WORKED and + // still lost", that consumer reads fell "roughly 3.6x", and that producer + // reads "go UP". Those were true of the x64 host it was written on. Run on + // an ARM64 host they were all three false -- caching was 17x FASTER, and + // producer reads fell by ~580x -- and the probe printed the old conclusion + // anyway, contradicting the table directly above it. An instrument that + // states its finding regardless of what it measured is worse than no + // instrument, because it is believed. + let Some(cached) = observation.get(Strategy::Cached) else { + return; + }; + + // The batch depth is the mechanism, so compute it rather than assert it: it + // is how many items each shared read is amortised over, and it is what + // decides whether trading freshness for fewer reads pays. + let consumer_batch = ITEMS as f64 / cached.consumer_refreshes.max(1) as f64; + let producer_batch = ITEMS as f64 / cached.producer_refreshes.max(1) as f64; + let consumer_reduction = + baseline.consumer_refreshes as f64 / cached.consumer_refreshes.max(1) as f64; + let producer_reduction = + baseline.producer_refreshes as f64 / cached.producer_refreshes.max(1) as f64; + let speedup = baseline.nanos_per_item / cached.nanos_per_item; + + println!(); + println!(" how far each shared read was amortised, with caching on:"); println!( - " - The read columns say the technique WORKED and still lost. Caching" + " consumer: {consumer_batch:.1} items per read ({consumer_reduction:.1}x fewer reads than baseline)" + ); + println!( + " producer: {producer_batch:.1} items per read ({producer_reduction:.1}x fewer reads than baseline)" ); - println!(" cut the consumer's shared reads by roughly 3.6x -- it is not that"); - println!(" the optimisation failed to engage. It engaged and cost throughput."); println!(); - println!(" The reason is in the same columns: the batch it amortises over is"); - println!(" only about 3.6 items deep, because a spinning consumer keeps the"); - println!(" ring near empty. And on the producer side the count goes UP, not"); - println!(" down -- a cached index is only consulted when it says 'no room', so"); - println!(" a producer that is genuinely blocked refreshes on every spin and"); - println!(" gains nothing at all."); + + let engaged = consumer_reduction > 1.5; + if !engaged { + println!(" The technique did NOT engage: the consumer's shared reads barely"); + println!(" moved. Any throughput difference below is noise about something"); + println!(" else, and says nothing about peer-index caching."); + } else if speedup >= 1.1 { + println!(" The technique engaged AND won, by {speedup:.2}x."); + println!(" Peer-index caching trades freshness for fewer reads, and that"); + println!(" trade pays when the batch it amortises over is deep. At the"); + println!(" depths above it is paying."); + } else if speedup <= 0.9 { + println!(" The technique engaged and still LOST, at {speedup:.2}x the baseline."); + println!(" This is a real result about the shape rather than a failed"); + println!(" implementation. Caching trades freshness for fewer reads; at the"); + println!(" batch depths above, each side idles on a stale bound it could"); + println!(" have refreshed, and that idling costs more than the reads saved."); + if producer_reduction < 1.0 { + println!(" Note the producer count went UP: a cached index is consulted"); + println!(" only when it says 'no room', so a blocked producer refreshes on"); + println!(" every spin and gains nothing."); + } + } else { + println!(" The technique engaged and changed throughput by {speedup:.2}x, which"); + println!(" is inside the noise of this probe. Treat it as no effect."); + } + println!(); - println!(" That is the trade the technique actually makes: it exchanges"); - println!(" freshness for fewer reads. When a real backlog exists the exchange"); - println!(" is free, because a stale index is still far behind the peer. When"); - println!(" the ring hovers near empty or near full it is not free -- each side"); - println!(" idles on a stale bound it could have refreshed, and that idling"); - println!(" costs more than the reads it saved."); + println!(" BATCH DEPTH IS THE VARIABLE, AND IT IS NOT A CONSTANT OF THE CODE."); + println!(" It depends on how the producer and consumer interleave, which"); + println!(" depends on the host: core count, whether siblings share a core,"); + println!(" and how the scheduler places the two threads. The same binary has"); + println!(" measured a depth near 1 on one machine and in the hundreds on"); + println!(" another, and the verdict inverted with it. Do not carry a"); + println!(" conclusion from one host to another -- run it on the host you"); + println!(" intend to make the decision for."); + + let Some(warmed) = observation.get(Strategy::Warmed) else { + return; + }; + let warm_reduction = + baseline.consumer_refreshes as f64 / warmed.consumer_refreshes.max(1) as f64; println!(); - println!(" The warming load is the control, and it behaves as a control should:"); - println!(" it removes no shared read (its count matches the baseline) and it"); - println!(" changes no throughput. Warming cannot help here because the"); - println!(" authoritative load still happens, and in a tight handoff loop the"); - println!(" prefetch has no time to land before it."); + println!( + " control (warming load): {:.2}x throughput, {:.2}x fewer consumer reads.", + baseline.nanos_per_item / warmed.nanos_per_item, + warm_reduction + ); + if warm_reduction < 1.5 { + println!(" It removed no shared read, which is what a control should do. A"); + println!(" discarded load cannot help: the authoritative load still happens,"); + println!(" and in a tight handoff loop the prefetch has no time to land."); + println!(" So the technique works by REMOVING the load, not by warming it."); + } else { + println!(" UNEXPECTED: the control removed shared reads, so it is not acting"); + println!(" as a control. Distrust the comparison above until that is explained."); + } } diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index fb513716..e32f68b6 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -835,6 +835,10 @@ design note is not scheduled work. ## D-28: caching the peer's index was measured and rejected +**Amended -- the rejection held on x64 only, and ARM64 reverses it by 17x. The blanket "no shape adopts +it" no longer follows from the evidence; the open question is queued as +[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) item M-inf.4.** + The engineer recalled a technique credited with taking queue throughput from millions to hundreds of millions of operations per second: a load on the waiting side of the shared index. That memory is real and it names a real optimisation -- **peer-index caching**, the standard trick in a high-performance @@ -877,7 +881,39 @@ cannot help, because the authoritative load still happens and in a tight handoff no time to land before it. **The engineer's "it is just for cache warming" reading is therefore not the mechanism** -- the technique works by removing the load, not by warming the line for it. -Two further reasons this stays rejected even if a deeper-batching workload were found: +### The deeper-batching workload was found, and it is simply the other architecture + +The paragraph above says the trade "is free when a genuine backlog exists, because the batch it +amortises over is deep", and that here the batch is only ~3.6 items. **That mechanism is correct and it +is the reason the conclusion does not travel.** Re-running the identical release binary on the ARM64 +development host (Snapdragon X2 Elite, 12 cores, no SMT, no L3), median of three: + +| strategy | ns/item | consumer reads | producer reads | batch depth | +|---|---|---|---|---| +| baseline | 30.4 - 32.4 | ~2.1 M | ~2.1 M | ~1 | +| peer-index caching | **1.8** | ~9 - 19 K | ~3.4 - 3.6 K | **~150** | +| warming load only | 27.0 - 29.2 | ~2.0 M | ~2.1 M | ~1 | + +**17x faster, not 1.8x slower**, and the producer read count falls by ~580x rather than rising. Every +observable this decision rested on inverted. What did not change is the *explanation*: batch depth +decides the outcome, and batch depth is a property of how the two threads interleave -- core count, +whether siblings share a core, how the scheduler places them -- not of our code. x64 kept them +lock-step; ARM64 lets them decouple. + +Two consequences, and the second is the uncomfortable one: + +- The blanket rule **"no shape adopts it"** does not follow from the evidence any more. It is now a + choice between hosts, queued as [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M-inf.4, + which asks for a *policy* for a technique whose sign depends on the machine rather than for more + measurement. We have the measurement twice and it disagrees with itself. +- **The probe was printing this decision's conclusion as fixed prose.** It stated "the technique WORKED + and still lost", "roughly 3.6x", and "on the producer side the count goes UP" unconditionally, so on + ARM64 it contradicted its own table three lines above. Only the speedup ratio was computed. That is + fixed -- the interpretation is now derived from the run, including the batch depths, and it says + outright that the verdict has inverted by host. An instrument that reports its conclusion regardless + of what it measured is worse than no instrument, because it is believed. + +The two reasons below still stand, and neither is architecture-dependent: - **The shared read is a minority of the cost.** The model runs at 18.7-27.7 ns/item while the shipping `spsc` runs at 58.6-62.8. This probe deliberately does not attribute that gap (the shipping @@ -889,10 +925,15 @@ Two further reasons this stays rejected even if a deeper-batching workload were when the producer has already published is a lost wakeup -- the same defect class as [D-9](#d-9) and [D-15](#d-15), which this crate has now been bitten by twice. -The technique is not wrong; it is right for a ring with a standing backlog, and this crate's is not -that ring. If a later workload does show deep batching, the measurement to repeat is this probe with -the consumer throttled, and the arming path must be exempted from any cache regardless of the result. - -**No work is scheduled by this decision.** The finding is a rejection: no shape changes, and there is -no follow-up item. It is recorded so the technique is not re-proposed without the measurement, and so -the re-measurement conditions are written down if the workload ever changes. +The technique is not wrong; it is right for a ring with a standing backlog. **This crate's ring is that +ring on one of our two architectures and is not on the other**, which is the whole finding. The +re-measurement conditions written down here were "if a later workload shows deep batching" -- what +actually surfaced it was not a later workload but a second machine, and that is the more useful trigger +to remember. The arming path must be exempted from any cache regardless of the result, for the reason +below. + +**Work is now scheduled by this decision**, where an earlier revision said none was. That sentence was +accurate when the answer was a flat rejection and is not accurate now: the open question is +[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M-inf.4. It is recorded so the technique is +neither re-proposed without measurement nor adopted on the strength of whichever host someone happened +to benchmark on. From 8da5219409ec22d62b571a49bd73fba6581f7425 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Mon, 31 Aug 2026 10:26:14 -0400 Subject: [PATCH 036/361] feat(probes): measure queue placement across efficiency domains, and refute the hypothesis The question was whether it matters where the two ends of a queue run on a machine with two efficiency classes. It matters a great deal. probe-core-affinity pins an SPSC producer and consumer to chosen processors and measures the handoff under each placement the host can express. On the ARM64 development machine, medians of three and stable across three invocations: within a domain 38.5 ns/item across domains 215.3 ns/item -- 5.6x, for nothing but placement Within a class, the performance cores run the same handoff at 30.4 ns against the efficiency cores' 38.7, about 27% apart. So the boundary costs an order of magnitude more than the core type does. THE HYPOTHESIS IT WAS WRITTEN TO TEST IS REFUTED. The probe existed to explain why peer-index caching wins 17x on this host and loses 1.8x on x64. The suspect was placement: mismatched core speeds might decouple the two threads, letting a backlog form and giving caching the deep batch it needs. Measured, the opposite holds -- threads placed TOGETHER batch ~135x deeper than threads placed apart (49.6 against 0.4 items per shared read). A coherent reading is that a cheap handoff lets the producer race ahead and build a backlog while an expensive one throttles it into lockstep, so cost drives depth rather than core speed driving it. This run does not test that, and the probe says so rather than substituting a new conclusion it did not earn. It also failed at its main purpose, which is worth stating plainly: caching wins at BOTH placements here (14.4x together, 3.0x apart), so placement does not explain the host disagreement. D-28 and M-inf.4 stay open, and M-inf.4 now records that this particular explanation has been eliminated rather than leaving someone to try it again. A CONFOUND THE MACHINE CANNOT ESCAPE. Its efficiency classes and cache domains coincide exactly -- processors 0-5 are class 0 behind one L2, 6-11 are class 1 behind the other -- so every cross-class pair is also a cross-cache pair. The 5.6x is "across domains"; attributing it to core speed or to cache needs a host whose classes and caches cut differently. The probe detects this and prints a CAUTION, and reports the two inexpressible placements as n/a, because "this host cannot test that" and "that made no difference" are opposite findings. Two construction notes. Pinning failures panic rather than warn: a silently unpinned thread turns a placement experiment into a measurement of the scheduler's preferences while still printing a confident number. And batch depth is read only from the cached runs -- the baseline strategy reads the shared line every operation by definition, so its depth is ~1 at every placement. An earlier revision compared baseline depths and reported 0.8 against 0.4, which was noise around a constant being read as a finding. peer_index_cache gains time_model_on and a public Sample so the ring has one definition rather than a pinned copy beside an unpinned one. M-inf.5 queues the consequence: the cross-domain queue the M30 design deferred now has a measured price of 5.6x rather than an assumed one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 21 ++ crates/windows-platform-probes/Cargo.toml | 4 + .../windows-platform-probes/DESIGN-NOTES.md | 42 +++ .../src/bin/core_affinity.rs | 308 +++++++++++++++ .../src/core_affinity.rs | 357 ++++++++++++++++++ .../src/core_affinity/tests.rs | 160 ++++++++ crates/windows-platform-probes/src/lib.rs | 1 + .../src/peer_index_cache.rs | 62 ++- 8 files changed, 950 insertions(+), 5 deletions(-) create mode 100644 crates/windows-platform-probes/src/bin/core_affinity.rs create mode 100644 crates/windows-platform-probes/src/core_affinity.rs create mode 100644 crates/windows-platform-probes/src/core_affinity/tests.rs diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index fab4f031..6a68c6d3 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -604,6 +604,20 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio removes, and cloning already-prepared units is 95 ns of a 453 ns request. That 95 ns is the ceiling on the win, and only for a caller that can reuse a resolved path. +- [ ] **M-inf.5** -- **Domain-local queue placement**, now that the cost of getting it wrong is measured. + `probe-core-affinity` finds an SPSC handoff costs **38.5 ns/item within a domain and 215.3 ns/item + across domains on the ARM64 host -- 5.6x for nothing but where the two threads run**. That is far + larger than any micro-optimisation this crate has considered, and it is a *placement* decision rather + than a code one, which puts it squarely in the runtime's remit rather than the queue's. + The design already intends one pinned thread per domain, so the queue between two threads of the same + domain is the common case and is fine. What this measurement bounds is the **cross-domain** queue -- + the one M30's design deferred on the grounds that N=1 does not need it -- and the number to carry into + that decision is 5.6x, not zero. + Gated on the domain runtime existing (M33+.1), not on more measurement. + **Do not read the 5.6x as a cache effect or as a core-speed effect.** On this machine the efficiency + classes and cache domains coincide exactly, so the two are perfectly confounded; separating them needs + a host whose classes and caches cut differently, which we do not have. + - [ ] **M-inf.4** -- Peer-index caching in the head-based shapes, and more importantly **a policy for an optimisation whose sign depends on the host.** Gated on that policy, not on more measurement -- we already have the measurement, twice, and it disagrees with itself. @@ -624,3 +638,10 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio Whichever is chosen, it must be stated as a *policy* the crate owns rather than as a fact about a processor -- see PLATFORM INTEGRITY: this is exactly a lower baseline that must not be quietly dropped because the machine on the desk today prefers the other answer. + **One candidate explanation has already been tested and eliminated.** `probe-core-affinity` was written + to check whether the host difference was really a *placement* difference -- whether mismatched core + speeds on a heterogeneous machine decouple the two threads and manufacture the deep batch caching + needs. It does not: caching wins at **both** placements on the ARM64 host (14.4x within a domain, 3.0x + across), and threads placed together batch ~135x *deeper* than threads placed apart, which is the + opposite of the prediction. So the x64/ARM64 disagreement is not explained by where the threads run, + and this item cannot be closed by appealing to placement. diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index f851805d..f4d2b21d 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -15,6 +15,10 @@ description = "Executable probes for the undocumented Windows behaviour this wor [lib] path = "src/lib.rs" +[[bin]] +name = "probe-core-affinity" +path = "src/bin/core_affinity.rs" + [[bin]] name = "probe-error-mode" path = "src/bin/error_mode.rs" diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 2e80cfe4..287b1a90 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -494,6 +494,48 @@ count from the queue's own `Observable` counters precisely so that is visible as mistaken for contention: the sixteen- and thirty-two-producer drained rows show millions of refusals and should be read as measurements of the consumer. +## `probe-core-affinity`: placement costs 5.6x, and it refuted the hypothesis it was written to test + +This probe pins an SPSC producer and consumer to chosen logical processors and measures the handoff +under each placement the machine can express. It exists because +[`probe-peer-index-cache`](#probe-peer-index-cache-a-result-that-inverts-by-host-which-is-why-it-is-kept) +gave opposite answers on two hosts, and the obvious suspect was *placement*: a machine with two +efficiency classes might be decoupling the two threads in a way a homogeneous one does not. + +**The plain answer, which is the useful one.** On the ARM64 development host the unoptimised handoff +costs **38.5 ns/item within a domain and 215.3 ns/item across domains -- 5.6x, for no change but where +the two threads run.** Within a class, the performance cores (class 1) run the same handoff at 30.4 ns +against the efficiency cores' 38.7, about 27% apart, which is a real but far smaller effect than +crossing the boundary. Medians of three, stable across three invocations. + +**The hypothesis was refuted, and backwards.** The prediction was that mismatched core speeds would +decouple the two sides, letting a backlog form and giving peer-index caching the deep batch it needs. +Measured, threads placed *together* batch **~135x deeper** than threads placed apart (49.6 against 0.4 +items per shared read). A coherent reading is that a cheap handoff lets the producer race ahead and +build a backlog while an expensive one throttles it into lockstep -- so cost drives depth rather than +core speed driving it -- but **this run does not test that**, and the probe says so rather than +recording a replacement conclusion it did not earn. What is established is only that the original +prediction is wrong. + +**It also failed to explain the host disagreement, which was its main purpose.** Caching wins at *both* +placements here (14.4x together, 3.0x apart), so placement alone does not account for x64 rejecting the +technique while ARM64 accepts it. That question stays open under `D-28` and M-inf.4. + +**A confound this machine cannot escape, stated because it bounds every reading above.** Its efficiency +classes and its cache domains coincide exactly -- processors 0-5 are class 0 behind one L2, 6-11 are +class 1 behind the other -- so every cross-class pair is also a cross-cache pair. The 5.6x is +"across domains", and attributing it to core speed *or* to cache would need a machine whose classes and +caches cut differently. The probe detects this and prints a CAUTION rather than letting a reader draw +the finer conclusion; two of its four placement rows come back `n/a`, and reporting a placement as +inexpressible is deliberately not the same as reporting that it made no difference. + +Two construction notes. **Pinning failures panic** rather than warn: a silently unpinned thread turns a +placement experiment into a measurement of the scheduler's preferences while still printing a confident +number. And **batch depth is read from the cached runs only** -- the baseline strategy reads the shared +line on every operation by definition, so its depth is ~1 at every placement and carries no +information. An earlier revision compared the baseline depths and duly reported 0.8 against 0.4, which +is noise around a constant being read as a finding. + ## `probe-peer-index-cache`: a result that inverts by host, which is why it is kept This probe measures peer-index caching -- each side of an SPSC ring keeping a plain copy of the other diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs new file mode 100644 index 00000000..7414afd5 --- /dev/null +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -0,0 +1,308 @@ +// Copyright (c) Mike Grier. + +//! Prints whether it matters where the two ends of a queue run. + +use windows_platform_probes::core_affinity::{Placement, measure}; +use windows_platform_probes::peer_index_cache::Strategy; + +fn main() -> std::io::Result<()> { + println!("== does it matter where the two ends of a queue run? ==\n"); + + let observation = measure()?; + + println!("processors, as discovered:"); + println!( + " {:>4} {:>16} {:>13}", + "cpu", "efficiency class", "cache domain" + ); + for place in &observation.processors { + println!( + " {:>4} {:>16} {:>13}", + place.number, + place.efficiency_class, + place + .cache_domain + .map_or_else(|| "none".to_owned(), |id| id.to_string()) + ); + } + + let classes: Vec = { + let mut seen: Vec = observation + .processors + .iter() + .map(|p| p.efficiency_class) + .collect(); + seen.sort_unstable(); + seen.dedup(); + seen + }; + println!( + "\n {} efficiency class(es), {} cache domain(s)", + classes.len(), + { + let mut seen: Vec<_> = observation + .processors + .iter() + .map(|p| p.cache_domain) + .collect(); + seen.sort_unstable(); + seen.dedup(); + seen.len() + } + ); + + if !observation.by_class.is_empty() { + println!("\n-- the same handoff, within each efficiency class --"); + println!( + "{:<12} {:>4} {:>4} {:>12} {:>12} {:>10}", + "class", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" + ); + let mut classes: Vec = observation + .by_class + .iter() + .map(|m| m.producer.efficiency_class) + .collect(); + classes.sort_unstable(); + classes.dedup(); + for class in classes { + let base = observation + .by_class + .iter() + .find(|m| m.producer.efficiency_class == class && m.strategy == Strategy::Baseline); + let cached = observation + .by_class + .iter() + .find(|m| m.producer.efficiency_class == class && m.strategy == Strategy::Cached); + if let (Some(base), Some(cached)) = (base, cached) { + println!( + "{:<12} {:>4} {:>4} {:>12.1} {:>12.1} {:>10.1}", + format!("class {class}"), + base.producer.number, + base.consumer.number, + base.nanos_per_item, + cached.nanos_per_item, + cached.consumer_batch + ); + } + } + println!( + " (Windows numbers efficiency classes with the FASTER cores higher, so\n \ + the highest class here is the performance one.)" + ); + } + + println!("\n-- the handoff, by placement --"); + println!( + "{:<26} {:>4} {:>4} {:>12} {:>12} {:>10} {:>10}", + "placement", "prod", "cons", "base ns/it", "cached ns/it", "base depth", "cach depth" + ); + + let all = [ + Placement::SameCacheSameClass, + Placement::SameCacheCrossClass, + Placement::CrossCacheSameClass, + Placement::CrossCacheCrossClass, + ]; + + for placement in all { + let (Some(base), Some(cached)) = ( + observation.get(placement, Strategy::Baseline), + observation.get(placement, Strategy::Cached), + ) else { + // Absent is a finding, not a gap: it means this machine cannot + // express the placement at all. + println!( + "{:<26} {:>4} {:>4} {:>12} {:>12} {:>10} {:>10}", + placement.label(), + "-", + "-", + "n/a", + "n/a", + "-", + "-" + ); + continue; + }; + println!( + "{:<26} {:>4} {:>4} {:>12.1} {:>12.1} {:>10.1} {:>10.1}", + placement.label(), + base.producer.number, + base.consumer.number, + base.nanos_per_item, + cached.nanos_per_item, + base.consumer_batch, + cached.consumer_batch + ); + } + + println!("\ninterpretation:\n"); + + let expressible = observation.placements(); + if expressible.len() < 2 { + println!(" This machine expresses only one placement, so it cannot answer"); + println!(" the question. That is a fact about the host, not a null result:"); + println!(" a homogeneous single-cache machine has nowhere else to put the"); + println!(" two threads."); + return Ok(()); + } + + // Whether the two factors can be told apart at all on this host. If every + // cross-class pair is also cross-cache, they are perfectly confounded and + // no amount of measurement here separates them -- which is a fact to state, + // not to reason past. + let confounded = !expressible.contains(&Placement::SameCacheCrossClass) + && !expressible.contains(&Placement::CrossCacheSameClass); + if confounded { + println!(" CAUTION: on this machine the efficiency classes and the cache"); + println!(" domains coincide exactly, so every cross-class pair is also a"); + println!(" cross-cache pair. The two effects are perfectly CONFOUNDED here"); + println!(" and nothing below separates them. Read the rows as 'within a"); + println!(" domain' versus 'across domains', and do not attribute the"); + println!(" difference to core speed or to cache without a machine whose"); + println!(" classes and caches cut differently.\n"); + } + + // Batch depth is read from the CACHED runs, never the baseline ones. + // Baseline reads the shared line on every operation by definition, so its + // depth is ~1 whatever the placement and carries no information at all. An + // earlier version of this probe compared the baseline depths and duly + // reported ~0.8 against ~0.4, which is noise around a constant being read + // as a finding. + let same_class: Vec<_> = expressible + .iter() + .filter(|p| { + matches!( + p, + Placement::SameCacheSameClass | Placement::CrossCacheSameClass + ) + }) + .filter_map(|p| observation.get(*p, Strategy::Cached)) + .collect(); + let cross_class: Vec<_> = expressible + .iter() + .filter(|p| { + matches!( + p, + Placement::SameCacheCrossClass | Placement::CrossCacheCrossClass + ) + }) + .filter_map(|p| observation.get(*p, Strategy::Cached)) + .collect(); + + let mean = |runs: &[_], f: fn(&_) -> f64| -> Option { + if runs.is_empty() { + None + } else { + Some(runs.iter().map(f).sum::() / runs.len() as f64) + } + }; + + if let (Some(same), Some(cross)) = ( + mean( + &same_class, + |m: &windows_platform_probes::core_affinity::Measurement| m.consumer_batch, + ), + mean( + &cross_class, + |m: &windows_platform_probes::core_affinity::Measurement| m.consumer_batch, + ), + ) { + let within = if confounded { + "within a domain " + } else { + "same-class " + }; + let across = if confounded { + "across domains " + } else { + "cross-class " + }; + println!(" batch depth with caching on, {within}: {same:.1} items per shared read"); + println!(" batch depth with caching on, {across}: {cross:.1} items per shared read"); + if cross > same * 2.0 { + println!("\n SEPARATION DEEPENS THE BATCH. The two sides decouple: one runs"); + println!(" ahead, a real backlog forms, and each shared read is amortised"); + println!(" over it. That is the condition peer-index caching needs, and it"); + println!(" is a property of PLACEMENT -- not of the architecture."); + } else if same > cross * 2.0 { + println!("\n THE HYPOTHESIS IS REFUTED, AND BACKWARDS. Threads placed"); + println!( + " TOGETHER batch {:.0}x deeper than threads placed apart, where the", + same / cross.max(0.001) + ); + println!(" prediction was the reverse -- that mismatched cores would"); + println!(" decouple and batch deeply."); + println!(" A coherent reading: a cheap handoff lets the producer race ahead"); + println!(" and build a backlog, while an expensive one throttles it into"); + println!(" lockstep, so each side arrives to find exactly one item. Cost"); + println!(" drives depth, rather than depth being set by core speed."); + println!(" That is a hypothesis this run does not test, and it must not be"); + println!(" recorded as a finding -- what IS established is that the"); + println!(" original prediction is wrong."); + } else { + println!( + "\n Placement does NOT move batch depth here ({:.2}x).", + cross / same + ); + println!(" The hypothesis that unequal core speeds drive the batching is"); + println!(" not supported, and the difference between hosts needs another"); + println!(" explanation. Recording a refutation is the point of running it."); + } + } + + // The plainest answer to "does placement matter", independent of caching. + if let (Some(near), Some(far)) = ( + observation.get(Placement::SameCacheSameClass, Strategy::Baseline), + observation + .get(Placement::CrossCacheCrossClass, Strategy::Baseline) + .or_else(|| observation.get(Placement::CrossCacheSameClass, Strategy::Baseline)), + ) { + println!( + "\n the unoptimised handoff costs {:.1} ns/item together and {:.1} ns/item", + near.nanos_per_item, far.nanos_per_item + ); + println!( + " apart -- {:.1}x for crossing the boundary, with no code change.", + far.nanos_per_item / near.nanos_per_item + ); + } + + println!("\n does the verdict on caching depend on placement?\n"); + let mut verdicts = Vec::new(); + for placement in expressible { + let (Some(base), Some(cached)) = ( + observation.get(placement, Strategy::Baseline), + observation.get(placement, Strategy::Cached), + ) else { + continue; + }; + let speedup = base.nanos_per_item / cached.nanos_per_item; + let verdict = if speedup >= 1.1 { + "caching WINS" + } else if speedup <= 0.9 { + "caching LOSES" + } else { + "no effect" + }; + println!( + " {:<26} {:>7.2}x {verdict}", + placement.label(), + speedup + ); + verdicts.push(verdict); + } + verdicts.sort_unstable(); + verdicts.dedup(); + + if verdicts.len() > 1 { + println!("\n THE VERDICT FLIPS WITHIN ONE MACHINE. A technique whose sign"); + println!(" depends on where two threads are scheduled cannot be adopted or"); + println!(" rejected by a fixed decision. Any answer has to name the"); + println!(" placement it holds for."); + } else { + println!("\n The verdict is the same at every placement on this host, so"); + println!(" placement alone does not explain the disagreement between hosts."); + } + + Ok(()) +} diff --git a/crates/windows-platform-probes/src/core_affinity.rs b/crates/windows-platform-probes/src/core_affinity.rs new file mode 100644 index 00000000..efbdceba --- /dev/null +++ b/crates/windows-platform-probes/src/core_affinity.rs @@ -0,0 +1,357 @@ +// Copyright (c) Mike Grier. + +//! Does it matter *where* the two ends of a queue run? +//! +//! # The question +//! +//! A machine with two efficiency classes and two L2 domains offers four kinds +//! of producer/consumer placement: same cache and same class, same cache and +//! different class, different cache and same class, different cache and +//! different class. This probe measures an SPSC handoff under each, with the +//! two threads pinned rather than left to the scheduler. +//! +//! # Why it was written, which is not the obvious reason +//! +//! [`crate::peer_index_cache`] found that caching the peer's index costs about +//! 1.8x on an x64 host and *wins* about 17x on an ARM64 one. That decided a +//! design question (`D-28`) in opposite directions on two machines, which is an +//! uncomfortable place to leave it. +//! +//! The explanation both hosts agree on is **batch depth**: caching trades +//! freshness for fewer shared reads, and that trade pays only when each read is +//! amortised over many items. What neither run explained is *why* the depth +//! differed by two orders of magnitude. The hypothesis this probe exists to +//! test is that depth is set by how evenly matched the two threads are: +//! +//! - Two threads of **equal** speed stay in lockstep. The ring hovers near +//! empty, every operation finds it empty, and the batch is one item deep. +//! - Two threads of **unequal** speed decouple. The faster side runs ahead, a +//! real backlog forms, and the batch is as deep as the backlog. +//! +//! If that is right, then the x64/ARM64 split is not about the architecture at +//! all. It is about **homogeneous versus heterogeneous cores** -- the x64 host +//! has one class of core, this one has two -- and the deciding factor is +//! whether the producer and consumer landed on cores of the same class. That +//! would be a far more useful thing to know than "ARM64 is different", because +//! it names a condition a caller could actually reason about. +//! +//! The probe is built to be able to **refute** that, which matters more than +//! its ability to confirm it. If placement turns out not to move batch depth, +//! the hypothesis is wrong and the host difference needs another explanation; +//! the numbers below say so either way. +//! +//! # What it controls for +//! +//! Cross-class pairs differ in two ways at once -- the cores run at different +//! speeds *and*, on this machine, they sit behind different L2 domains. Those +//! are separable only if same-class cross-cache pairs exist, which is why every +//! available combination is measured rather than only the interesting one. A +//! same-class pair spanning two caches isolates the cache effect; a +//! different-class pair sharing a cache, where the hardware provides one, +//! isolates the speed effect. +//! +//! # Reading it +//! +//! Absolute nanoseconds are host-specific and not the point. Two things are: +//! whether **batch depth** tracks class mismatch, and whether the **verdict on +//! caching** flips between placements on a single machine. The second is the +//! one with consequences, because a technique whose sign depends on where two +//! threads happen to be scheduled cannot be adopted or rejected by a fixed +//! decision at all. +//! +//! Run in **release**. A debug build's overhead buries coherence effects, which +//! is the same reason the two probes this one extends are absent from CI. + +use std::collections::BTreeMap; + +use windows_topology_sys::{DomainKind, Topology}; + +use crate::peer_index_cache::{ITEMS, Strategy, time_model_on}; + +/// Repetitions per placement; the median is reported. +/// +/// Odd, so the median is an observation rather than an average of two. +const REPETITIONS: usize = 3; + +/// One cache domain: its id, and the processors behind it. +type CacheDomain = (u32, Vec); + +/// One logical processor's position in the machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProcessorPlace { + /// Its number within the (single) processor group. + pub number: u8, + /// Windows's efficiency class. Higher is faster; the values themselves are + /// only meaningful relative to each other on the same machine. + pub efficiency_class: u8, + /// Which last-level-that-partitions cache domain it sits behind, or `None` + /// if the machine reports no cache level that divides it. + pub cache_domain: Option, +} + +/// How a producer and a consumer are placed relative to each other. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Placement { + /// Same cache domain, same efficiency class. + SameCacheSameClass, + /// Same cache domain, different efficiency class. + SameCacheCrossClass, + /// Different cache domain, same efficiency class. + CrossCacheSameClass, + /// Different cache domain, different efficiency class. + CrossCacheCrossClass, +} + +impl Placement { + /// A short label for a table. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::SameCacheSameClass => "same cache, same class", + Self::SameCacheCrossClass => "same cache, cross class", + Self::CrossCacheSameClass => "cross cache, same class", + Self::CrossCacheCrossClass => "cross cache, cross class", + } + } +} + +/// One placement measured under one strategy. +#[derive(Debug, Clone, Copy)] +pub struct Measurement { + /// Which processor produced. + pub producer: ProcessorPlace, + /// Which processor consumed. + pub consumer: ProcessorPlace, + /// How the two are related. + pub placement: Placement, + /// Which strategy was measured. + pub strategy: Strategy, + /// Median nanoseconds per item. + pub nanos_per_item: f64, + /// How many items each consumer-side shared read was amortised over. + /// + /// **This is the number the hypothesis is about.** Near 1 means the two + /// sides stayed in lockstep; large means they decoupled and a real backlog + /// formed. + pub consumer_batch: f64, + /// The same for the producer side. + pub producer_batch: f64, +} + +/// Everything one invocation measured. +#[derive(Debug, Clone)] +pub struct Observation { + /// Every logical processor, as discovered. + pub processors: Vec, + /// One within-class, within-cache pair per efficiency class. + /// + /// Separate from [`Self::measurements`] because the placement categories + /// collapse every same-class pair into one row, which would answer "does + /// separation cost" while silently skipping "are the fast cores faster". + /// On a machine whose classes are named for their speed, that second + /// question is the one a reader asks first. + pub by_class: Vec, + /// Which placements this machine can actually express. + /// + /// Not every machine offers all four: one with a single cache domain cannot + /// produce a cross-cache pair, and a homogeneous one cannot produce a + /// cross-class pair. A placement that is absent is reported as absent + /// rather than silently skipped, because "this host cannot test that" and + /// "that made no difference" are opposite findings. + pub measurements: Vec, +} + +/// Discover where each logical processor sits. +/// +/// # Errors +/// +/// Returns whatever [`Topology::discover`] failed with. +pub fn discover_places() -> std::io::Result> { + let topology = Topology::discover()?; + + let mut class_of: BTreeMap = BTreeMap::new(); + for core in topology.cores() { + let DomainKind::Core { + efficiency_class, .. + } = core.kind + else { + continue; + }; + for (_group, number) in core.processors.iter() { + class_of.insert(number, efficiency_class); + } + } + + // The outermost cache level that actually divides the machine. Keyed on + // "the level that partitions" rather than literally on L3, because this + // machine reports no L3 at all and a rule naming L3 would find nothing. + let mut best: Option> = None; + for level in 1..=4_u8 { + let domains: Vec = topology + .caches_at_level(level) + .map(|domain| { + ( + domain.id, + domain.processors.iter().map(|(_, number)| number).collect(), + ) + }) + .collect(); + if domains.len() > 1 { + best = Some(domains); + } + } + + let mut cache_of: BTreeMap = BTreeMap::new(); + if let Some(domains) = &best { + for (id, processors) in domains { + for number in processors { + cache_of.insert(*number, *id); + } + } + } + + Ok(class_of + .into_iter() + .map(|(number, efficiency_class)| ProcessorPlace { + number, + efficiency_class, + cache_domain: cache_of.get(&number).copied(), + }) + .collect()) +} + +/// Classify a pair. +#[must_use] +pub fn classify(producer: ProcessorPlace, consumer: ProcessorPlace) -> Placement { + let same_cache = producer.cache_domain == consumer.cache_domain; + let same_class = producer.efficiency_class == consumer.efficiency_class; + match (same_cache, same_class) { + (true, true) => Placement::SameCacheSameClass, + (true, false) => Placement::SameCacheCrossClass, + (false, true) => Placement::CrossCacheSameClass, + (false, false) => Placement::CrossCacheCrossClass, + } +} + +/// Choose one representative processor pair for each placement this machine can +/// express. +/// +/// One pair per placement rather than an exhaustive sweep: the question is +/// whether the *category* moves the result, and 12 processors would otherwise +/// mean 132 ordered pairs times three strategies times three repetitions. +#[must_use] +pub fn representative_pairs( + places: &[ProcessorPlace], +) -> BTreeMap { + let mut chosen = BTreeMap::new(); + for producer in places { + for consumer in places { + if producer.number == consumer.number { + // No SMT here, and in any case a queue whose two ends share one + // core measures scheduling, not coherence. + continue; + } + chosen + .entry(classify(*producer, *consumer)) + .or_insert((*producer, *consumer)); + } + } + chosen +} + +/// Measure every expressible placement under baseline and cached strategies. +/// +/// # Errors +/// +/// Returns whatever [`discover_places`] failed with. +pub fn measure() -> std::io::Result { + let processors = discover_places()?; + let pairs = representative_pairs(&processors); + let mut measurements = Vec::new(); + + for (placement, (producer, consumer)) in pairs { + for strategy in [Strategy::Baseline, Strategy::Cached] { + let mut samples: Vec<_> = (0..REPETITIONS) + .map(|_| time_model_on(strategy, Some(producer.number), Some(consumer.number))) + .collect(); + samples.sort_by(|a, b| a.nanos.total_cmp(&b.nanos)); + let median = samples[samples.len() / 2]; + + measurements.push(Measurement { + producer, + consumer, + placement, + strategy, + nanos_per_item: median.nanos / ITEMS as f64, + consumer_batch: ITEMS as f64 / median.consumer_refreshes.max(1) as f64, + producer_batch: ITEMS as f64 / median.producer_refreshes.max(1) as f64, + }); + } + } + + // One same-class, same-cache pair per efficiency class, so "are the fast + // cores faster at this" is answerable and not folded into a single + // same-class row. + let mut by_class = Vec::new(); + let mut classes: Vec = processors.iter().map(|p| p.efficiency_class).collect(); + classes.sort_unstable(); + classes.dedup(); + for class in classes { + let members: Vec<_> = processors + .iter() + .filter(|p| p.efficiency_class == class) + .collect(); + let Some((producer, consumer)) = members + .iter() + .flat_map(|a| members.iter().map(move |b| (**a, **b))) + .find(|(a, b)| a.number != b.number && a.cache_domain == b.cache_domain) + else { + continue; + }; + for strategy in [Strategy::Baseline, Strategy::Cached] { + let mut samples: Vec<_> = (0..REPETITIONS) + .map(|_| time_model_on(strategy, Some(producer.number), Some(consumer.number))) + .collect(); + samples.sort_by(|a, b| a.nanos.total_cmp(&b.nanos)); + let median = samples[samples.len() / 2]; + by_class.push(Measurement { + producer, + consumer, + placement: classify(producer, consumer), + strategy, + nanos_per_item: median.nanos / ITEMS as f64, + consumer_batch: ITEMS as f64 / median.consumer_refreshes.max(1) as f64, + producer_batch: ITEMS as f64 / median.producer_refreshes.max(1) as f64, + }); + } + } + + Ok(Observation { + processors, + by_class, + measurements, + }) +} + +impl Observation { + /// The measurement for one placement and strategy, if it was taken. + #[must_use] + pub fn get(&self, placement: Placement, strategy: Strategy) -> Option { + self.measurements + .iter() + .find(|m| m.placement == placement && m.strategy == strategy) + .copied() + } + + /// Which placements this machine could express. + #[must_use] + pub fn placements(&self) -> Vec { + let mut seen: Vec = self.measurements.iter().map(|m| m.placement).collect(); + seen.sort_unstable(); + seen.dedup(); + seen + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-platform-probes/src/core_affinity/tests.rs b/crates/windows-platform-probes/src/core_affinity/tests.rs new file mode 100644 index 00000000..f7d1d6a7 --- /dev/null +++ b/crates/windows-platform-probes/src/core_affinity/tests.rs @@ -0,0 +1,160 @@ +// Copyright (c) Mike Grier. + +//! Tests for the placement classifier. +//! +//! These cover the pure logic -- classification and pair selection -- and +//! deliberately not the measurement, which needs two real cores and several +//! seconds. What is worth testing here is that the probe cannot silently +//! mislabel a pair, because every conclusion it prints is keyed on that label. + +use super::{Placement, ProcessorPlace, classify, representative_pairs}; + +fn place(number: u8, efficiency_class: u8, cache_domain: Option) -> ProcessorPlace { + ProcessorPlace { + number, + efficiency_class, + cache_domain, + } +} + +#[test] +fn same_cache_and_same_class_is_classified_as_such() { + let a = place(0, 1, Some(0)); + let b = place(1, 1, Some(0)); + assert_eq!(classify(a, b), Placement::SameCacheSameClass); +} + +#[test] +fn a_differing_class_alone_is_cross_class() { + let a = place(0, 1, Some(0)); + let b = place(1, 0, Some(0)); + assert_eq!(classify(a, b), Placement::SameCacheCrossClass); +} + +#[test] +fn a_differing_cache_alone_is_cross_cache() { + let a = place(0, 1, Some(0)); + let b = place(6, 1, Some(1)); + assert_eq!(classify(a, b), Placement::CrossCacheSameClass); +} + +#[test] +fn differing_in_both_is_cross_cross() { + let a = place(0, 1, Some(0)); + let b = place(6, 0, Some(1)); + assert_eq!(classify(a, b), Placement::CrossCacheCrossClass); +} + +#[test] +fn classification_is_symmetric_in_the_two_ends() { + // The placement describes a relationship, so naming which end produces must + // not change it. The measurement may well differ by direction -- a fast + // producer feeding a slow consumer is not the same experiment as the + // reverse -- but that is a difference in the result, not in the label. + let a = place(0, 1, Some(0)); + let b = place(6, 0, Some(1)); + assert_eq!(classify(a, b), classify(b, a)); +} + +#[test] +fn a_machine_with_no_partitioning_cache_still_classifies() { + // Both `None`, which compares equal: a machine whose caches do not divide + // it has every pair in the "same cache" category rather than in none. + let a = place(0, 1, None); + let b = place(1, 0, None); + assert_eq!(classify(a, b), Placement::SameCacheCrossClass); +} + +#[test] +fn a_homogeneous_single_cache_machine_offers_only_one_placement() { + let places: Vec<_> = (0..4).map(|n| place(n, 0, Some(0))).collect(); + let pairs = representative_pairs(&places); + + assert_eq!( + pairs.keys().copied().collect::>(), + vec![Placement::SameCacheSameClass], + "a machine that cannot express a placement must report it absent, not \ + fabricate one" + ); +} + +#[test] +fn a_heterogeneous_two_cache_machine_offers_all_four() { + // A machine whose classes and caches cut differently, which is exactly what + // the development host is NOT -- there the two coincide and only two of the + // four placements exist. This case is what the probe would need to separate + // the cache effect from the core-speed one. + let mut places = Vec::new(); + let mut number = 0_u8; + for cache in 0..2_u32 { + for class in 0..2_u8 { + for _ in 0..2 { + places.push(place(number, class, Some(cache))); + number += 1; + } + } + } + let pairs = representative_pairs(&places); + + assert_eq!(pairs.len(), 4, "all four placements must be expressible"); + for (placement, (producer, consumer)) in pairs { + assert_eq!( + classify(producer, consumer), + placement, + "the pair chosen for a placement must actually be that placement" + ); + } +} + +#[test] +fn a_machine_whose_classes_follow_its_caches_offers_only_two() { + // The development host's shape: processors 0-5 are class 0 in cache domain + // 0, and 6-11 are class 1 in cache domain 1. The two factors are perfectly + // confounded, so the probe must report the mixed placements as absent + // rather than inventing a pair for them -- "this host cannot test that" and + // "that made no difference" are opposite findings. + let mut places = Vec::new(); + for number in 0..12_u8 { + let side = u32::from(number) / 6; + places.push(place(number, side as u8, Some(side))); + } + let pairs = representative_pairs(&places); + + let mut found: Vec<_> = pairs.keys().copied().collect(); + found.sort_unstable(); + assert_eq!( + found, + vec![ + Placement::SameCacheSameClass, + Placement::CrossCacheCrossClass + ], + "confounded classes and caches must yield exactly the two pure placements" + ); +} + +#[test] +fn a_pair_never_puts_both_ends_on_one_processor() { + let places: Vec<_> = (0..4) + .map(|n| place(n, n % 2, Some(u32::from(n) / 2))) + .collect(); + for (_, (producer, consumer)) in representative_pairs(&places) { + assert_ne!( + producer.number, consumer.number, + "a queue with both ends on one core measures scheduling, not coherence" + ); + } +} + +#[test] +fn every_expressible_placement_is_chosen_exactly_once() { + let places: Vec<_> = (0..8) + .map(|n| place(n, n % 2, Some(u32::from(n) / 4))) + .collect(); + let pairs = representative_pairs(&places); + + let mut labels: Vec<_> = pairs.keys().map(|p| p.label()).collect(); + labels.sort_unstable(); + let before = labels.len(); + labels.dedup(); + assert_eq!(before, labels.len(), "no placement may be measured twice"); +} diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index 19115ee9..6c77d07a 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -111,6 +111,7 @@ pub mod cancel_io; pub mod completion_port; +pub mod core_affinity; pub mod device_map; pub mod doorbell_cost; pub mod error_mode; diff --git a/crates/windows-platform-probes/src/peer_index_cache.rs b/crates/windows-platform-probes/src/peer_index_cache.rs index 0d4c2923..5c9cc6d9 100644 --- a/crates/windows-platform-probes/src/peer_index_cache.rs +++ b/crates/windows-platform-probes/src/peer_index_cache.rs @@ -55,6 +55,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use std::time::Instant; +use windows_sys::Win32::System::Threading::{GetCurrentThread, SetThreadAffinityMask}; use windows_waitable_queues::spsc; /// Items handed across the ring in one timed run. @@ -149,10 +150,13 @@ pub fn measure() -> Observation { /// One timed pass, with the shared-read counts that pass performed. #[derive(Debug, Clone, Copy)] -struct Sample { - nanos: f64, - consumer_refreshes: u64, - producer_refreshes: u64, +pub struct Sample { + /// Wall-clock nanoseconds for the whole pass. + pub nanos: f64, + /// How many times the consumer read the producer's shared position. + pub consumer_refreshes: u64, + /// How many times the producer read the consumer's shared position. + pub producer_refreshes: u64, } fn median(label: &'static str, mut timer: impl FnMut() -> Sample) -> Run { @@ -245,10 +249,36 @@ impl Ring { } fn time_model(strategy: Strategy) -> Sample { + time_model_on(strategy, None, None) +} + +/// One run of the model, optionally with each side pinned to a chosen +/// processor. +/// +/// The affinity arguments exist so a caller can ask where the two threads run +/// rather than accept wherever the scheduler puts them. That turned out to +/// matter: this probe's headline result inverted between two hosts, and the +/// mechanism -- how deeply the two sides batch -- is a property of how they are +/// placed relative to each other, which an unpinned run leaves to chance and +/// cannot report. +/// +/// `None` leaves a side unconstrained, which is what the unpinned entry points +/// pass and is deliberately not the same thing as pinning it to every +/// processor: an unconstrained thread can migrate mid-run. +pub fn time_model_on( + strategy: Strategy, + producer_cpu: Option, + consumer_cpu: Option, +) -> Sample { let ring = Ring::new(CAPACITY); let started = Instant::now(); let (consumer_refreshes, producer_refreshes) = thread::scope(|scope| { - let producer = scope.spawn(|| produce(&ring, strategy)); + let shared = ˚ + let producer = scope.spawn(move || { + pin_current_thread(producer_cpu); + produce(shared, strategy) + }); + pin_current_thread(consumer_cpu); let consumer_refreshes = consume(&ring, strategy); let producer_refreshes = producer.join().expect("the producer must not panic"); (consumer_refreshes, producer_refreshes) @@ -260,6 +290,28 @@ fn time_model(strategy: Strategy) -> Sample { } } +/// Confine the calling thread to one logical processor. +/// +/// Panics rather than warns on failure. A silently unpinned thread would turn +/// a placement experiment into a measurement of the scheduler's preferences, +/// and the run would still print a confident number -- the same failure mode as +/// a probe that asserts its conclusion. +fn pin_current_thread(cpu: Option) { + let Some(cpu) = cpu else { + return; + }; + assert!(cpu < 64, "this probe assumes a single processor group"); + let mask: usize = 1 << cpu; + // SAFETY: sets this thread's affinity to a mask with one bit set, for a + // processor the caller took from the discovered topology. + let previous = unsafe { SetThreadAffinityMask(GetCurrentThread(), mask) }; + assert!( + previous != 0, + "SetThreadAffinityMask failed for processor {cpu}: {}", + std::io::Error::last_os_error() + ); +} + /// Fills the ring, returning how many times it read the consumer's position. fn produce(ring: &Ring, strategy: Strategy) -> u64 { // The producer's local copy of the consumer's position. Plain, not atomic: From 9787871bb743650ee57a94763cb6259761e0329d Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Mon, 31 Aug 2026 10:37:44 -0400 Subject: [PATCH 037/361] feat(probes): fingerprint the host and the slice each measurement ran on Asked whether the x64 host had uniform cache domains, the answer could not be recovered. The only record of that machine anywhere in the repository is the prose fragment "AMD EPYC 7763 (8C/16T, x64)" in one checklist line -- enough to identify the part, not enough to say which placements it could express. Every performance number here is a fact about a machine, and none of them said which. Two things are now recorded, and both are needed: host: aarch64 12p/12c smt- L2[6,6] ec[0:6,1:6] numa[12] slice: pinned prod=cpu0/core0/ec0/cd2 cons=cpu6/core6/ec1/cd15 [cross-cache,cross-class] The host says which experiments a machine CAN express. The slice says which one this number came from -- and on this host those differ by 5.6x, so a figure labelled only with the host is still ambiguous. The slice is carried on each Measurement rather than printed once in a banner, because a table of rows from different slices is precisely the thing that misleads. An unpinned run renders as "unpinned Nthr (scheduler-placed, not reproducible)" rather than fabricating a slice it does not have. "These threads ran here" and "these threads ran wherever Windows put them" are different claims, and only the first supports comparison between runs. The format is canonical and omits clock speeds, cache sizes and model names: those vary without changing which experiments are possible, and a fingerprint that changes when the answer does not is one nobody can compare. A test asserts that every field which changes the answer changes the string. A LATENT DEFECT THIS SURFACED. probe-core-affinity had no concept of SMT siblings. Two processors on one core have different processor numbers, so they would have been measured and then classified as SameCacheSameClass -- indistinguishable from two separate cores behind one cache. On a non-SMT host that is harmless and it is why it went unnoticed; on the 8C/16T x64 host it would have silently conflated the tightest coupling the machine offers with an ordinary one, in the probe whose entire purpose is to distinguish couplings. SameCoreSiblings is now its own placement, tested first because sharing a core dominates any cache or class the pair also shares, and reported inexpressible on a machine without SMT rather than merged into another category. The per-efficiency-class comparison likewise now requires different CORES, not merely different processors, or one class could be measured as siblings and the other as two cores and the comparison would be measuring placement instead. ProcessorPlace moves from core_affinity to fingerprint: where a processor sits is a fact about the host, not about one experiment, and both now read it from a single discovery routine that applies the same "outermost level that partitions" rule as the fingerprint itself, so the two cannot disagree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/bin/core_affinity.rs | 8 + .../src/bin/peer_index_cache.rs | 1 + .../src/bin/queue_contention.rs | 1 + .../src/core_affinity.rs | 124 ++---- .../src/core_affinity/tests.rs | 79 +++- .../src/fingerprint.rs | 409 ++++++++++++++++++ .../src/fingerprint/tests.rs | 160 +++++++ crates/windows-platform-probes/src/lib.rs | 1 + 8 files changed, 699 insertions(+), 84 deletions(-) create mode 100644 crates/windows-platform-probes/src/fingerprint.rs create mode 100644 crates/windows-platform-probes/src/fingerprint/tests.rs diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index 7414afd5..8fd4ec74 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -6,6 +6,7 @@ use windows_platform_probes::core_affinity::{Placement, measure}; use windows_platform_probes::peer_index_cache::Strategy; fn main() -> std::io::Result<()> { + windows_platform_probes::fingerprint::print_banner(); println!("== does it matter where the two ends of a queue run? ==\n"); let observation = measure()?; @@ -135,6 +136,13 @@ fn main() -> std::io::Result<()> { ); } + println!("\nthe slice each row was measured on:"); + for placement in all { + if let Some(base) = observation.get(placement, Strategy::Baseline) { + println!(" {:<26} {}", placement.label(), base.slice); + } + } + println!("\ninterpretation:\n"); let expressible = observation.placements(); diff --git a/crates/windows-platform-probes/src/bin/peer_index_cache.rs b/crates/windows-platform-probes/src/bin/peer_index_cache.rs index 3d5705fd..43c161e8 100644 --- a/crates/windows-platform-probes/src/bin/peer_index_cache.rs +++ b/crates/windows-platform-probes/src/bin/peer_index_cache.rs @@ -9,6 +9,7 @@ use windows_platform_probes::peer_index_cache::{CAPACITY, ITEMS, Strategy, measure}; fn main() { + windows_platform_probes::fingerprint::print_banner(); println!("== what does caching the peer's index buy an SPSC ring? ==\n"); let observation = measure(); diff --git a/crates/windows-platform-probes/src/bin/queue_contention.rs b/crates/windows-platform-probes/src/bin/queue_contention.rs index 668eea04..272a4782 100644 --- a/crates/windows-platform-probes/src/bin/queue_contention.rs +++ b/crates/windows-platform-probes/src/bin/queue_contention.rs @@ -13,6 +13,7 @@ use windows_platform_probes::queue_contention::{PRODUCER_COUNTS, Run, measure}; fn main() { + windows_platform_probes::fingerprint::print_banner(); println!("== does the array queue's tail claim contend? ==\n"); let observation = measure(); diff --git a/crates/windows-platform-probes/src/core_affinity.rs b/crates/windows-platform-probes/src/core_affinity.rs index efbdceba..c3ade7e4 100644 --- a/crates/windows-platform-probes/src/core_affinity.rs +++ b/crates/windows-platform-probes/src/core_affinity.rs @@ -64,8 +64,7 @@ use std::collections::BTreeMap; -use windows_topology_sys::{DomainKind, Topology}; - +use crate::fingerprint::{ProcessorPlace, Slice, discover_places}; use crate::peer_index_cache::{ITEMS, Strategy, time_model_on}; /// Repetitions per placement; the median is reported. @@ -73,26 +72,24 @@ use crate::peer_index_cache::{ITEMS, Strategy, time_model_on}; /// Odd, so the median is an observation rather than an average of two. const REPETITIONS: usize = 3; -/// One cache domain: its id, and the processors behind it. -type CacheDomain = (u32, Vec); - -/// One logical processor's position in the machine. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ProcessorPlace { - /// Its number within the (single) processor group. - pub number: u8, - /// Windows's efficiency class. Higher is faster; the values themselves are - /// only meaningful relative to each other on the same machine. - pub efficiency_class: u8, - /// Which last-level-that-partitions cache domain it sits behind, or `None` - /// if the machine reports no cache level that divides it. - pub cache_domain: Option, -} - /// How a producer and a consumer are placed relative to each other. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Placement { - /// Same cache domain, same efficiency class. + /// Two SMT siblings: the same physical core, sharing L1. + /// + /// Listed first because it is the tightest coupling a machine can offer, + /// and kept distinct from `SameCacheSameClass` for a measured reason: on an + /// SMT host a sibling pair and a two-core pair behind one cache would + /// otherwise land in the same bucket, and the probe would report whichever + /// it happened to select. That is precisely the distinction needed to + /// explain why peer-index caching loses on an SMT x64 host and wins on a + /// non-SMT ARM64 one -- siblings sharing L1 have every reason to stay in + /// lockstep, which is the shallow-batch condition that makes caching lose. + /// + /// Absent on a machine without SMT, where it is reported inexpressible + /// rather than merged into another category. + SameCoreSiblings, + /// Same cache domain, same efficiency class, but different physical cores. SameCacheSameClass, /// Same cache domain, different efficiency class. SameCacheCrossClass, @@ -107,6 +104,7 @@ impl Placement { #[must_use] pub fn label(self) -> &'static str { match self { + Self::SameCoreSiblings => "SMT siblings (one core)", Self::SameCacheSameClass => "same cache, same class", Self::SameCacheCrossClass => "same cache, cross class", Self::CrossCacheSameClass => "cross cache, same class", @@ -116,8 +114,14 @@ impl Placement { } /// One placement measured under one strategy. -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub struct Measurement { + /// Exactly which processors this number came from. + /// + /// Carried on the measurement rather than printed once in a banner: a + /// table of numbers from different slices is the thing that misleads, and + /// the only defence is for each row to know its own provenance. + pub slice: Slice, /// Which processor produced. pub producer: ProcessorPlace, /// Which processor consumed. @@ -161,68 +165,14 @@ pub struct Observation { pub measurements: Vec, } -/// Discover where each logical processor sits. -/// -/// # Errors -/// -/// Returns whatever [`Topology::discover`] failed with. -pub fn discover_places() -> std::io::Result> { - let topology = Topology::discover()?; - - let mut class_of: BTreeMap = BTreeMap::new(); - for core in topology.cores() { - let DomainKind::Core { - efficiency_class, .. - } = core.kind - else { - continue; - }; - for (_group, number) in core.processors.iter() { - class_of.insert(number, efficiency_class); - } - } - - // The outermost cache level that actually divides the machine. Keyed on - // "the level that partitions" rather than literally on L3, because this - // machine reports no L3 at all and a rule naming L3 would find nothing. - let mut best: Option> = None; - for level in 1..=4_u8 { - let domains: Vec = topology - .caches_at_level(level) - .map(|domain| { - ( - domain.id, - domain.processors.iter().map(|(_, number)| number).collect(), - ) - }) - .collect(); - if domains.len() > 1 { - best = Some(domains); - } - } - - let mut cache_of: BTreeMap = BTreeMap::new(); - if let Some(domains) = &best { - for (id, processors) in domains { - for number in processors { - cache_of.insert(*number, *id); - } - } - } - - Ok(class_of - .into_iter() - .map(|(number, efficiency_class)| ProcessorPlace { - number, - efficiency_class, - cache_domain: cache_of.get(&number).copied(), - }) - .collect()) -} - /// Classify a pair. #[must_use] pub fn classify(producer: ProcessorPlace, consumer: ProcessorPlace) -> Placement { + // Tested first: two processors on one core share L1, which dominates any + // statement about the cache domain or the class they also share. + if producer.core == consumer.core { + return Placement::SameCoreSiblings; + } let same_cache = producer.cache_domain == consumer.cache_domain; let same_class = producer.efficiency_class == consumer.efficiency_class; match (same_cache, same_class) { @@ -247,8 +197,10 @@ pub fn representative_pairs( for producer in places { for consumer in places { if producer.number == consumer.number { - // No SMT here, and in any case a queue whose two ends share one - // core measures scheduling, not coherence. + // One processor cannot be both ends: that measures the + // scheduler time-slicing a thread against itself. Two + // processors on one *core* are a different matter entirely and + // are measured, as `Placement::SameCoreSiblings`. continue; } chosen @@ -278,6 +230,7 @@ pub fn measure() -> std::io::Result { let median = samples[samples.len() / 2]; measurements.push(Measurement { + slice: Slice::pair(producer, consumer), producer, consumer, placement, @@ -304,7 +257,11 @@ pub fn measure() -> std::io::Result { let Some((producer, consumer)) = members .iter() .flat_map(|a| members.iter().map(move |b| (**a, **b))) - .find(|(a, b)| a.number != b.number && a.cache_domain == b.cache_domain) + // Different cores, not merely different processors: on an SMT host + // one class might otherwise be measured as siblings and the other + // as two cores, and the comparison between classes would be + // measuring the placement difference instead. + .find(|(a, b)| a.core != b.core && a.cache_domain == b.cache_domain) else { continue; }; @@ -315,6 +272,7 @@ pub fn measure() -> std::io::Result { samples.sort_by(|a, b| a.nanos.total_cmp(&b.nanos)); let median = samples[samples.len() / 2]; by_class.push(Measurement { + slice: Slice::pair(producer, consumer), producer, consumer, placement: classify(producer, consumer), @@ -340,7 +298,7 @@ impl Observation { self.measurements .iter() .find(|m| m.placement == placement && m.strategy == strategy) - .copied() + .cloned() } /// Which placements this machine could express. diff --git a/crates/windows-platform-probes/src/core_affinity/tests.rs b/crates/windows-platform-probes/src/core_affinity/tests.rs index f7d1d6a7..f908b104 100644 --- a/crates/windows-platform-probes/src/core_affinity/tests.rs +++ b/crates/windows-platform-probes/src/core_affinity/tests.rs @@ -7,11 +7,29 @@ //! seconds. What is worth testing here is that the probe cannot silently //! mislabel a pair, because every conclusion it prints is keyed on that label. -use super::{Placement, ProcessorPlace, classify, representative_pairs}; +use super::{Placement, classify, representative_pairs}; +use crate::fingerprint::ProcessorPlace; +/// A processor on its own physical core, which is the non-SMT case. fn place(number: u8, efficiency_class: u8, cache_domain: Option) -> ProcessorPlace { ProcessorPlace { number, + core: u32::from(number), + efficiency_class, + cache_domain, + } +} + +/// Two processors sharing one physical core: SMT siblings. +fn sibling( + number: u8, + core: u32, + efficiency_class: u8, + cache_domain: Option, +) -> ProcessorPlace { + ProcessorPlace { + number, + core, efficiency_class, cache_domain, } @@ -158,3 +176,62 @@ fn every_expressible_placement_is_chosen_exactly_once() { labels.dedup(); assert_eq!(before, labels.len(), "no placement may be measured twice"); } + +#[test] +fn smt_siblings_are_their_own_placement() { + // Two processors on one core share L1, which is a tighter coupling than + // any cache domain expresses. Before this was distinguished, a sibling pair + // and a two-core pair behind one cache landed in the same bucket, and the + // probe reported whichever it happened to select first -- on an SMT host, + // which is exactly where the distinction matters. + let a = sibling(0, 0, 0, Some(0)); + let b = sibling(1, 0, 0, Some(0)); + assert_eq!(classify(a, b), Placement::SameCoreSiblings); +} + +#[test] +fn siblings_outrank_the_cache_and_class_they_also_share() { + let a = sibling(0, 0, 1, Some(3)); + let b = sibling(1, 0, 1, Some(3)); + assert_ne!( + classify(a, b), + Placement::SameCacheSameClass, + "sharing a core must not be reported as merely sharing a cache" + ); +} + +#[test] +fn an_smt_host_expresses_a_placement_a_non_smt_host_cannot() { + // The 8C/16T homogeneous shape: two processors per core, one cache domain, + // one efficiency class. It can express exactly two placements, and one of + // them is unavailable on the non-SMT development host -- which is why the + // two machines' results are not directly comparable. + let mut places = Vec::new(); + for core in 0..8_u32 { + for lane in 0..2_u8 { + places.push(sibling(core as u8 * 2 + lane, core, 0, Some(0))); + } + } + let pairs = representative_pairs(&places); + + let mut found: Vec<_> = pairs.keys().copied().collect(); + found.sort_unstable(); + assert_eq!( + found, + vec![Placement::SameCoreSiblings, Placement::SameCacheSameClass], + "an SMT host must offer the sibling placement alongside the two-core one" + ); +} + +#[test] +fn a_non_smt_host_cannot_express_the_sibling_placement() { + let places: Vec<_> = (0..12) + .map(|n| place(n, u8::from(n >= 6), Some(u32::from(n) / 6))) + .collect(); + let pairs = representative_pairs(&places); + + assert!( + !pairs.contains_key(&Placement::SameCoreSiblings), + "a machine with one processor per core has no siblings to measure" + ); +} diff --git a/crates/windows-platform-probes/src/fingerprint.rs b/crates/windows-platform-probes/src/fingerprint.rs new file mode 100644 index 00000000..dfe3d67c --- /dev/null +++ b/crates/windows-platform-probes/src/fingerprint.rs @@ -0,0 +1,409 @@ +// Copyright (c) Mike Grier. + +//! A one-line description of the machine, printed by every probe that measures +//! something. +//! +//! # Why this exists +//! +//! Every performance number this crate produces is a fact about a machine, and +//! is close to meaningless without knowing which. That is not a hypothetical +//! concern here: `probe-peer-index-cache` gave opposite verdicts on two hosts, +//! and when the question "did the other machine even have more than one cache +//! domain?" was asked, the answer could not be recovered -- the only record of +//! that host was the prose fragment "AMD EPYC 7763 (8C/16T, x64)" in a +//! checklist. Enough to identify the part number, not enough to say which +//! placements it could express. +//! +//! So the fingerprint travels with the measurement rather than being something +//! a reader is trusted to have written down separately. +//! +//! # The format +//! +//! One line, canonical, and short enough to paste into a table or a commit +//! message: +//! +//! ```text +//! aarch64 12p/12c smt- L2[6,6] ec[0:6,1:6] numa[12] +//! x86_64 16p/8c smt+ L3[16] ec[0:16] numa[16] +//! ``` +//! +//! - `` -- the target architecture. +//! - `Np/Mc` -- N logical processors across M physical cores. +//! - `smt+` / `smt-` -- whether any core carries more than one processor. +//! Written explicitly rather than left to be inferred from `N != M`, because +//! it decides whether a whole class of placement exists. +//! - `L[a,b,...]` -- the outermost cache level that *partitions* the +//! machine, and how many processors sit behind each of its domains. +//! `L-[N]` when no cache level divides it. Keyed on "the level that +//! partitions" rather than literally on L3, because the development host +//! reports no L3 at all. +//! - `ec[class:count,...]` -- efficiency classes and their sizes. A single +//! entry means a homogeneous machine. +//! - `numa[...]` -- processors per NUMA node. +//! +//! **It is canonical**, so two hosts that render the same string can express +//! the same placements, and string equality is a usable comparison. It +//! deliberately omits clock speeds, cache sizes, and model names: those vary +//! without changing which experiments are possible, and a fingerprint that +//! changes when the answer does not is a fingerprint nobody can compare. + +use std::fmt; + +use windows_topology_sys::{DomainKind, Topology}; + +/// One logical processor's position in the machine. +/// +/// Lives here rather than with the affinity experiment because it is a fact +/// about the *host*, not about any one measurement: the slice below reports it, +/// and the placement classifier interprets it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ProcessorPlace { + /// Its number within the (single) processor group. + pub number: u8, + /// Which physical core it belongs to. + /// + /// Two processors sharing a core are SMT siblings, which is the tightest + /// coupling a machine can offer -- they share L1 outright. On a host + /// without SMT every core has exactly one processor and this only ever + /// distinguishes cores. + pub core: u32, + /// Windows's efficiency class. Higher is faster; the values themselves are + /// only meaningful relative to each other on the same machine. + pub efficiency_class: u8, + /// Which cache domain it sits behind, at the outermost level that + /// partitions the machine, or `None` if no level does. + pub cache_domain: Option, +} + +impl fmt::Display for ProcessorPlace { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "cpu{}/core{}/ec{}", + self.number, self.core, self.efficiency_class + )?; + match self.cache_domain { + Some(id) => write!(f, "/cd{id}"), + None => write!(f, "/cd-"), + } + } +} + +/// Which part of the machine an experiment actually ran on. +/// +/// # Why this is recorded beside the host +/// +/// The host fingerprint says which experiments a machine *can* express; it does +/// not say which one was run. Two measurements from the same host can differ by +/// 5.6x purely because of where their threads were placed, so a number labelled +/// only with the host is still ambiguous -- and an unpinned run is ambiguous +/// even against itself, because the scheduler is free to choose differently on +/// the next invocation. +/// +/// So an unpinned run says so, plainly, instead of rendering a slice it does +/// not actually have. That distinction is the point: "these threads ran here" +/// and "these threads ran wherever Windows put them" are different claims, and +/// only the first supports comparison between runs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Slice { + /// Threads were left to the scheduler, which may place them differently on + /// every invocation. + Unpinned { + /// How many threads participated, since that is all that is knowable. + threads: usize, + }, + /// Each named thread was confined to one processor. + Pinned { + /// `(role, where it ran)`, in the order the experiment defines. + participants: Vec<(&'static str, ProcessorPlace)>, + }, +} + +impl Slice { + /// A two-participant pinned slice, the common case. + #[must_use] + pub fn pair(producer: ProcessorPlace, consumer: ProcessorPlace) -> Self { + Self::Pinned { + participants: vec![("prod", producer), ("cons", consumer)], + } + } + + /// Whether every participant sits behind the same cache domain. + /// + /// `None` when the slice is unpinned, because the question has no answer + /// rather than a negative one. + #[must_use] + pub fn same_cache_domain(&self) -> Option { + let Self::Pinned { participants } = self else { + return None; + }; + let mut domains = participants.iter().map(|(_, place)| place.cache_domain); + let first = domains.next()?; + Some(domains.all(|domain| domain == first)) + } + + /// Whether every participant is of the same efficiency class. + /// + /// `None` when the slice is unpinned, for the same reason. + #[must_use] + pub fn same_efficiency_class(&self) -> Option { + let Self::Pinned { participants } = self else { + return None; + }; + let mut classes = participants.iter().map(|(_, place)| place.efficiency_class); + let first = classes.next()?; + Some(classes.all(|class| class == first)) + } +} + +impl fmt::Display for Slice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Unpinned { threads } => { + write!( + f, + "unpinned {threads}thr (scheduler-placed, not reproducible)" + ) + } + Self::Pinned { participants } => { + write!(f, "pinned")?; + for (role, place) in participants { + write!(f, " {role}={place}")?; + } + // The relationship, spelled out, so a reader does not have to + // compare the domain ids themselves. + let cache = match self.same_cache_domain() { + Some(true) => "same-cache", + Some(false) => "cross-cache", + None => "?-cache", + }; + let class = match self.same_efficiency_class() { + Some(true) => "same-class", + Some(false) => "cross-class", + None => "?-class", + }; + write!(f, " [{cache},{class}]") + } + } + } +} + +/// A machine's shape, in the terms that decide which placements exist. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fingerprint { + /// Target architecture, as `std::env::consts::ARCH` reports it. + pub arch: &'static str, + /// Logical processors. + pub processors: usize, + /// Physical cores. + pub cores: usize, + /// Whether any core carries more than one logical processor. + pub smt: bool, + /// The outermost cache level that divides the machine, if any. + pub partitioning_cache_level: Option, + /// Processors behind each domain of that cache level, ascending. + pub cache_domain_sizes: Vec, + /// `(efficiency class, processor count)`, ascending by class. + pub efficiency_classes: Vec<(u8, usize)>, + /// Processors per NUMA node, ascending. + pub numa_node_sizes: Vec, +} + +impl Fingerprint { + /// Read this machine's shape. + /// + /// # Errors + /// + /// Returns whatever [`Topology::discover`] failed with. + pub fn discover() -> std::io::Result { + let topology = Topology::discover()?; + + let cores: Vec<_> = topology.cores().collect(); + let processors: usize = cores.iter().map(|core| core.processors.len()).sum(); + let smt = cores.iter().any(|core| core.processors.len() > 1); + + let mut efficiency_classes: Vec<(u8, usize)> = Vec::new(); + for core in &cores { + let DomainKind::Core { + efficiency_class, .. + } = core.kind + else { + continue; + }; + let count = core.processors.len(); + match efficiency_classes + .iter_mut() + .find(|(class, _)| *class == efficiency_class) + { + Some((_, total)) => *total += count, + None => efficiency_classes.push((efficiency_class, count)), + } + } + efficiency_classes.sort_unstable(); + + // The outermost level that actually divides the machine. A level with + // one domain covers everything and partitions nothing. + let mut partitioning_cache_level = None; + let mut cache_domain_sizes = Vec::new(); + for level in 1..=4_u8 { + let sizes: Vec = topology + .caches_at_level(level) + .map(|domain| domain.processors.len()) + .collect(); + if sizes.len() > 1 { + partitioning_cache_level = Some(level); + cache_domain_sizes = sizes; + } + } + cache_domain_sizes.sort_unstable(); + if partitioning_cache_level.is_none() { + cache_domain_sizes = vec![processors]; + } + + let mut numa_node_sizes: Vec = topology + .memory_domains() + .map(|domain| domain.processors.len()) + .filter(|size| *size > 0) + .collect(); + numa_node_sizes.sort_unstable(); + + Ok(Self { + arch: std::env::consts::ARCH, + processors, + cores: cores.len(), + smt, + partitioning_cache_level, + cache_domain_sizes, + efficiency_classes, + numa_node_sizes, + }) + } + + /// Whether this machine is heterogeneous. + #[must_use] + pub fn heterogeneous(&self) -> bool { + self.efficiency_classes.len() > 1 + } + + /// Whether any cache level divides this machine. + #[must_use] + pub fn partitioned(&self) -> bool { + self.cache_domain_sizes.len() > 1 + } +} + +impl fmt::Display for Fingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {}p/{}c smt{}", + self.arch, + self.processors, + self.cores, + if self.smt { '+' } else { '-' } + )?; + + match self.partitioning_cache_level { + Some(level) => write!(f, " L{level}[")?, + None => write!(f, " L-[")?, + } + for (index, size) in self.cache_domain_sizes.iter().enumerate() { + if index > 0 { + write!(f, ",")?; + } + write!(f, "{size}")?; + } + write!(f, "] ec[")?; + for (index, (class, count)) in self.efficiency_classes.iter().enumerate() { + if index > 0 { + write!(f, ",")?; + } + write!(f, "{class}:{count}")?; + } + write!(f, "] numa[")?; + for (index, size) in self.numa_node_sizes.iter().enumerate() { + if index > 0 { + write!(f, ",")?; + } + write!(f, "{size}")?; + } + write!(f, "]") + } +} + +/// Discover where each logical processor sits. +/// +/// # Errors +/// +/// Returns whatever [`Topology::discover`] failed with. +pub fn discover_places() -> std::io::Result> { + let topology = Topology::discover()?; + + let mut class_of = std::collections::BTreeMap::new(); + let mut core_of = std::collections::BTreeMap::new(); + for core in topology.cores() { + for (_group, number) in core.processors.iter() { + core_of.insert(number, core.id); + } + let DomainKind::Core { + efficiency_class, .. + } = core.kind + else { + continue; + }; + for (_group, number) in core.processors.iter() { + class_of.insert(number, efficiency_class); + } + } + + // The outermost cache level that actually divides the machine, matching + // the fingerprint's own rule so the two cannot disagree. + let mut cache_of = std::collections::BTreeMap::new(); + for level in 1..=4_u8 { + let domains: Vec<_> = topology.caches_at_level(level).collect(); + if domains.len() > 1 { + cache_of.clear(); + for domain in domains { + for (_group, number) in domain.processors.iter() { + cache_of.insert(number, domain.id); + } + } + } + } + + Ok(class_of + .into_iter() + .map(|(number, efficiency_class)| ProcessorPlace { + number, + core: core_of.get(&number).copied().unwrap_or(u32::from(number)), + efficiency_class, + cache_domain: cache_of.get(&number).copied(), + }) + .collect()) +} + +/// Print the host fingerprint as a probe's first line, or say why it could not +/// be read. +/// +/// Never fails the probe: a measurement without a fingerprint is still worth +/// having, and is far better than one that refused to run. But it says so +/// loudly, because an unlabelled number is what this exists to prevent. +pub fn print_banner() { + match Fingerprint::discover() { + Ok(fingerprint) => println!("host: {fingerprint}"), + Err(error) => println!("host: UNKNOWN -- topology discovery failed: {error}"), + } +} + +/// Print the host fingerprint and the slice one measurement ran on. +/// +/// Both, always: the host says which experiments the machine can express, and +/// the slice says which one this number came from. Either alone leaves a +/// reader unable to tell whether two figures are comparable. +pub fn print_banner_with(slice: &Slice) { + print_banner(); + println!("slice: {slice}"); +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-platform-probes/src/fingerprint/tests.rs b/crates/windows-platform-probes/src/fingerprint/tests.rs new file mode 100644 index 00000000..e802085a --- /dev/null +++ b/crates/windows-platform-probes/src/fingerprint/tests.rs @@ -0,0 +1,160 @@ +// Copyright (c) Mike Grier. + +//! Tests for the fingerprint's rendering. +//! +//! The rendering is what makes two runs comparable, so it is what is tested: +//! a format that changes shape between hosts, or that renders two different +//! machines identically, would silently defeat the whole point. +//! +//! Discovery itself is not tested here -- it reads the real machine, so an +//! assertion about its output would be an assertion about whatever hardware +//! happens to run the suite. + +use super::Fingerprint; + +/// The development host: 12 cores, no SMT, two L2 domains, two efficiency +/// classes, one NUMA node. +fn arm64_dev_host() -> Fingerprint { + Fingerprint { + arch: "aarch64", + processors: 12, + cores: 12, + smt: false, + partitioning_cache_level: Some(2), + cache_domain_sizes: vec![6, 6], + efficiency_classes: vec![(0, 6), (1, 6)], + numa_node_sizes: vec![12], + } +} + +/// The shape an 8C/16T homogeneous x64 host is expected to take. +fn x64_smt_host() -> Fingerprint { + Fingerprint { + arch: "x86_64", + processors: 16, + cores: 8, + smt: true, + partitioning_cache_level: Some(3), + cache_domain_sizes: vec![16], + efficiency_classes: vec![(0, 16)], + numa_node_sizes: vec![16], + } +} + +#[test] +fn the_development_host_renders_as_expected() { + assert_eq!( + arm64_dev_host().to_string(), + "aarch64 12p/12c smt- L2[6,6] ec[0:6,1:6] numa[12]" + ); +} + +#[test] +fn an_smt_host_renders_as_expected() { + assert_eq!( + x64_smt_host().to_string(), + "x86_64 16p/8c smt+ L3[16] ec[0:16] numa[16]" + ); +} + +#[test] +fn smt_is_stated_rather_than_left_to_be_inferred() { + // `16p/8c` already implies SMT arithmetically, but the marker is what a + // reader scans for, and the arithmetic does not survive a machine with + // offline processors. + assert!(x64_smt_host().to_string().contains("smt+")); + assert!(arm64_dev_host().to_string().contains("smt-")); +} + +#[test] +fn the_two_hosts_do_not_render_identically() { + assert_ne!( + arm64_dev_host().to_string(), + x64_smt_host().to_string(), + "two machines that express different placements must be distinguishable" + ); +} + +#[test] +fn a_machine_no_cache_partitions_says_so() { + let flat = Fingerprint { + arch: "x86_64", + processors: 4, + cores: 4, + smt: false, + partitioning_cache_level: None, + cache_domain_sizes: vec![4], + efficiency_classes: vec![(0, 4)], + numa_node_sizes: vec![4], + }; + assert!( + flat.to_string().contains("L-[4]"), + "an undivided machine must render distinctly from a divided one, got {flat}" + ); + assert!(!flat.partitioned()); +} + +#[test] +fn heterogeneity_is_reported_from_the_classes() { + assert!(arm64_dev_host().heterogeneous()); + assert!(!x64_smt_host().heterogeneous()); +} + +#[test] +fn partitioning_is_reported_from_the_domain_count() { + assert!(arm64_dev_host().partitioned()); + assert!( + !x64_smt_host().partitioned(), + "one cache domain covering everything partitions nothing" + ); +} + +#[test] +fn rendering_is_stable_across_calls() { + // Canonical output is what lets two runs be compared by string equality, + // so nothing in the rendering may depend on iteration order or on time. + let host = arm64_dev_host(); + assert_eq!(host.to_string(), host.to_string()); +} + +#[test] +fn every_field_that_changes_the_answer_appears_in_the_render() { + // A fingerprint that omitted a field would render two genuinely different + // machines identically, which is worse than having no fingerprint: it + // would license a comparison that is not valid. + let base = arm64_dev_host(); + + let mut fewer_cores = base.clone(); + fewer_cores.cores = 6; + fewer_cores.processors = 6; + + let mut with_smt = base.clone(); + with_smt.smt = true; + + let mut different_cache = base.clone(); + different_cache.cache_domain_sizes = vec![4, 8]; + + let mut different_classes = base.clone(); + different_classes.efficiency_classes = vec![(0, 12)]; + + let mut different_numa = base.clone(); + different_numa.numa_node_sizes = vec![6, 6]; + + let mut different_level = base.clone(); + different_level.partitioning_cache_level = Some(3); + + for (name, variant) in [ + ("core count", fewer_cores), + ("smt", with_smt), + ("cache domains", different_cache), + ("efficiency classes", different_classes), + ("numa nodes", different_numa), + ("cache level", different_level), + ] { + assert_ne!( + base.to_string(), + variant.to_string(), + "changing {name} must change the fingerprint" + ); + } +} diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index 6c77d07a..309bd90c 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -115,6 +115,7 @@ pub mod core_affinity; pub mod device_map; pub mod doorbell_cost; pub mod error_mode; +pub mod fingerprint; pub mod handle_state; pub mod ioring; pub mod peer_index_cache; From 08a85294573690a8ec0f0c39584e75f289a9a253 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 10:55:06 -0400 Subject: [PATCH 038/361] fix(probes): show the SMT sibling row, and record that placement flips D-28 probe-core-affinity printed its placement table from a hard-coded list of four variants that omitted SameCoreSiblings, while the interpretation beneath it iterated over the placements actually measured. On an SMT host the table showed the sibling row as absent while the interpretation quoted a number for it -- and on this host that row is the single most important one. Also makes the "near vs far" summary fall back to the sibling pair, since a host whose outermost partitioning cache is per-core has no same-cache-different-core pair and would otherwise print nothing. With the row visible, the x64 measurement answers the question ARM64 could not ask, and refutes the stated hypothesis backwards. The prediction was that SMT siblings sharing L1 stay in lockstep, giving the shallow batches that make caching lose. They do the opposite: siblings produce the deepest batches on this host (116-163) and caching WINS 1.8x there, while the cross-core row is the shallow one (1.7) where caching LOSES 2.0x. So the verdict flips inside one machine, and the earlier framing that "x64 keeps the threads lock-step; ARM64 lets them decouple" attributed to the instruction set something that is a property of placement. Unpinned threads land across cores -- exactly the losing row -- so D-28's original result was one placement reported as though it were the machine. Unified rule both hosts obey: caching wins when the cost of the shared read times the reads saved exceeds the cost of idling on a stale bound. Both terms move with placement, which is why neither alone ever explained it. This host also separates cache domain from core class, which ARM64 cannot: one L3 domain, one efficiency class, eight L2 domains, so its cross-cache same-class row varies the cache alone (1.8-2.0x). Two corrections follow from that and are swept here: M-inf.3 said no such host existed, and M-inf.4 said the disagreement was not explained by placement. Completed item: none -- this is M31.7's x64 half plus a probe defect found while running it. Swept D-28's claim across 3 files: the decision-table row still carried the withdrawn blanket rejection with no supersedence marker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 41 ++++++++--- .../src/bin/core_affinity.rs | 12 +++- .../windows-waitable-queues/DESIGN-NOTES.md | 68 ++++++++++++++++++- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 6a68c6d3..bb8ecaec 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -614,9 +614,14 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio the one M30's design deferred on the grounds that N=1 does not need it -- and the number to carry into that decision is 5.6x, not zero. Gated on the domain runtime existing (M33+.1), not on more measurement. - **Do not read the 5.6x as a cache effect or as a core-speed effect.** On this machine the efficiency - classes and cache domains coincide exactly, so the two are perfectly confounded; separating them needs - a host whose classes and caches cut differently, which we do not have. + **Do not read the 5.6x as a cache effect or as a core-speed effect.** On the ARM64 machine the + efficiency classes and cache domains coincide exactly, so the two are perfectly confounded. + **We now do have a host that separates them.** The x64 host has one L3 domain and one efficiency + class across eight L2 domains, so its `cross cache, same class` row varies the cache domain alone, + with class, package, L3 and NUMA held constant: the isolated cache-crossing cost there is + **1.8x - 2.0x** on the unoptimised handoff. That is not the same 5.6x boundary -- it is a shallower + crossing (L2 inside a shared L3, not a cluster-to-cluster hop) -- so it bounds rather than + decomposes the ARM64 number. Do not subtract one from the other. - [ ] **M-inf.4** -- Peer-index caching in the head-based shapes, and more importantly **a policy for an optimisation whose sign depends on the host.** Gated on that policy, not on more measurement -- we @@ -638,10 +643,26 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio Whichever is chosen, it must be stated as a *policy* the crate owns rather than as a fact about a processor -- see PLATFORM INTEGRITY: this is exactly a lower baseline that must not be quietly dropped because the machine on the desk today prefers the other answer. - **One candidate explanation has already been tested and eliminated.** `probe-core-affinity` was written - to check whether the host difference was really a *placement* difference -- whether mismatched core - speeds on a heterogeneous machine decouple the two threads and manufacture the deep batch caching - needs. It does not: caching wins at **both** placements on the ARM64 host (14.4x within a domain, 3.0x - across), and threads placed together batch ~135x *deeper* than threads placed apart, which is the - opposite of the prediction. So the x64/ARM64 disagreement is not explained by where the threads run, - and this item cannot be closed by appealing to placement. + **Placement was tested on both hosts, and the picture is now complete.** `probe-core-affinity` was + written to check whether the host difference was really a *placement* difference. On ARM64 alone it + looked eliminated: caching wins at **both** placements there (14.4x within a domain, 3.0x across), and + threads placed together batch ~135x *deeper* than threads placed apart. Running it on x64 changed the + answer -- **the verdict flips inside that single machine**: pinned to SMT siblings, caching WINS 1.8x + at a batch depth of 116-163; pinned across cores it LOSES 2.0x at a depth of 1.7. Unpinned threads + land across cores, which is precisely the losing row, so D-28's original result was one placement + reported as though it were the machine. + The unified rule both hosts obey: caching wins when `(cost of the shared read) x (reads saved)` + exceeds the cost of idling on a stale bound. **Both terms are placement-dependent, which is why one + term alone never explained it.** ARM64 wins even at depth ~0.4 because the read it saves is genuinely + expensive (215 ns baseline); x64 loses at a similar depth because its cross-core read is cheap + (19-21 ns -- crossing L2 while staying inside one L3, one package, one NUMA node, one efficiency + class). So the sign is predictable from the two terms, and this item's policy question is unchanged + but now better posed: **the knob is placement, not architecture**, and any policy keyed to the + instruction set would be keyed to the wrong variable. + **What the x64 host contributed that ARM64 could not.** ARM64's cache domains and efficiency classes + are perfectly confounded (see M-inf.3's caution at the top of this section). The x64 host has one L3 + domain, one efficiency class, and eight L2 domains, so its `cross cache, same class` row varies only + the cache domain -- the isolated cache-crossing cost is **1.8x - 2.0x**. It cannot express + `same cache, same class` at all, because its outermost partitioning cache is L2 and is shared by + exactly the two siblings of one core. The two hosts are complementary; neither alone produces the + full table, and M-inf.3's "we do not have such a host" caution should be read against that. diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index 8fd4ec74..462e27ec 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -98,7 +98,12 @@ fn main() -> std::io::Result<()> { "placement", "prod", "cons", "base ns/it", "cached ns/it", "base depth", "cach depth" ); + // Every variant, tightest coupling first. `SameCoreSiblings` MUST be here: + // it is the placement the caching hypothesis is about, and on an SMT host it + // is where the interesting result lives. Omitting it once already produced a + // table that disagreed with the interpretation printed directly beneath it. let all = [ + Placement::SameCoreSiblings, Placement::SameCacheSameClass, Placement::SameCacheCrossClass, Placement::CrossCacheSameClass, @@ -259,8 +264,13 @@ fn main() -> std::io::Result<()> { } // The plainest answer to "does placement matter", independent of caching. + // "Near" falls back to SMT siblings, because a host whose outermost + // partitioning cache is per-core has no same-cache-different-core pair at + // all -- its nearest expressible placement IS the sibling pair. if let (Some(near), Some(far)) = ( - observation.get(Placement::SameCacheSameClass, Strategy::Baseline), + observation + .get(Placement::SameCacheSameClass, Strategy::Baseline) + .or_else(|| observation.get(Placement::SameCoreSiblings, Strategy::Baseline)), observation .get(Placement::CrossCacheCrossClass, Strategy::Baseline) .or_else(|| observation.get(Placement::CrossCacheSameClass, Strategy::Baseline)), diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index e32f68b6..4a3a304b 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -51,7 +51,7 @@ preferred. | D-25 | **`Observable` deliberately does not restate depth.** [D-2](#d-2)'s sketch listed it, but `Bounded::len` already reports it from positions the queue keeps anyway. Naming it twice would give one number two spellings and two places to drift. What belongs on `Observable` is only what must be *accumulated*. | | D-26 | **Measured: the tail claim contends badly, and `reserving_mpsc` is up to 4x FASTER than `mpsc` under contention -- the opposite of what [D-16](#d-16) assumed.** Aggregate throughput *falls* as producers are added, for both shapes and far more than a bare contended atomic explains. D-16's premise, that reading the consumer's position makes the reserving shape the expensive one, is falsified everywhere except a single producer with a live consumer. | | D-27 | **The gap is intrinsic to Vyukov's sequence protocol, not a fixable flaw in `mpsc`'s retry loop.** Its producer must read a slot's sequence *before* claiming, and that slot marches through memory as the tail advances while other producers write it. Padding slots onto their own cache lines was tested and rejected: it recovers about a fifth at eight producers, for four times the memory, and leaves the shape still 2.8x slower. | -| D-28 | **Measured and rejected: caching the peer's index makes our SPSC ring slower, so no shape adopts it.** The technique is real and well documented, and it engaged as designed -- it cut the consumer's shared reads by 3.6x. It still cost about 1.8x throughput, because it trades freshness for fewer reads and our ring hovers near empty, where a stale bound makes each side idle on information it could have refreshed. A prefetch-only "warming" variant was measured as a control and changed nothing. | +| D-28 | **Amended -- the blanket rejection is withdrawn; the verdict depends on thread placement, and the open question is queued as [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M-inf.4.** Caching the peer's index was measured, and it engaged as designed. It cost ~1.8x on x64 with the threads across cores, and *won* 17x on ARM64 and 1.8x on x64 SMT siblings. Batch depth decides the sign, and batch depth is set by where the two threads are scheduled -- not by the architecture and not by our code. A prefetch-only "warming" control changed nothing on any host. | ## D-2: capabilities are sliced, not gathered @@ -900,6 +900,10 @@ decides the outcome, and batch depth is a property of how the two threads interl whether siblings share a core, how the scheduler places them -- not of our code. x64 kept them lock-step; ARM64 lets them decouple. +**That last sentence was itself too coarse, and the x64 host disproved it.** See +"[the flip is placement, not architecture](#d-28-placement)" below: pinned to SMT siblings, the same +x64 machine that produced the rejection reverses it. The variable is placement, not instruction set. + Two consequences, and the second is the uncomfortable one: - The blanket rule **"no shape adopts it"** does not follow from the evidence any more. It is now a @@ -937,3 +941,65 @@ accurate when the answer was a flat rejection and is not accurate now: the open [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M-inf.4. It is recorded so the technique is neither re-proposed without measurement nor adopted on the strength of whichever host someone happened to benchmark on. + +### The flip is placement, not architecture -- and the sibling hypothesis was refuted backwards + +The ARM64 host asked the x64 host to test a specific prediction: **that SMT siblings sharing L1 would +stay in lockstep, giving shallow batches, and that this was the condition making caching lose.** ARM64 +has no SMT and physically cannot express that placement, so only the x64 host could answer it. + +`probe-core-affinity`, x64, medians of three runs (all three agreed to within 3%): + +| placement | base ns/item | cached ns/item | cached batch depth | verdict | +|---|---|---|---|---| +| SMT siblings (one core) | 10.7 | 5.9 - 6.0 | **116 - 163** | caching **WINS** 1.8x | +| same cache, same class | *not expressible* | | | | +| cross cache, same class | 19.3 - 21.8 | 38.7 - 42.1 | **1.7 - 1.8** | caching **LOSES** 2.0x | + +**The hypothesis is refuted, and refuted backwards.** Siblings do not stay in lockstep -- they produce +by far the *deepest* batches measured on this host, and caching wins there. The shallow batches are on +the *cross-core* row, which is where caching loses. Sharing a cache causes decoupling, not lockstep. + +This is the more important half of the finding: **the verdict flips inside a single machine.** The +earlier framing that "x64 keeps the threads lock-step and ARM64 lets them decouple" attributed to the +instruction set something that is a property of *placement*. The same x64 binary on the same x64 host +both wins and loses depending only on which two processors the threads land on. No decision keyed to +architecture can be correct, which is why the amended rule is placement-scoped. + +It also explains the original rejection without contradicting it. Unpinned threads land on separate +cores, which is exactly the losing row; re-running `probe-peer-index-cache` unpinned reproduces it +(baseline 18.4 - 23.8 ns, cached 35.0 - 38.8 ns). The first measurement was never wrong -- it was one +placement reported as though it were the machine. + +**Why the sign changes, unified across both hosts.** Caching wins when +`(cost of the shared read) x (reads saved)` exceeds the cost of idling on a stale bound. Both terms +move with placement: + +- *Siblings* share L1, so the handoff is cheap (10.7 vs 19.3 ns even with no caching). The two threads + interleave on one core's execution resources rather than running truly concurrently, so the producer + bursts ahead and the consumer drains deep batches. Deep batch, caching wins. +- *Across cores* the ring ping-pongs one item at a time (depth ~1.7) and, on this host, the read being + saved is cheap anyway -- crossing L2 while staying inside a shared L3, same package, same NUMA node, + same efficiency class. Little saved, staleness paid. Caching loses. +- *ARM64 across domains* saves a genuinely expensive read (215 ns baseline), so it wins 3.5x even at a + batch depth below 1. Cost per read, not just depth, is part of the trade. + +**The x64 host isolates the cache effect, which ARM64 could not.** ARM64's cache domains and core +classes are confounded -- crossing one crosses the other -- so it cannot separate "cache domain cost" +from "core speed cost". This host has a single L3 domain, a single efficiency class, and eight L2 +domains, so its `cross cache, same class` row varies *only* the cache domain, with class, package, +L3 and NUMA all held constant. That isolated crossing costs **1.8x - 2.0x** on the unoptimised +handoff. Conversely this host cannot express `same cache, same class` at all: its outermost +partitioning cache is L2, shared by exactly the two siblings of one core, so any two processors +sharing a cache domain are siblings. The two hosts are complementary rather than redundant, and +neither alone can produce the full table. + +**A probe defect found while doing this, now fixed.** `probe-core-affinity` printed its placement +table from a hard-coded list of four variants that omitted `SameCoreSiblings`, while the +interpretation beneath it iterated over the placements actually measured. On an SMT host the table +therefore showed the sibling row as absent while the interpretation quoted a number for it -- the +single most important row on this machine, silently missing from the table that was supposed to +report it. This is the second time in this investigation that an instrument's *presentation* rather +than its measurement nearly produced a wrong conclusion (the first was the fixed-prose interpretation +noted above). The fix also makes the "near vs far" summary fall back to the sibling pair on hosts +where `same cache, same class` is not expressible, which would otherwise have printed nothing here. From d00b48328a4d4b578333ebe9ebbc9427dcb836d5 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 11:08:58 -0400 Subject: [PATCH 039/361] docs: record that the two measured hosts are disjoint, not overlapping Checking whether ARM64 needed a re-run after the SMT-row fix turned up something worth writing down: the two hosts do not overlap on a single placement. ARM64 confounds cache domain with efficiency class, so it can only produce the two diagonal rows; the x64 slice has one efficiency class and one L3 with L2 shared only by SMT siblings, so it can only produce the other two. That means no row cross-checks another and every row rests on one machine -- which is exactly the single-host generalisation that produced the original D-28 error, now made visible as a coverage matrix rather than left implicit in prose saying the hosts are "complementary". It also names the row neither host can express: same cache, cross class needs heterogeneous cores inside one cache domain, and no re-run of either machine will produce it. Records what an Intel hybrid box would add, framed as predictions to falsify. Its value is not being a third architecture -- that framing is the one this investigation already falsified -- but that it should express four rows at once, and would be the first host to hold both SMT siblings and same cache, same class simultaneously. Those two rows currently sit on different machines, so "sharing a cache produces deep batches" is still confounded with the host it was measured on; one machine expressing both decouples them. No re-run of ARM64 is needed. classify() returns SameCoreSiblings only for two distinct processors on one core, which requires SMT, so the fixed row is structurally unreachable on a 12-core no-SMT host and the fix cannot change any number it produced. Completed item: none -- coverage analysis for the open M-inf.4 question. The matrix is kept in the checklist beside that question and referenced from D-28 rather than copied, so the two cannot drift. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 37 +++++++++++++++++++ .../windows-waitable-queues/DESIGN-NOTES.md | 5 +++ 2 files changed, 42 insertions(+) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index bb8ecaec..e605efd5 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -666,3 +666,40 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio `same cache, same class` at all, because its outermost partitioning cache is L2 and is shared by exactly the two siblings of one core. The two hosts are complementary; neither alone produces the full table, and M-inf.3's "we do not have such a host" caution should be read against that. + + **Coverage, stated explicitly, because the two hosts turn out to be disjoint rather than + overlapping.** Not one placement is measured by both machines, so no row is a cross-check of another + and every row rests on a single host: + + | placement | ARM64 (Snapdragon X2) | x64 (EPYC 7763 slice) | measured by | + |---|---|---|---| + | SMT siblings (one core) | not expressible (no SMT) | **yes** | x64 only | + | same cache, same class | **yes** | not expressible (L2 is per-core-pair) | ARM64 only | + | same cache, cross class | not expressible (confounded) | not expressible (one class) | **neither** | + | cross cache, same class | not expressible (confounded) | **yes** | x64 only | + | cross cache, cross class | **yes** | not expressible (one class) | ARM64 only | + + A machine cannot express a placement when its topology makes the pair impossible: ARM64 confounds + cache domain with efficiency class (crossing one crosses the other), and the x64 slice has exactly + one efficiency class and one L3 with L2 shared only by SMT siblings. + + `same cache, cross class` -- two *different* cores sharing a cache domain but differing in class -- + **is unmeasurable on either host, and no re-run of either will produce it.** It needs heterogeneous + cores inside one cache domain. + + **A third host is expected and is worth a run: an Intel hybrid dev box (P-cores with SMT, E-cores + without, clustered).** Its value is not "another architecture" -- that framing is the one this + investigation already falsified -- but that it should express **four** rows at once, and would be the + first machine to hold both `SMT siblings` and `same cache, same class` at the same time. That matters + because those two rows currently sit on different machines, so the claim that sharing a cache + produces deep batches is confounded with the host it was measured on. One machine expressing both + decouples them. + Predictions to falsify rather than confirm, since a hybrid's outermost *partitioning* cache is + probably still L2 (per-core on P, per-cluster on E): + - `SMT siblings` from a P-core pair, and `same cache, same class` from two E-cores in one L2 cluster. + - `cross cache, same class` from two P-cores, and `cross cache, cross class` from a P/E pair. + - `same cache, cross class` **probably still absent**, because P and E cores are unlikely to share + the outermost partitioning cache. If it does appear, that is the missing row and should be called + out loudly. + Run `probe-topology` first: whether that host's outermost partitioning cache is L2 or L3 determines + which rows exist at all, and it is not predictable from the part number. diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 4a3a304b..64f13c8d 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -994,6 +994,11 @@ partitioning cache is L2, shared by exactly the two siblings of one core, so any sharing a cache domain are siblings. The two hosts are complementary rather than redundant, and neither alone can produce the full table. +The two hosts are in fact **disjoint** -- no placement is measured by both -- so every row rests on a +single machine and none cross-checks another. The per-placement coverage matrix, including the one row +(`same cache, cross class`) that neither host can express, is kept with the open question in +[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M-inf.4 rather than duplicated here. + **A probe defect found while doing this, now fixed.** `probe-core-affinity` printed its placement table from a hard-coded list of four variants that omitted `SameCoreSiblings`, while the interpretation beneath it iterated over the placements actually measured. On an SMT host the table From c7a1b014d00ccc226ec1ef51cf64453717bc0051 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 11:17:09 -0400 Subject: [PATCH 040/361] feat(probes): classify NUMA node crossings before spending a NUMA machine on it ProcessorPlace carried core, efficiency class and cache domain but not the NUMA node, and Placement had no node dimension. A cross-node pair therefore classified as CrossCache*, under a cache label, with nothing in the output saying a node had been crossed -- and representative_pairs takes whichever pair it enumerates first, so which of the two you measured would not have been reproducible either. That only matters on a machine none of us has yet, which is exactly why it is worth fixing now: NUMA hardware is the scarce resource here, and the failure mode is a large number attributed to the wrong cause, discovered after the run rather than during it. CrossNumaNode is classified first, for the mirror-image reason SameCoreSiblings is: crossing a node dominates any statement about cache or class, since two nodes necessarily have separate last-level caches. That keeps the table one-dimensional and ordered tightest-to-loosest rather than doubling it. Six tests, including the precedence cases and both directions of the expressible/inexpressible boundary. Verified by sabotage: removing the check fails four of them. Slices now render /nN, and the single-node hosts report the row as inexpressible rather than omitting it. Also corrects this item's prediction for the planned Intel host. It said a hybrid part would express four rows at once; the box is a cloud VM, so it will be a Xeon with one efficiency class, and the class-crossing rows stay absent. More usefully: all three hosts are VM slices, and the EPYC slice already proves slices flatten topology -- a 7763 is 64 cores across eight CCXs with separate L3s, yet probe-topology reports L3[16], one domain, one NUMA node. So the missing rows are probably unreachable from any dev-box-sized slice, and the Intel run's real value is narrower: whether the SMT-sibling result reproduces on Intel Hyper-Threading rather than AMD SMT, a row currently resting on one vendor's implementation. Completed item: none -- readiness work for the open M-inf.4 question, plus a correction to a prediction committed in d00b483. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 51 ++++++---- .../src/bin/core_affinity.rs | 1 + .../src/core_affinity.rs | 23 +++++ .../src/core_affinity/tests.rs | 96 +++++++++++++++++++ .../src/fingerprint.rs | 24 ++++- 5 files changed, 177 insertions(+), 18 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index e605efd5..b77cd625 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -678,6 +678,7 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio | same cache, cross class | not expressible (confounded) | not expressible (one class) | **neither** | | cross cache, same class | not expressible (confounded) | **yes** | x64 only | | cross cache, cross class | **yes** | not expressible (one class) | ARM64 only | + | cross NUMA node | not expressible (one node) | not expressible (one node) | **neither** | A machine cannot express a placement when its topology makes the pair impossible: ARM64 confounds cache domain with efficiency class (crossing one crosses the other), and the x64 slice has exactly @@ -687,19 +688,37 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio **is unmeasurable on either host, and no re-run of either will produce it.** It needs heterogeneous cores inside one cache domain. - **A third host is expected and is worth a run: an Intel hybrid dev box (P-cores with SMT, E-cores - without, clustered).** Its value is not "another architecture" -- that framing is the one this - investigation already falsified -- but that it should express **four** rows at once, and would be the - first machine to hold both `SMT siblings` and `same cache, same class` at the same time. That matters - because those two rows currently sit on different machines, so the claim that sharing a cache - produces deep batches is confounded with the host it was measured on. One machine expressing both - decouples them. - Predictions to falsify rather than confirm, since a hybrid's outermost *partitioning* cache is - probably still L2 (per-core on P, per-cluster on E): - - `SMT siblings` from a P-core pair, and `same cache, same class` from two E-cores in one L2 cluster. - - `cross cache, same class` from two P-cores, and `cross cache, cross class` from a P/E pair. - - `same cache, cross class` **probably still absent**, because P and E cores are unlikely to share - the outermost partitioning cache. If it does appear, that is the missing row and should be called - out loudly. - Run `probe-topology` first: whether that host's outermost partitioning cache is L2 or L3 determines - which rows exist at all, and it is not predictable from the part number. + **`cross NUMA node` is the other unmeasured row, and the probe was silently unable to report it + until now.** `ProcessorPlace` carried core, class and cache domain but *not* the NUMA node, and + `Placement` had no node dimension, so a cross-node pair would have been bucketed under a cache label + with nothing in the output saying so -- and `representative_pairs` picks whichever pair it enumerates + first, so which one you got would not have been reproducible either. On a scarce NUMA machine that + would have produced a large number attributed to the wrong cause. Fixed ahead of the machine rather + than after it: `numa_node` is now carried, `Placement::CrossNumaNode` is classified *first* (crossing + a node dominates cache and class, exactly as sharing a core does), and six tests cover it including + the precedence cases. Verified by sabotage -- removing the check fails four of them. + This is the same defect class as the omitted `SMT siblings` row, and the third time in this + investigation that an instrument's *classification or presentation*, rather than its measurement, + was the thing about to produce a wrong answer. + + **A third host is planned -- an Intel cloud dev box -- and it should be expected to add no new rows.** + An earlier revision of this item predicted it would express four placements at once, on the + assumption of a *hybrid client* part (P-cores with SMT, E-cores without). That assumption is wrong + for a cloud VM: cloud Intel means Xeon, which has no efficiency cores, so `ec[...]` will almost + certainly read as a single class exactly like the EPYC slice, and the two class-crossing rows stay + inexpressible. + **All three hosts are VM slices, and a slice flattens topology.** The EPYC slice is the proof + already in hand: a 7763 is 64 cores across eight CCXs each with its own L3, and 16 of those cores + would span two of them -- yet `probe-topology` reports `L3[16]`, a single domain, and a single NUMA + node. The hypervisor presented a flat view. **So the missing rows are not merely unmeasured, they + are probably unreachable from any dev-box-sized VM slice**, and expecting a third slice to supply + them would repeat the error of expecting a third architecture to. + The Intel slice is still worth running, for a narrower and more honest reason: **it tests whether + the SMT-sibling result reproduces on Intel Hyper-Threading rather than AMD SMT.** That row currently + rests on one machine and one vendor's implementation of the feature, and it is the row carrying the + claim that sharing L1 produces deep batches. A second SMT vendor either strengthens it or breaks it. + Run `probe-topology` first regardless: whether the outermost partitioning cache is L2 or L3 decides + which rows exist at all, and on a VM slice it is not predictable from the part number. + **What would actually add rows**, if either becomes available: bare metal for `same cache, same + class` and `same cache, cross class`, or a deliberately large multi-NUMA VM SKU (not a dev box) for + a genuine node crossing. See the NUMA gap recorded below before spending time on the latter. diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index 462e27ec..36f939b1 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -108,6 +108,7 @@ fn main() -> std::io::Result<()> { Placement::SameCacheCrossClass, Placement::CrossCacheSameClass, Placement::CrossCacheCrossClass, + Placement::CrossNumaNode, ]; for placement in all { diff --git a/crates/windows-platform-probes/src/core_affinity.rs b/crates/windows-platform-probes/src/core_affinity.rs index c3ade7e4..ebf5eb87 100644 --- a/crates/windows-platform-probes/src/core_affinity.rs +++ b/crates/windows-platform-probes/src/core_affinity.rs @@ -97,6 +97,21 @@ pub enum Placement { CrossCacheSameClass, /// Different cache domain, different efficiency class. CrossCacheCrossClass, + /// Different NUMA nodes. + /// + /// Listed last because it is the loosest coupling a machine can offer, and + /// classified *first* for the same reason `SameCoreSiblings` is: crossing a + /// node dominates any statement about the cache domain or the class, since + /// two nodes necessarily have different last-level caches anyway. + /// + /// Kept as its own bucket rather than merged into `CrossCache*` because the + /// merge is silent and the run that would expose it is the expensive one. A + /// machine with real NUMA would otherwise report a node crossing under a + /// cache label, with nothing in the output saying which had been measured. + /// + /// Absent on every host measured so far -- all three are VM slices that + /// present a single node -- and reported inexpressible rather than merged. + CrossNumaNode, } impl Placement { @@ -109,6 +124,7 @@ impl Placement { Self::SameCacheCrossClass => "same cache, cross class", Self::CrossCacheSameClass => "cross cache, same class", Self::CrossCacheCrossClass => "cross cache, cross class", + Self::CrossNumaNode => "cross NUMA node", } } } @@ -173,6 +189,13 @@ pub fn classify(producer: ProcessorPlace, consumer: ProcessorPlace) -> Placement if producer.core == consumer.core { return Placement::SameCoreSiblings; } + // Tested before cache and class for the mirror-image reason: crossing a + // NUMA node dominates both, since separate nodes have separate last-level + // caches. Without this the pair would be reported under a cache label and + // the node crossing would be invisible. + if producer.numa_node != consumer.numa_node { + return Placement::CrossNumaNode; + } let same_cache = producer.cache_domain == consumer.cache_domain; let same_class = producer.efficiency_class == consumer.efficiency_class; match (same_cache, same_class) { diff --git a/crates/windows-platform-probes/src/core_affinity/tests.rs b/crates/windows-platform-probes/src/core_affinity/tests.rs index f908b104..7241b8c9 100644 --- a/crates/windows-platform-probes/src/core_affinity/tests.rs +++ b/crates/windows-platform-probes/src/core_affinity/tests.rs @@ -11,15 +11,24 @@ use super::{Placement, classify, representative_pairs}; use crate::fingerprint::ProcessorPlace; /// A processor on its own physical core, which is the non-SMT case. +/// +/// Single-node, matching every host measured so far; use [`on_node`] to move +/// one onto another NUMA node. fn place(number: u8, efficiency_class: u8, cache_domain: Option) -> ProcessorPlace { ProcessorPlace { number, core: u32::from(number), efficiency_class, cache_domain, + numa_node: 0, } } +/// The same processor, relocated to another NUMA node. +fn on_node(place: ProcessorPlace, numa_node: u32) -> ProcessorPlace { + ProcessorPlace { numa_node, ..place } +} + /// Two processors sharing one physical core: SMT siblings. fn sibling( number: u8, @@ -32,6 +41,7 @@ fn sibling( core, efficiency_class, cache_domain, + numa_node: 0, } } @@ -235,3 +245,89 @@ fn a_non_smt_host_cannot_express_the_sibling_placement() { "a machine with one processor per core has no siblings to measure" ); } + +#[test] +fn different_numa_nodes_are_classified_as_a_node_crossing() { + let a = place(0, 1, Some(0)); + let b = on_node(place(1, 1, Some(1)), 1); + + assert_eq!(classify(a, b), Placement::CrossNumaNode); +} + +#[test] +fn a_node_crossing_outranks_the_cache_and_class_it_also_crosses() { + // The whole point of the variant: without it this pair reports as + // `CrossCacheCrossClass` and the node crossing is invisible, so an + // expensive run on a real NUMA machine would be recorded as a cache + // effect. + let a = place(0, 1, Some(0)); + let b = on_node(place(1, 0, Some(1)), 1); + + assert_eq!(classify(a, b), Placement::CrossNumaNode); +} + +#[test] +fn a_node_crossing_is_reported_even_when_cache_and_class_match() { + // Same cache domain id and same class on two different nodes is not a + // configuration real hardware offers, but the classifier must not depend on + // that: it decides on the node, not on the fields the node happens to + // correlate with. + let a = place(0, 1, Some(0)); + let b = on_node(place(1, 1, Some(0)), 1); + + assert_eq!(classify(a, b), Placement::CrossNumaNode); +} + +#[test] +fn siblings_outrank_a_node_crossing_because_one_core_cannot_span_nodes() { + // Ordering check rather than a hardware claim. `SameCoreSiblings` is tested + // before the node, so if a topology ever reported one core on two nodes the + // sibling relationship would win. Pinning the order down here means a later + // reordering of `classify` is caught by a test rather than by a confusing + // table on a machine nobody has yet run. + let a = sibling(0, 0, 1, Some(0)); + let b = on_node(sibling(1, 0, 1, Some(0)), 1); + + assert_eq!(classify(a, b), Placement::SameCoreSiblings); +} + +#[test] +fn a_single_node_machine_never_produces_a_node_crossing() { + // Every host measured so far is a VM slice presenting one node, so this is + // the case that must stay quiet: the new variant must not appear where it + // cannot apply. + let places: Vec<_> = (0..8) + .map(|number| place(number, u8::from(number < 4), Some(u32::from(number) / 2))) + .collect(); + + let pairs = representative_pairs(&places); + + assert!( + !pairs.contains_key(&Placement::CrossNumaNode), + "a single-node machine reported a node crossing: {:?}", + pairs.keys().collect::>() + ); +} + +#[test] +fn a_two_node_machine_expresses_the_node_crossing() { + let places: Vec<_> = (0..8) + .map(|number| { + let base = place(number, 1, Some(u32::from(number) / 2)); + on_node(base, u32::from(number) / 4) + }) + .collect(); + + let pairs = representative_pairs(&places); + + assert!( + pairs.contains_key(&Placement::CrossNumaNode), + "a two-node machine did not express a node crossing: {:?}", + pairs.keys().collect::>() + ); + let (producer, consumer) = pairs[&Placement::CrossNumaNode]; + assert_ne!( + producer.numa_node, consumer.numa_node, + "the pair chosen for a node crossing is on one node" + ); +} diff --git a/crates/windows-platform-probes/src/fingerprint.rs b/crates/windows-platform-probes/src/fingerprint.rs index dfe3d67c..4b11d67b 100644 --- a/crates/windows-platform-probes/src/fingerprint.rs +++ b/crates/windows-platform-probes/src/fingerprint.rs @@ -73,6 +73,15 @@ pub struct ProcessorPlace { /// Which cache domain it sits behind, at the outermost level that /// partitions the machine, or `None` if no level does. pub cache_domain: Option, + /// Which NUMA node it belongs to. + /// + /// Carried even though every host measured so far reports a single node, + /// because the cost of *not* carrying it is paid at exactly the wrong + /// moment. Without it a cross-node pair is indistinguishable from a + /// cross-cache one, so a scarce run on a genuine NUMA machine would record + /// a node crossing as a cache effect and nothing in the output would say + /// which had been measured. + pub numa_node: u32, } impl fmt::Display for ProcessorPlace { @@ -83,9 +92,10 @@ impl fmt::Display for ProcessorPlace { self.number, self.core, self.efficiency_class )?; match self.cache_domain { - Some(id) => write!(f, "/cd{id}"), - None => write!(f, "/cd-"), + Some(id) => write!(f, "/cd{id}")?, + None => write!(f, "/cd-")?, } + write!(f, "/n{}", self.numa_node) } } @@ -371,6 +381,15 @@ pub fn discover_places() -> std::io::Result> { } } + // Node 0 is the correct default rather than a fallback: a machine with no + // NUMA partitioning has exactly one node, and every processor is in it. + let mut numa_of = std::collections::BTreeMap::new(); + for domain in topology.memory_domains() { + for (_group, number) in domain.processors.iter() { + numa_of.insert(number, domain.id); + } + } + Ok(class_of .into_iter() .map(|(number, efficiency_class)| ProcessorPlace { @@ -378,6 +397,7 @@ pub fn discover_places() -> std::io::Result> { core: core_of.get(&number).copied().unwrap_or(u32::from(number)), efficiency_class, cache_domain: cache_of.get(&number).copied(), + numa_node: numa_of.get(&number).copied().unwrap_or(0), }) .collect()) } From 781ec7c21030755a1185700f076e917a706d0150 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 11:22:51 -0400 Subject: [PATCH 041/361] test(probes): validate the node-crossing path against synthetic multi-socket hosts classify and representative_pairs are pure functions of a processor list, so a mocked list exercises them exactly as real hardware would. Five fixtures: a two-socket host with several cache domains per node, one with a single cache domain per node, a no-SMT server, and a four-node host. Each asserts more than "the expected rows appear". It asserts the pair chosen for each row actually satisfies that row's predicate, because a table with the right labels and the wrong pairs behind them is worse than a missing row -- it reports a number for a placement that was never measured. It also checks the regression the NUMA variant could have caused: classifying the node first must not swallow same-node cache crossings. Verified by sabotage. Disabling the classifier's node check fails all nine node-related tests, including all five added here. The timings are deliberately not mocked and cannot be: measure pins to real processors and pinning to one that does not exist fails loudly rather than fabricating a number. Mocking that would produce confidence without evidence, which is the opposite of what these probes are for. Records one prediction worth having in writing before the hardware exists: on a multi-socket host, cross cache same class may be absent entirely. cache_domain means "outermost cache level that partitions the machine", so its meaning moves with the host -- L2 on the single-socket EPYC slice, but the socket itself on a two-socket box with a per-socket last-level cache. There, two cores either share the domain or are on different nodes, the node check claims the pair first, and the row has no members. That absence is the topology speaking, not a defect, and the corollary is that the EPYC slice's isolated 1.8-2.0x cache-crossing number may have no counterpart on a multi-socket host. Completed item: none -- readiness work for the open M-inf.4 question. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 20 ++ .../src/core_affinity/tests.rs | 228 ++++++++++++++++++ 2 files changed, 248 insertions(+) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index b77cd625..ff1e4b83 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -701,6 +701,26 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio investigation that an instrument's *classification or presentation*, rather than its measurement, was the thing about to produce a wrong answer. + **The node-crossing path is validated against synthetic multi-socket topologies, offline.** + `classify` and `representative_pairs` are pure functions of a processor list, so a mocked list + exercises them exactly as real hardware would; five fixtures cover a two-socket host with several + cache domains per node, one with a single cache domain per node, a no-SMT server, and a four-node + host. Each asserts not merely that the expected rows appear but that **the pair chosen for each row + actually satisfies that row's predicate** -- a table with right labels and wrong pairs behind them + is worse than a missing row. All nine node-related tests fail when the classifier's node check is + removed. The *timings* are deliberately not mocked and cannot be: `measure` pins to real + processors, and pinning to one that does not exist fails loudly rather than fabricating a number. + + **A prediction that will otherwise look like a bug on the real run: on a multi-socket host, + `cross cache, same class` may be absent entirely.** `cache_domain` is defined as the outermost cache + level that *partitions the machine*, so its meaning moves with the host. On the single-socket EPYC + slice that level is L2, and the row measures an L2 crossing inside one L3. On a two-socket box whose + last-level cache is per-socket, that level becomes the socket -- so two cores either share the cache + domain (same node) or sit on different nodes, the node check claims the pair first, and the + cross-cache row has no members. A synthetic fixture pins this down. **Read that absence as the + topology speaking, not as a defect**, and note the corollary: the EPYC slice's isolated 1.8x - 2.0x + cache-crossing number may have no counterpart on a multi-socket host at all. + **A third host is planned -- an Intel cloud dev box -- and it should be expected to add no new rows.** An earlier revision of this item predicted it would express four placements at once, on the assumption of a *hybrid client* part (P-cores with SMT, E-cores without). That assumption is wrong diff --git a/crates/windows-platform-probes/src/core_affinity/tests.rs b/crates/windows-platform-probes/src/core_affinity/tests.rs index 7241b8c9..8300b6d3 100644 --- a/crates/windows-platform-probes/src/core_affinity/tests.rs +++ b/crates/windows-platform-probes/src/core_affinity/tests.rs @@ -331,3 +331,231 @@ fn a_two_node_machine_expresses_the_node_crossing() { "the pair chosen for a node crossing is on one node" ); } + +// --------------------------------------------------------------------------- +// Synthetic multi-node hosts. +// +// No machine available to this workspace has more than one NUMA node, so the +// classification and *selection* logic for a node crossing would otherwise +// first execute on scarce hardware, where a mis-selection reads as a surprising +// measurement rather than as a bug. These fixtures exercise that logic offline. +// +// What they can and cannot establish, stated plainly: `classify` and +// `representative_pairs` are pure functions of a processor list, so feeding +// them a synthetic list tests them exactly as the real thing would. The +// *timings* are not testable this way and are not attempted -- `measure` pins +// threads to real processors, and pinning to a processor that does not exist +// fails loudly rather than returning a fabricated number. +// --------------------------------------------------------------------------- + +/// The shape of a machine, in the terms the classifier actually uses. +struct HostSpec { + nodes: u32, + /// Cache domains per node, at the outermost level that partitions the host. + cache_domains_per_node: u32, + cores_per_cache_domain: u32, + threads_per_core: u32, +} + +/// Build the processor list such a machine would present. +/// +/// Deliberately small. The shape is what the classifier reads; a 128-processor +/// version would exercise the identical code paths and would not fit in `u8`. +fn synthesize(spec: &HostSpec) -> Vec { + let mut places = Vec::new(); + let mut number = 0_u8; + let mut core = 0_u32; + let mut cache_domain = 0_u32; + + for node in 0..spec.nodes { + for _ in 0..spec.cache_domains_per_node { + for _ in 0..spec.cores_per_cache_domain { + for _ in 0..spec.threads_per_core { + places.push(ProcessorPlace { + number, + core, + efficiency_class: 0, + cache_domain: Some(cache_domain), + numa_node: node, + }); + number += 1; + } + core += 1; + } + cache_domain += 1; + } + } + places +} + +/// Assert that every chosen pair genuinely satisfies the placement it is filed +/// under. +/// +/// This is the check that matters. A table with the right *row labels* and the +/// wrong *pairs* behind them is worse than a missing row, because it reports a +/// number for a placement that was never measured. +fn assert_pairs_are_faithful(places: &[ProcessorPlace]) { + for (placement, (producer, consumer)) in representative_pairs(places) { + assert_eq!( + classify(producer, consumer), + placement, + "pair {producer} / {consumer} filed under {}", + placement.label() + ); + assert_ne!( + producer.number, consumer.number, + "a placement was measured against a single processor" + ); + match placement { + Placement::SameCoreSiblings => { + assert_eq!(producer.core, consumer.core); + assert_eq!(producer.numa_node, consumer.numa_node); + } + Placement::CrossNumaNode => { + assert_ne!(producer.numa_node, consumer.numa_node); + } + // Every non-NUMA placement must stay inside one node, or it would + // have classified as a node crossing instead. + _ => assert_eq!( + producer.numa_node, + consumer.numa_node, + "{} spans two NUMA nodes", + placement.label() + ), + } + } +} + +/// A two-socket machine whose outermost partitioning cache sits *inside* each +/// node -- several cache domains per node, as an EPYC's CCX layout gives. +fn two_socket_many_cache_domains() -> Vec { + synthesize(&HostSpec { + nodes: 2, + cache_domains_per_node: 2, + cores_per_cache_domain: 2, + threads_per_core: 2, + }) +} + +/// A two-socket machine with a single cache domain per node, which is what a +/// classic server presents when its last-level cache is per-socket. +fn two_socket_one_cache_domain_per_node() -> Vec { + synthesize(&HostSpec { + nodes: 2, + cache_domains_per_node: 1, + cores_per_cache_domain: 4, + threads_per_core: 2, + }) +} + +#[test] +fn a_two_socket_host_expresses_siblings_both_cache_rows_and_the_node_crossing() { + let places = two_socket_many_cache_domains(); + let pairs = representative_pairs(&places); + let mut found: Vec<_> = pairs.keys().copied().collect(); + found.sort_unstable(); + + assert_eq!( + found, + vec![ + Placement::SameCoreSiblings, + Placement::SameCacheSameClass, + Placement::CrossCacheSameClass, + Placement::CrossNumaNode, + ], + "unexpected placement set for a two-socket host" + ); + assert_pairs_are_faithful(&places); +} + +#[test] +fn adding_a_second_node_does_not_cannibalise_the_cross_cache_row() { + // The subtle regression the NUMA variant could have introduced: classifying + // the node first must not swallow same-node cache crossings, which are a + // different and still-interesting measurement. + let one_node = synthesize(&HostSpec { + nodes: 1, + cache_domains_per_node: 2, + cores_per_cache_domain: 2, + threads_per_core: 2, + }); + let two_nodes = two_socket_many_cache_domains(); + + let single = representative_pairs(&one_node); + let dual = representative_pairs(&two_nodes); + + assert!(single.contains_key(&Placement::CrossCacheSameClass)); + assert!( + dual.contains_key(&Placement::CrossCacheSameClass), + "the cross-cache row vanished once a second node existed" + ); + assert!(!single.contains_key(&Placement::CrossNumaNode)); + assert!(dual.contains_key(&Placement::CrossNumaNode)); +} + +#[test] +fn one_cache_domain_per_node_has_no_cross_cache_row_at_all() { + // Not a defect, and worth pinning down before a real multi-socket run makes + // it look like one. When the outermost partitioning cache *is* the socket, + // two cores either share it (same node) or are on different nodes -- so + // "cross cache, same class" has no members and the node crossing is the + // only way out of a cache domain. A future run on such a host will show + // that row as inexpressible, and that is the correct reading. + let places = two_socket_one_cache_domain_per_node(); + let pairs = representative_pairs(&places); + let mut found: Vec<_> = pairs.keys().copied().collect(); + found.sort_unstable(); + + assert_eq!( + found, + vec![ + Placement::SameCoreSiblings, + Placement::SameCacheSameClass, + Placement::CrossNumaNode, + ] + ); + assert_pairs_are_faithful(&places); +} + +#[test] +fn a_node_crossing_is_expressible_without_smt() { + // A multi-socket host with hyper-threading disabled, which is a common + // server configuration and the one where a sibling-shaped assumption would + // break. + let places = synthesize(&HostSpec { + nodes: 2, + cache_domains_per_node: 1, + cores_per_cache_domain: 4, + threads_per_core: 1, + }); + let pairs = representative_pairs(&places); + + assert!(!pairs.contains_key(&Placement::SameCoreSiblings)); + assert!(pairs.contains_key(&Placement::CrossNumaNode)); + assert_pairs_are_faithful(&places); +} + +#[test] +fn a_four_node_host_still_reports_exactly_one_node_crossing_row() { + // More than two nodes must not multiply the row: the probe measures one + // representative pair per placement, and "cross NUMA" is one placement + // however many nodes exist. Distance between specific nodes is a real + // effect this deliberately does not model, and saying so here stops the + // single row being over-read later. + let places = synthesize(&HostSpec { + nodes: 4, + cache_domains_per_node: 1, + cores_per_cache_domain: 2, + threads_per_core: 1, + }); + let pairs = representative_pairs(&places); + + assert_eq!( + pairs + .keys() + .filter(|p| **p == Placement::CrossNumaNode) + .count(), + 1 + ); + assert_pairs_are_faithful(&places); +} From afd434e8d68f3b4bb9aa918dd8714edb4852ff36 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 11:29:34 -0400 Subject: [PATCH 042/361] feat(probes): measure each NUMA hop separately instead of one representative CrossNumaNode is a single placement however many nodes a host has, so on a machine with three or more it reported one hop and implied the rest were like it -- and which hop you got depended on enumeration order. Real multi-node hardware is not equidistant: two nodes on one package are far closer than two across a socket link. node_pairs selects one representative processor pair per distinct node pair, keyed (low, high) so a link is measured once rather than once per direction, and measure reports each hop in by_node_pair. The probe prints the table, names the cheapest and dearest hop, and says outright whether the spread is small enough for the single cross NUMA node row to be a fair summary of it. These are measured hops, not a firmware distance matrix. Windows exposes no NUMA distance table -- no Win32 equivalent of reading ACPI SLIT -- so measuring the handoff is the only way to learn that two nodes are further apart than another two. The printed text says so, to stop the numbers being read as a restatement of firmware. Seven tests over synthetic 1-, 2-, 3-, 4- and 8-node hosts: every hop selected exactly once, keys canonical, both directions never both present, the chosen pair genuinely spanning the nodes it is filed under, hop count equal to the triangular number of the node count, selection stable across calls, and non-zero-based node ids still selected. Verified by sabotage -- relaxing the canonical-ordering guard fails five of them. On the single-node hosts available to this workspace the section prints nothing, rather than a header over an empty table. Completed item: none -- readiness work for the open M-inf.4 question. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 17 ++ .../src/bin/core_affinity.rs | 98 ++++++++++- .../src/core_affinity.rs | 99 +++++++++++ .../src/core_affinity/tests.rs | 160 +++++++++++++++++- 4 files changed, 371 insertions(+), 3 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index ff1e4b83..45eecf2b 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -711,6 +711,23 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio removed. The *timings* are deliberately not mocked and cannot be: `measure` pins to real processors, and pinning to one that does not exist fails loudly rather than fabricating a number. + **Inter-node distance is measured per hop, not collapsed into one row.** `CrossNumaNode` is a single + placement however many nodes exist, so on a host with three or more it would report one hop and + imply the rest were like it -- and which hop you got would depend on enumeration order. Real + multi-node hardware is not equidistant: two nodes on one package are far closer than two across a + socket link. `node_pairs` therefore selects one representative processor pair per *distinct* node + pair, keyed `(low, high)` so a link is measured once rather than once per direction, and `measure` + reports each hop separately in `by_node_pair`. The probe prints the resulting table, names the + cheapest and dearest hop, and says outright whether the spread is small enough for the single + `cross NUMA node` row to be a fair summary. + **These are measured hops, not a firmware distance matrix.** Windows exposes no NUMA distance table + -- there is no Win32 equivalent of reading ACPI SLIT -- so measuring the handoff is the only way to + learn that two nodes are further apart than another two. Seven tests cover the selection on + synthetic 1-, 2-, 3-, 4- and 8-node hosts, including that the hop count is the triangular number of + the node count, that selection is stable across calls, and that non-zero-based node ids still work; + all five relevant ones fail when the canonical-ordering guard is broken. On the single-node hosts we + have, the section prints nothing rather than an empty table. + **A prediction that will otherwise look like a bug on the real run: on a multi-socket host, `cross cache, same class` may be absent entirely.** `cache_domain` is defined as the outermost cache level that *partitions the machine*, so its meaning moves with the host. On the single-socket EPYC diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index 36f939b1..10483e15 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -2,7 +2,7 @@ //! Prints whether it matters where the two ends of a queue run. -use windows_platform_probes::core_affinity::{Placement, measure}; +use windows_platform_probes::core_affinity::{Observation, Placement, measure}; use windows_platform_probes::peer_index_cache::Strategy; fn main() -> std::io::Result<()> { @@ -149,7 +149,13 @@ fn main() -> std::io::Result<()> { } } - println!("\ninterpretation:\n"); + print_node_distances(&observation); + + println!( + " +interpretation: +" + ); let expressible = observation.placements(); if expressible.len() < 2 { @@ -325,3 +331,91 @@ fn main() -> std::io::Result<()> { Ok(()) } + +/// Print the per-node-pair handoff cost, when the host has nodes to cross. +/// +/// Silent on a single-node machine: there is nothing to say, and a header over +/// an empty table invites the reader to wonder what went wrong. +fn print_node_distances(observation: &Observation) { + let pairs = observation.node_pairs_measured(); + if pairs.is_empty() { + return; + } + + println!("\n-- the handoff, by NUMA node pair --"); + println!( + "{:<14} {:>4} {:>4} {:>12} {:>12} {:>10}", + "node pair", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" + ); + + let mut slowest: Option<(f64, (u32, u32))> = None; + let mut fastest: Option<(f64, (u32, u32))> = None; + + for pair in &pairs { + let (Some(base), Some(cached)) = ( + observation.node_pair(*pair, Strategy::Baseline), + observation.node_pair(*pair, Strategy::Cached), + ) else { + continue; + }; + println!( + "{:<14} {:>4} {:>4} {:>12.1} {:>12.1} {:>10.1}", + format!("{} <-> {}", pair.0, pair.1), + base.producer.number, + base.consumer.number, + base.nanos_per_item, + cached.nanos_per_item, + cached.consumer_batch + ); + let seen = (base.nanos_per_item, *pair); + if slowest.is_none_or(|(worst, _)| seen.0 > worst) { + slowest = Some(seen); + } + if fastest.is_none_or(|(best, _)| seen.0 < best) { + fastest = Some(seen); + } + } + + if pairs.len() == 1 { + println!( + "\n One node pair, so this restates the `cross NUMA node` row above\n \ + rather than adding to it. The table earns its place from three\n \ + nodes upward, where the hops stop being interchangeable." + ); + return; + } + + let (Some((worst, worst_pair)), Some((best, best_pair))) = (slowest, fastest) else { + return; + }; + println!( + "\n {} node pairs. Cheapest hop {} <-> {} at {:.1} ns/item; dearest\n \ + {} <-> {} at {:.1} ns/item -- a spread of {:.1}x.", + pairs.len(), + best_pair.0, + best_pair.1, + best, + worst_pair.0, + worst_pair.1, + worst, + worst / best + ); + if worst / best < 1.2 { + println!( + " That spread is small enough that this host's nodes are close to\n \ + equidistant, so the single `cross NUMA node` row above is a fair\n \ + summary of it." + ); + } else { + println!( + " The hops are NOT interchangeable, so the single `cross NUMA node`\n \ + row above reports whichever one was enumerated first and should not\n \ + be read as 'the' cost of leaving a node." + ); + } + println!( + " This measures the handoff between two nodes; it is not a distance\n \ + matrix read from firmware. Windows exposes no NUMA distance table, so\n \ + these numbers are the observable rather than a restatement of ACPI." + ); +} diff --git a/crates/windows-platform-probes/src/core_affinity.rs b/crates/windows-platform-probes/src/core_affinity.rs index ebf5eb87..14967d09 100644 --- a/crates/windows-platform-probes/src/core_affinity.rs +++ b/crates/windows-platform-probes/src/core_affinity.rs @@ -179,6 +179,19 @@ pub struct Observation { /// rather than silently skipped, because "this host cannot test that" and /// "that made no difference" are opposite findings. pub measurements: Vec, + /// One measurement per *distinct pair of NUMA nodes*. + /// + /// Separate from [`Self::measurements`] for the same reason [`Self::by_class`] + /// is: the placement categories collapse every node crossing into a single + /// `CrossNumaNode` row, which answers "does leaving the node cost" while + /// silently assuming every node is equidistant. On real multi-node hardware + /// they are not -- two nodes on one package are far closer than two across a + /// socket link -- so a single row would report whichever hop the enumeration + /// happened to reach first. + /// + /// Empty on a single-node machine, and a single entry on a two-node one, + /// where it restates the `CrossNumaNode` row rather than adding to it. + pub by_node_pair: Vec, } /// Classify a pair. @@ -234,6 +247,45 @@ pub fn representative_pairs( chosen } +/// Choose one representative processor pair for each *distinct pair of NUMA +/// nodes*. +/// +/// Keyed by `(low, high)` node id, so a node pair appears once rather than once +/// per direction: this measures the link, and `0 -> 1` and `1 -> 0` traverse the +/// same one. The producer is always on the lower-numbered node, which makes a +/// run reproducible rather than dependent on enumeration order. +/// +/// # Why this exists separately from [`representative_pairs`] +/// +/// Windows exposes no NUMA distance table -- there is no Win32 equivalent of +/// reading ACPI SLIT -- so the only way to learn that two nodes are further +/// apart than another two is to measure the handoff between them. A single +/// `CrossNumaNode` row cannot express that, because it reports one hop and +/// implies every hop is like it. +/// +/// Empty on a single-node machine: there is no node crossing to represent, and +/// an empty result says so more honestly than a fabricated self-pair. +#[must_use] +pub fn node_pairs( + places: &[ProcessorPlace], +) -> BTreeMap<(u32, u32), (ProcessorPlace, ProcessorPlace)> { + let mut chosen = BTreeMap::new(); + for producer in places { + for consumer in places { + if producer.numa_node >= consumer.numa_node { + // `>=` rather than `!=` collapses the two directions onto the + // canonical `(low, high)` key and drops same-node pairs, which + // are not a crossing at all. + continue; + } + chosen + .entry((producer.numa_node, consumer.numa_node)) + .or_insert((*producer, *consumer)); + } + } + chosen +} + /// Measure every expressible placement under baseline and cached strategies. /// /// # Errors @@ -307,10 +359,33 @@ pub fn measure() -> std::io::Result { } } + let mut by_node_pair = Vec::new(); + for ((left, right), (producer, consumer)) in node_pairs(&processors) { + debug_assert_eq!((producer.numa_node, consumer.numa_node), (left, right)); + for strategy in [Strategy::Baseline, Strategy::Cached] { + let mut samples: Vec<_> = (0..REPETITIONS) + .map(|_| time_model_on(strategy, Some(producer.number), Some(consumer.number))) + .collect(); + samples.sort_by(|a, b| a.nanos.total_cmp(&b.nanos)); + let median = samples[samples.len() / 2]; + by_node_pair.push(Measurement { + slice: Slice::pair(producer, consumer), + producer, + consumer, + placement: classify(producer, consumer), + strategy, + nanos_per_item: median.nanos / ITEMS as f64, + consumer_batch: ITEMS as f64 / median.consumer_refreshes.max(1) as f64, + producer_batch: ITEMS as f64 / median.producer_refreshes.max(1) as f64, + }); + } + } + Ok(Observation { processors, by_class, measurements, + by_node_pair, }) } @@ -324,6 +399,30 @@ impl Observation { .cloned() } + /// Every node pair measured, in canonical `(low, high)` order. + #[must_use] + pub fn node_pairs_measured(&self) -> Vec<(u32, u32)> { + let mut seen: Vec<(u32, u32)> = self + .by_node_pair + .iter() + .map(|m| (m.producer.numa_node, m.consumer.numa_node)) + .collect(); + seen.sort_unstable(); + seen.dedup(); + seen + } + + /// The measurement for one node pair and strategy, if it was taken. + #[must_use] + pub fn node_pair(&self, pair: (u32, u32), strategy: Strategy) -> Option { + self.by_node_pair + .iter() + .find(|m| { + (m.producer.numa_node, m.consumer.numa_node) == pair && m.strategy == strategy + }) + .cloned() + } + /// Which placements this machine could express. #[must_use] pub fn placements(&self) -> Vec { diff --git a/crates/windows-platform-probes/src/core_affinity/tests.rs b/crates/windows-platform-probes/src/core_affinity/tests.rs index 8300b6d3..4c1571e9 100644 --- a/crates/windows-platform-probes/src/core_affinity/tests.rs +++ b/crates/windows-platform-probes/src/core_affinity/tests.rs @@ -7,7 +7,7 @@ //! seconds. What is worth testing here is that the probe cannot silently //! mislabel a pair, because every conclusion it prints is keyed on that label. -use super::{Placement, classify, representative_pairs}; +use super::{Placement, classify, node_pairs, representative_pairs}; use crate::fingerprint::ProcessorPlace; /// A processor on its own physical core, which is the non-SMT case. @@ -559,3 +559,161 @@ fn a_four_node_host_still_reports_exactly_one_node_crossing_row() { ); assert_pairs_are_faithful(&places); } + +// --------------------------------------------------------------------------- +// Inter-node distance selection. +// +// `CrossNumaNode` is one category however many nodes exist, so on a host with +// three or more it reports one hop and implies the rest are like it. Real +// multi-node hardware does not work that way. These cover the selection that +// measures each hop separately; as above, only selection is testable offline. +// --------------------------------------------------------------------------- + +/// Every chosen pair must genuinely span the two nodes it is filed under, in +/// canonical order. +fn assert_node_pairs_are_faithful(places: &[ProcessorPlace]) { + for ((low, high), (producer, consumer)) in node_pairs(places) { + assert!(low < high, "key ({low}, {high}) is not in canonical order"); + assert_eq!( + producer.numa_node, low, + "producer {producer} is not on node {low}" + ); + assert_eq!( + consumer.numa_node, high, + "consumer {consumer} is not on node {high}" + ); + assert_eq!( + classify(producer, consumer), + Placement::CrossNumaNode, + "a node pair did not classify as a node crossing" + ); + } +} + +#[test] +fn a_single_node_host_has_no_node_pairs() { + let places = synthesize(&HostSpec { + nodes: 1, + cache_domains_per_node: 2, + cores_per_cache_domain: 2, + threads_per_core: 2, + }); + + assert!( + node_pairs(&places).is_empty(), + "a single-node host produced a node pair" + ); +} + +#[test] +fn a_two_node_host_has_exactly_one_node_pair() { + let places = two_socket_many_cache_domains(); + let pairs = node_pairs(&places); + + assert_eq!(pairs.len(), 1); + assert!(pairs.contains_key(&(0, 1))); + assert_node_pairs_are_faithful(&places); +} + +#[test] +fn a_four_node_host_measures_every_hop_exactly_once() { + // The whole reason this exists: six distinct hops, not one row standing in + // for all of them. + let places = synthesize(&HostSpec { + nodes: 4, + cache_domains_per_node: 1, + cores_per_cache_domain: 2, + threads_per_core: 1, + }); + let pairs = node_pairs(&places); + + let mut keys: Vec<_> = pairs.keys().copied().collect(); + keys.sort_unstable(); + assert_eq!(keys, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]); + assert_node_pairs_are_faithful(&places); +} + +#[test] +fn node_pairs_are_undirected_so_a_hop_is_never_measured_twice() { + // `0 -> 1` and `1 -> 0` traverse the same link, so measuring both would + // double the cost of the table and invite a reader to treat the difference + // between them as signal when it is noise. + let places = synthesize(&HostSpec { + nodes: 3, + cache_domains_per_node: 1, + cores_per_cache_domain: 2, + threads_per_core: 1, + }); + let pairs = node_pairs(&places); + + assert_eq!(pairs.len(), 3); + for (low, high) in pairs.keys() { + assert!(low < high); + assert!( + !pairs.contains_key(&(*high, *low)), + "both directions of ({low}, {high}) were selected" + ); + } +} + +#[test] +fn the_hop_count_is_the_triangular_number_of_the_node_count() { + // A property rather than a fixture, so a host size nobody wrote a test for + // is still covered. + for nodes in 1..=8_u32 { + let places = synthesize(&HostSpec { + nodes, + cache_domains_per_node: 1, + cores_per_cache_domain: 1, + threads_per_core: 1, + }); + let expected = (nodes * nodes.saturating_sub(1) / 2) as usize; + + assert_eq!( + node_pairs(&places).len(), + expected, + "wrong hop count for {nodes} nodes" + ); + assert_node_pairs_are_faithful(&places); + } +} + +#[test] +fn node_pair_selection_is_stable_across_calls() { + // The producer is always on the lower-numbered node, so a run is + // reproducible rather than dependent on enumeration order. Without this a + // re-run could silently measure a different pair and the difference would + // read as drift in the hardware. + let places = synthesize(&HostSpec { + nodes: 3, + cache_domains_per_node: 2, + cores_per_cache_domain: 2, + threads_per_core: 2, + }); + + let first = node_pairs(&places); + let second = node_pairs(&places); + + assert_eq!(first, second); +} + +#[test] +fn a_node_pair_is_still_selected_when_the_nodes_are_not_numbered_from_zero() { + // Node ids are opaque identifiers from the topology, not indices. A host + // that reports nodes 2 and 5 must still produce the hop between them. + let mut places = synthesize(&HostSpec { + nodes: 2, + cache_domains_per_node: 1, + cores_per_cache_domain: 2, + threads_per_core: 1, + }); + for place in &mut places { + place.numa_node = if place.numa_node == 0 { 2 } else { 5 }; + } + + let pairs = node_pairs(&places); + + assert_eq!(pairs.len(), 1); + assert!(pairs.contains_key(&(2, 5))); + assert_node_pairs_are_faithful(&places); +} From d50d8de01964b4dbbdd6a1ae8a725f2a92fbdac4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 11:34:43 -0400 Subject: [PATCH 043/361] docs(probes): record why measure() must not gain a topology-injection seam numa_node and number are independent fields, so a synthetic list fed to a real host can have valid processor numbers throughout. Every pin then succeeds and the run produces genuine timings filed under fabricated node ids -- a table indistinguishable from a real NUMA measurement that measured no such thing. The pin assertion does not catch it: it rejects a processor that does not exist, not a label that is wrong. The absence of the seam is the only thing preventing that, which makes it a property to defend explicitly rather than leave as an accident -- a measure_with(places) overload reads as an obvious testability improvement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core_affinity.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/windows-platform-probes/src/core_affinity.rs b/crates/windows-platform-probes/src/core_affinity.rs index 14967d09..b5a8bbf7 100644 --- a/crates/windows-platform-probes/src/core_affinity.rs +++ b/crates/windows-platform-probes/src/core_affinity.rs @@ -288,6 +288,25 @@ pub fn node_pairs( /// Measure every expressible placement under baseline and cached strategies. /// +/// # Do not add a seam here to inject a processor list +/// +/// This reads the real machine on purpose, and the classification functions it +/// calls -- [`classify`], [`representative_pairs`], [`node_pairs`] -- are pure +/// so that they, and not this, are what synthetic topologies exercise. A +/// `measure_with(places)` overload would look like an obvious testability +/// improvement and would be a trap: +/// +/// [`ProcessorPlace::numa_node`] and [`ProcessorPlace::number`] are independent +/// fields. Feed a synthetic four-node list to a sixteen-processor single-node +/// host and every processor number in it is still *valid*, so every pin +/// succeeds and the run produces genuine timings filed under fabricated node +/// ids -- a table indistinguishable from a real NUMA measurement that measured +/// no such thing. The pin assertion does not catch it: it rejects a processor +/// that does not exist, not a label that is wrong. +/// +/// Selection is testable offline and is tested there; the timings need real +/// hardware and are worth nothing without it. +/// /// # Errors /// /// Returns whatever [`discover_places`] failed with. From 78636fa75636173b133092a4fb0350293ea0bcac Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 11:41:43 -0400 Subject: [PATCH 044/361] docs: plan topology provenance, so fed-in descriptions cannot pass as measured Topology is documented as constructible by hand and by deserializing a fed-in description, and there is a passing test that parses a Linux-shaped description with an ACPI SLIT-style distance matrix on a Windows-only crate. Nothing distinguishes either from discover(), so a consumer treats another machine's topology, or a fabricated one, as this machine's truth. Surfaced by CHECKLIST-io-domains.md M-inf.4: probe-core-affinity needs synthetic multi-node topologies precisely because no NUMA machine is available, and the whole point of a probe is that its output is believed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-topology-provenance.md | 64 ++++++++++++++++++++++++++++++++ PLANS.md | 1 + 2 files changed, 65 insertions(+) create mode 100644 CHECKLIST-topology-provenance.md diff --git a/CHECKLIST-topology-provenance.md b/CHECKLIST-topology-provenance.md new file mode 100644 index 00000000..3efeef82 --- /dev/null +++ b/CHECKLIST-topology-provenance.md @@ -0,0 +1,64 @@ +# Checklist: topology provenance + +**Problem.** [crates/windows-topology-sys/src/topology.rs](crates/windows-topology-sys/src/topology.rs) +documents that a `Topology` is "built either by `Topology::discover` from the running system, by hand, +or (with the `serde` feature) by deserializing a fed-in description" -- and **nothing distinguishes the +three once built**. `Topology` derives `Default`, has public fields, and derives `Deserialize`. There is +a passing test that parses a *Linux-shaped* description, complete with an ACPI SLIT-style distance +matrix, on a Windows-only crate. A consumer handed that value treats another machine's topology, or a +fabricated one, as this machine's truth. + +This is not hypothetical for the work in flight. `probe-core-affinity` needs synthetic multi-node +topologies precisely because no NUMA machine is available, and the whole point of a probe is that its +output is believed. + +**Decision.** Topology content carries its own provenance, defaulting to the *untrusted* value so that +forgetting is safe and claiming is deliberate. Persisted forms carry it visibly, and loading can only +ever downgrade -- a file cannot assert that it is this machine. + +Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is what surfaced this. + +## M1: the marker, and its invariants + +- [ ] **TP-1.1** -- Add `Provenance` to `windows-topology-sys` with three states ordered by trust: + `Measured` (read from the running system), `Restored` (deserialized from a description of some + machine), `Synthetic` (constructed by hand). **`Synthetic` is `Default`.** That is the load-bearing + choice: `Topology::default()`, `..Default::default()`, and any construction that omits the field all + come out tainted, so a caller must do work to claim data is real rather than work to admit it is not. + Document that the threat model is *accident*, not forgery -- a caller who writes + `provenance: Measured` over fabricated data has lied deliberately, and no type prevents that. + +- [ ] **TP-1.2** -- Add the field to `Topology` and set `Measured` in `discover()`. This is a **breaking + change** for struct-literal construction, and deliberately so: every existing site is forced to state + which kind of data it holds. Update the crate's own tests and every dependent that constructs a + `Topology` by hand. + +- [ ] **TP-1.3** -- Serde: serialize the marker so it is *visible* in the persisted form, and + **downgrade on load** -- `Measured` becomes `Restored`, everything else is unchanged. The rule is + **never upgrade**, so a hand-edited `"provenance": "measured"` is ignored rather than honoured. A + description absent the field loads as `Synthetic`. Test each of the four load cases, including that a + round trip of a measured topology does not come back measured. + +## M2: making it loud where it is read + +- [ ] **TP-2.1** -- `Fingerprint` in [crates/windows-platform-probes/src/fingerprint.rs](crates/windows-platform-probes/src/fingerprint.rs) + carries the provenance and renders it **first and unmissably** when it is not `Measured`. The + fingerprint string is documented as canonical, so string equality is a usable comparison -- which + means the marker must be *inside* the string, or a synthetic host could compare equal to a real one. + That is the specific bug this prevents, not merely a display nicety. + +- [ ] **TP-2.2** -- Every probe banner and every persisted probe line inherits it, since + `print_banner` and `Slice` are what end up pasted into checklists and design notes. A number quoted + from a synthetic run must arrive already labelled, because the label is what a reader will not think + to ask for. + +## M3: closing the loop with the probes + +- [ ] **TP-3.1** -- Reconsider whether `probe-core-affinity`'s synthetic hosts should be expressed as + `Topology` values rather than as `Vec`. Going through `Topology` would exercise the + provenance path end to end and let a synthetic *NUMA* host drive selection through the real + `discover_places` conversion; staying at `ProcessorPlace` keeps the tests pure and fast. **Decide on + the evidence, and record the decision either way** -- this item is not "do it", it is "choose". + Note the constraint from + [crates/windows-platform-probes/src/core_affinity.rs](crates/windows-platform-probes/src/core_affinity.rs): + `measure()` must still not gain a topology-injection seam, whatever is decided here. diff --git a/PLANS.md b/PLANS.md index 1b06d36d..835c9d19 100644 --- a/PLANS.md +++ b/PLANS.md @@ -19,6 +19,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil |---|---|---|---| | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | not started | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | +| [CHECKLIST-topology-provenance.md](CHECKLIST-topology-provenance.md) | in progress | Topology content carries its own provenance. `Topology` is documented as constructible by hand and by deserializing a fed-in description -- there is a passing test that parses a *Linux-shaped* description on a Windows-only crate -- and nothing currently distinguishes either from `discover()`. M1 adds a three-state marker defaulting to the untrusted value, so forgetting is safe and claiming is deliberate, with serde downgrading on load so a file cannot assert it is this machine. M2 makes it loud in the fingerprint string, which is documented as canonical for comparison and would otherwise let a synthetic host compare equal to a real one. M3 decides whether the probes' synthetic hosts should route through `Topology`. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | | [crates/windows-ioring-sys/CHECKLIST.md](crates/windows-ioring-sys/CHECKLIST.md) | in progress | Memory-safe Rust over the Windows `IoRing` submission/completion ring, as a new crate. M1-M19 are complete (0.2.0 shipped 2026-08-30, restoring availability after all three 0.1.x versions were yanked); M1-M18 are archived. **M20** queues documentation and policy-test repairs from the 2026-08-30 NUMA-sharding measurement, and the pinned-thread `M6+` work stays parked. | [crates/windows-ioring-sys/DESIGN-NOTES.md](crates/windows-ioring-sys/DESIGN-NOTES.md) | From eb83f60675cdb5c39953a8f4181b2e4910b5c53a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 11:43:32 -0400 Subject: [PATCH 045/361] feat(topology)!: add Provenance, defaulting to the untrusted value This crate deliberately lets a topology be read from the system, built by hand, or deserialized from a description written for -- in its own documentation's words -- a machine you do not have. Without a marker the three are indistinguishable once built. Synthetic is Default, which is the load-bearing choice: a topology assembled without a thought about provenance comes out tainted, so a caller must do work to claim data is real rather than work to admit it is not. The variants are ordered Synthetic < Restored < Measured so the derived Ord is the trust order and min() implements 'never upgrade'. The threat model is accident, not forgery. A caller who writes provenance: Measured over fabricated data has lied deliberately and no type in a crate with public fields prevents that. Deserialization is the exception and is handled in TP-1.3. Completed item: TP-1.1: Add Provenance to windows-topology-sys with three states ordered by trust, Synthetic as Default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-topology-provenance.md | 2 +- crates/windows-topology-sys/src/lib.rs | 3 + crates/windows-topology-sys/src/provenance.rs | 125 ++++++++++++++++++ .../src/provenance/tests.rs | 95 +++++++++++++ 4 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 crates/windows-topology-sys/src/provenance.rs create mode 100644 crates/windows-topology-sys/src/provenance/tests.rs diff --git a/CHECKLIST-topology-provenance.md b/CHECKLIST-topology-provenance.md index 3efeef82..9eb6e605 100644 --- a/CHECKLIST-topology-provenance.md +++ b/CHECKLIST-topology-provenance.md @@ -20,7 +20,7 @@ Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is wh ## M1: the marker, and its invariants -- [ ] **TP-1.1** -- Add `Provenance` to `windows-topology-sys` with three states ordered by trust: +- [x] **TP-1.1** -- Add `Provenance` to `windows-topology-sys` with three states ordered by trust: `Measured` (read from the running system), `Restored` (deserialized from a description of some machine), `Synthetic` (constructed by hand). **`Synthetic` is `Default`.** That is the load-bearing choice: `Topology::default()`, `..Default::default()`, and any construction that omits the field all diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 7e6e97c0..b8a3ce0f 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -64,6 +64,8 @@ mod domain; #[cfg(windows)] mod processor_set; +/// Where a topology's content came from. +mod provenance; #[cfg(windows)] mod relation; #[cfg(windows)] @@ -75,6 +77,7 @@ mod walk; pub use domain::{AttributeValue, Distances, Domain, DomainKind, Processor, ProcessorId}; #[cfg(windows)] pub use processor_set::ProcessorSet; +pub use provenance::Provenance; #[cfg(windows)] pub use relation::{ CacheKind, CacheRelation, CoreRelation, GroupRelation, NumaNodeRelation, PackageRelation, diff --git a/crates/windows-topology-sys/src/provenance.rs b/crates/windows-topology-sys/src/provenance.rs new file mode 100644 index 00000000..d538649f --- /dev/null +++ b/crates/windows-topology-sys/src/provenance.rs @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Mike Grier +//! Where a topology's content came from. + +use std::fmt; + +/// Where a [`Topology`](crate::Topology)'s content came from. +/// +/// # Why this exists +/// +/// This crate deliberately lets a topology be built three ways: read from the +/// running system, constructed by hand, or deserialized from a description +/// written for -- in the crate documentation's own words -- "a machine you do +/// not have". That is a feature, and it is the reason a marker is needed: +/// without one the three are indistinguishable once built, and a consumer +/// handed a fabricated or foreign topology treats it as this machine's truth. +/// +/// The failure that motivates this is not exotic. A probe measuring NUMA +/// behaviour on a machine with no NUMA needs a synthetic topology to test its +/// selection logic; the whole point of a probe is that its output is believed. +/// A number produced against fabricated topology and quoted without a label is +/// worse than no number, because nothing downstream can tell. +/// +/// # Ordering is trust +/// +/// The variants are ordered `Synthetic < Restored < Measured`, so the derived +/// `Ord` *is* the trust order and [`Ord::min`] implements "never upgrade". +/// [`Self::downgraded_to`] relies on this. +/// +/// # The default is the untrusted value, on purpose +/// +/// [`Self::Synthetic`] is [`Default`], so a `Topology` built by +/// [`Default::default`], completed with `..Default::default()`, or otherwise +/// assembled without a thought about provenance comes out **tainted**. A caller +/// must do work to claim data is real, rather than work to admit it is not. +/// Getting this backwards would mean every forgetful construction silently +/// asserts it measured the machine. +/// +/// # The threat model is accident, not forgery +/// +/// A caller who writes `provenance: Provenance::Measured` over data they +/// fabricated has lied deliberately, and no type in a crate with public fields +/// prevents that. This defends against *forgetting*, which is the thing that +/// actually happens. The one place forgery is refused is deserialization, where +/// the input is a file rather than a line of code someone had to write on +/// purpose -- see [`Self::downgraded_to`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum Provenance { + /// Constructed by hand, or by [`Default`]. Describes no machine in + /// particular, and is the default precisely so that forgetting is safe. + #[default] + Synthetic, + /// Deserialized from a description. It may faithfully describe some real + /// machine -- but not necessarily *this* one, and nothing in the file can + /// establish which. + Restored, + /// Read from the running system by [`Topology::discover`](crate::Topology::discover). + /// The only variant that asserts "this is the machine you are on". + Measured, +} + +impl Provenance { + /// Whether this describes the machine actually running the code. + /// + /// The single question most consumers want to ask, named so that the + /// answer cannot be got wrong by comparing against the wrong variant. + #[must_use] + pub fn is_measured(self) -> bool { + self == Self::Measured + } + + /// This provenance, or `ceiling` if this one claims more trust. + /// + /// Only ever lowers. Deserialization uses it with a ceiling of + /// [`Self::Restored`] so a description saying `"measured"` is not honoured: + /// a file cannot establish that it is the machine you are on, however + /// sincerely it asserts it. A description saying `"synthetic"` stays + /// synthetic, because the ceiling is a maximum and not an assignment. + #[must_use] + pub fn downgraded_to(self, ceiling: Self) -> Self { + self.min(ceiling) + } + + /// A short word for a rendered form. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Synthetic => "SYNTHETIC", + Self::Restored => "RESTORED", + Self::Measured => "measured", + } + } +} + +impl fmt::Display for Provenance { + /// Renders the untrusted variants in capitals and the trusted one in lower + /// case, so a tainted value is visibly louder than a real one in any string + /// it reaches. See [`Self::label`]. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} + +/// Deserialize a provenance, refusing any claim above [`Provenance::Restored`]. +/// +/// Wired onto [`Topology::provenance`](crate::Topology::provenance) so the rule +/// holds for every description, including one hand-edited to claim it was +/// measured. +/// +/// # Errors +/// +/// Returns whatever the underlying deserializer failed with. +#[cfg(feature = "serde")] +pub fn deserialize_downgraded<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::Deserialize; + + Ok(Provenance::deserialize(deserializer)?.downgraded_to(Provenance::Restored)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-topology-sys/src/provenance/tests.rs b/crates/windows-topology-sys/src/provenance/tests.rs new file mode 100644 index 00000000..d44d47bd --- /dev/null +++ b/crates/windows-topology-sys/src/provenance/tests.rs @@ -0,0 +1,95 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`Provenance`](super::Provenance). + +use super::Provenance; + +#[test] +fn the_default_is_the_untrusted_value() { + // The load-bearing property. If this ever flips, every construction that + // forgets to name a provenance silently starts asserting it measured the + // machine, which is the exact failure this type exists to prevent. + assert_eq!(Provenance::default(), Provenance::Synthetic); + assert!(!Provenance::default().is_measured()); +} + +#[test] +fn the_ordering_is_the_trust_order() { + assert!(Provenance::Synthetic < Provenance::Restored); + assert!(Provenance::Restored < Provenance::Measured); +} + +#[test] +fn only_measured_reports_as_measured() { + assert!(Provenance::Measured.is_measured()); + assert!(!Provenance::Restored.is_measured()); + assert!(!Provenance::Synthetic.is_measured()); +} + +#[test] +fn downgrading_lowers_a_higher_claim() { + assert_eq!( + Provenance::Measured.downgraded_to(Provenance::Restored), + Provenance::Restored + ); +} + +#[test] +fn downgrading_leaves_an_equal_or_lower_claim_alone() { + // The ceiling is a maximum, not an assignment: a synthetic description must + // not be promoted to restored just because it passed through a loader. + assert_eq!( + Provenance::Restored.downgraded_to(Provenance::Restored), + Provenance::Restored + ); + assert_eq!( + Provenance::Synthetic.downgraded_to(Provenance::Restored), + Provenance::Synthetic + ); +} + +#[test] +fn downgrading_never_raises_for_any_pair() { + // Exhaustive over the whole type, so a variant added later cannot quietly + // acquire an upgrade path. + let all = [ + Provenance::Synthetic, + Provenance::Restored, + Provenance::Measured, + ]; + for value in all { + for ceiling in all { + let result = value.downgraded_to(ceiling); + assert!( + result <= value, + "{value:?} downgraded to {ceiling:?} produced the higher {result:?}" + ); + assert!( + result <= ceiling, + "{value:?} downgraded to {ceiling:?} exceeded the ceiling" + ); + } + } +} + +#[test] +fn the_untrusted_labels_are_louder_than_the_trusted_one() { + // Deliberate asymmetry: a tainted value must stand out in any string it + // reaches, and a reader scanning output should not have to know the + // vocabulary to notice something is off. + assert_eq!(Provenance::Synthetic.to_string(), "SYNTHETIC"); + assert_eq!(Provenance::Restored.to_string(), "RESTORED"); + assert_eq!(Provenance::Measured.to_string(), "measured"); + + for tainted in [Provenance::Synthetic, Provenance::Restored] { + let rendered = tainted.to_string(); + assert_eq!( + rendered, + rendered.to_uppercase(), + "{tainted:?} does not render in capitals" + ); + } + assert_ne!( + Provenance::Measured.to_string(), + Provenance::Measured.to_string().to_uppercase() + ); +} From afe293008295618c92b9a30dec42bc87ba310d8f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 11:47:32 -0400 Subject: [PATCH 046/361] feat(topology)!: carry provenance on Topology, and refuse to load a measured claim TP-1.2 and TP-1.3 land together because they are one change. The serde behaviour is an attribute on the field itself, so adding the field without deciding what deserialization does with it would commit a half-defined contract -- there is no intermediate state worth having. discover() stamps Measured; from_relations stamps Synthetic. The transform is a pure function of whatever relations it is handed and cannot know where they came from, so the claim belongs to the call that asked the operating system. A future second caller of the transform then does not silently inherit an assertion it has not earned. Deserialization can only downgrade: a description claiming "measured" loads as Restored, one claiming "synthetic" is not promoted, and one with no field at all loads as Synthetic. The marker is still serialized, so it stays visible in the persisted form -- the goal is that a tainted topology is loud, not that it is unwritable. The consequence is intended: a measured topology cannot be archived and reloaded as measured, because what you reload is a description of a machine rather than a statement about the host reading it. One existing test asserted a discovered topology round-trips unchanged, which is now deliberately false. It is rewritten rather than relaxed: it still compares against the whole original with only the provenance adjusted, so a second corruption introduced elsewhere in the round trip would still fail it. Verified by sabotage -- removing the downgrade fails three tests including that round-trip one. Only one construction site in the workspace needed updating, so the breaking change is real but narrow. Completed item: TP-1.2: Add the field to Topology and set Measured in discover(), updating every hand-construction site. Completed item: TP-1.3: Serde serializes the marker and downgrades on load, never upgrading. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-topology-provenance.md | 4 +- crates/windows-topology-sys/DESIGN-NOTES.md | 50 ++++++ crates/windows-topology-sys/src/topology.rs | 28 +++- .../src/topology/tests.rs | 144 +++++++++++++++++- 4 files changed, 221 insertions(+), 5 deletions(-) diff --git a/CHECKLIST-topology-provenance.md b/CHECKLIST-topology-provenance.md index 9eb6e605..d90f0c68 100644 --- a/CHECKLIST-topology-provenance.md +++ b/CHECKLIST-topology-provenance.md @@ -28,12 +28,12 @@ Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is wh Document that the threat model is *accident*, not forgery -- a caller who writes `provenance: Measured` over fabricated data has lied deliberately, and no type prevents that. -- [ ] **TP-1.2** -- Add the field to `Topology` and set `Measured` in `discover()`. This is a **breaking +- [x] **TP-1.2** -- Add the field to `Topology` and set `Measured` in `discover()`. This is a **breaking change** for struct-literal construction, and deliberately so: every existing site is forced to state which kind of data it holds. Update the crate's own tests and every dependent that constructs a `Topology` by hand. -- [ ] **TP-1.3** -- Serde: serialize the marker so it is *visible* in the persisted form, and +- [x] **TP-1.3** -- Serde: serialize the marker so it is *visible* in the persisted form, and **downgrade on load** -- `Measured` becomes `Restored`, everything else is unchanged. The rule is **never upgrade**, so a hand-edited `"provenance": "measured"` is ignored rather than honoured. A description absent the field loads as `Synthetic`. Test each of the four load cases, including that a diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 15938da6..ffbd2756 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -31,6 +31,56 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-9 | **Deliberately excluded, with reasons.** See the detail section below. **No work is scheduled against any of it** -- the absence of checklist items is intentional, not an oversight. | | D-10 | **The description is platform-neutral; platform constraints live in the planner.** A description sourced from Linux will have one group possibly containing more than 64 processors, which is unrepresentable as a Windows affinity mask. The schema does not enforce the Windows limit. A Windows planner consuming such a description must reject or split it rather than silently emitting an affinity mask that cannot exist. Keeping the constraint in the planner is what allows a description of a machine to be written on, and for, a different platform. | | D-11 | **A `Memory` domain's `memory_bytes` is `Option`, not a bare `u64`, because Windows's own enumeration cannot report it.** `GetLogicalProcessorInformationEx`'s NUMA-node relationship carries a processor set and a node number, never a capacity; measuring node memory would mean a different API entirely. A `Topology` this crate discovers therefore always sets `memory_bytes: None` for every memory domain it produces from `RelationNumaNode`/`RelationNumaNodeEx`. Using `Some(0)` as a stand-in would be indistinguishable from "this node genuinely has no memory," which is exactly the CXL-expander case D-5 exists to represent honestly; `None` is the only choice that does not silently invent data. A hand-written or fed-in description may still supply a real value. | +| D-12 | **A topology carries its own provenance, and the untrusted value is the default.** This crate deliberately lets a topology be discovered, built by hand, or deserialized from a description written for a machine you do not have -- and until now the three were indistinguishable once built. [`Provenance`] is `Synthetic` by `Default`, so forgetting is safe and claiming is deliberate; only `discover` yields `Measured`; and deserialization can only ever *downgrade*, so a file cannot assert it is the machine you are on. | + +## D-12: provenance, and why the default points at distrust + +Three ways to obtain a `Topology` are supported on purpose, and the crate's own front page advertises +the third: "deserialize one from JSON written for a machine you do not have". That is a feature -- it +is how a consumer tests against hardware it lacks, and this workspace needs it right now, because +`probe-core-affinity` must exercise NUMA selection logic on hosts that have exactly one NUMA node. + +The hazard is that **the resulting value looked exactly like a discovered one**. There is a passing +test in this crate that parses a *Linux-shaped* description, complete with an ACPI SLIT-style distance +matrix, on a Windows-only crate. Nothing downstream could tell that apart from the machine it was +running on. + +Three decisions make the marker hard to lose. + +**`Synthetic` is `Default`.** This is the load-bearing one. `Topology::default()`, +`..Default::default()`, and every construction that simply does not think about provenance come out +tainted. A caller must do work to claim data is real, rather than work to admit it is not. The reverse +default would mean every forgetful construction silently asserts it read the machine -- which is +precisely the accident this exists to catch, and it would be catastrophically quiet. + +**The variants are ordered by trust**, `Synthetic < Restored < Measured`, so the derived `Ord` *is* the +trust order and `min` implements "never upgrade". `downgraded_to` is that one line, which is why there +is no second, subtly different rule anywhere: a ceiling is a maximum, not an assignment, so passing a +synthetic description through a loader does not launder it into a restored one. + +**Deserialization refuses any claim above `Restored`.** A hand-edited `"provenance": "measured"` is +ignored. This is the one place forgery rather than accident is refused, and the asymmetry is +deliberate: a line of code claiming `Measured` had to be written by someone who meant it, whereas a +JSON file is data that travels, gets copied between machines, and is edited by people who never read +this note. The marker is still *serialized*, so it is visible in the persisted form -- the goal is that +a tainted topology is loud, not that it is unwritable. + +The consequence to be aware of: **a measured topology cannot be archived and reloaded as measured.** +That is intended. What you reload is a description of a machine, and the fact that it was once read +from a real one does not make it a statement about the host doing the reading. + +**The threat model is accident, not forgery.** A caller who writes `provenance: Provenance::Measured` +over data they fabricated has lied deliberately, and no type in a crate with public fields prevents +that. Adding a private field with a constructor was considered and rejected: it would break the +hand-construction the crate deliberately supports (D-8's "plain data" property), for a guarantee that +only holds against an adversary this crate does not have. + +`from_relations` stamps `Synthetic` and `discover` overwrites it with `Measured`, rather than the +transform claiming it. `from_relations` is a pure function of whatever relations it is handed and +cannot know where they came from; putting the claim in `discover` keeps it attached to the act of +asking the operating system, so a future second caller of the transform does not silently inherit an +assertion it has not earned. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 6e515326..fe3b16cb 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -4,6 +4,7 @@ use std::io; use crate::domain::{Distances, Domain, DomainKind, Processor, ProcessorId}; +use crate::provenance::Provenance; use crate::relation::{self, Relations}; /// A processor, cache, and memory topology: a set of processors and the @@ -25,6 +26,21 @@ pub struct Topology { pub domains: Vec, /// An optional scalar distance matrix. pub distances: Option, + /// Where this content came from. + /// + /// **Defaults to [`Provenance::Synthetic`]**, so a topology built by hand + /// or by [`Default`] is tainted unless its author says otherwise. Only + /// [`Self::discover`] produces [`Provenance::Measured`], and + /// deserialization can never produce it -- see + /// [`Provenance`] for why the default points this way. + #[cfg_attr( + feature = "serde", + serde( + default, + deserialize_with = "crate::provenance::deserialize_downgraded" + ) + )] + pub provenance: Provenance, } impl Topology { @@ -36,7 +52,11 @@ impl Topology { /// call. pub fn discover() -> io::Result { let relations = relation::discover()?; - Ok(Self::from_relations(relations)) + let mut topology = Self::from_relations(relations); + // The one place in the crate that may claim this is the machine you are + // on, because it is the one place that asked the operating system. + topology.provenance = Provenance::Measured; + Ok(topology) } fn from_relations(relations: Relations) -> Self { @@ -106,6 +126,12 @@ impl Topology { processors, domains, distances: None, + // Synthetic, not measured: this is a pure transform of whatever + // relations it was handed, and cannot know where they came from. + // `discover` stamps the claim because `discover` is what read the + // machine -- so if this ever gains a second caller, that caller + // does not silently inherit an assertion it has not earned. + provenance: Provenance::Synthetic, } } diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index bd6a71cf..4045238a 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -73,6 +73,9 @@ fn synthetic() -> Topology { }, ], distances: None, + // Named rather than defaulted, so this fixture states what it is. The + // helper is called `synthetic` and now says so in the value too. + provenance: Provenance::Synthetic, } } @@ -168,11 +171,34 @@ mod serde_tests { use super::super::*; #[test] - fn a_discovered_topology_round_trips_through_json_unchanged() { + fn a_discovered_topology_round_trips_through_json_except_for_its_provenance() { + // This test used to assert the round trip was *unchanged*. That is now + // deliberately false, and the change is the point rather than a + // regression: a discovered topology asserts "this is the machine you + // are on", and once written to a file it can no longer assert that. + // Reloading yields `Restored`. + // + // The assertion is deliberately not weakened to "the parts I still + // expect to match". Everything except the provenance must survive + // verbatim, so this compares against the original with only that field + // adjusted -- a second corruption would still fail here. let topology = Topology::discover().expect("discover"); + assert!( + topology.provenance.is_measured(), + "discover must claim the machine it read" + ); + let json = serde_json::to_string(&topology).expect("serialize"); let back: Topology = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(topology, back); + + assert_eq!(back.provenance, Provenance::Restored); + assert_eq!( + back, + Topology { + provenance: Provenance::Restored, + ..topology + } + ); } #[test] @@ -250,3 +276,117 @@ mod serde_tests { ); } } + +#[test] +fn a_hand_built_topology_is_not_measured() { + // The fixture above names `Synthetic` explicitly; this pins down that the + // value survives to a reader, so a consumer asking "is this my machine" + // gets the right answer from hand-built data. + assert_eq!(synthetic().provenance, Provenance::Synthetic); + assert!(!synthetic().provenance.is_measured()); +} + +#[test] +fn a_defaulted_topology_is_not_measured() { + // `Topology::default()` is the easiest way to obtain one and must be the + // safe one. If this ever reports measured, every forgetful construction in + // every dependent silently starts asserting it read the machine. + let topology = Topology::default(); + + assert_eq!(topology.provenance, Provenance::Synthetic); + assert!(!topology.provenance.is_measured()); +} + +#[test] +fn struct_update_syntax_from_default_stays_untrusted() { + // `..Default::default()` is how a caller builds a topology while naming + // only the fields they care about, and provenance is exactly the field + // nobody thinks to name. + let topology = Topology { + distances: None, + ..Default::default() + }; + + assert!(!topology.provenance.is_measured()); +} + +#[cfg(feature = "serde")] +mod serde_provenance { + use super::*; + + fn load(provenance_field: &str) -> Topology { + let json = + format!(r#"{{"processors": [], "domains": [], "distances": null{provenance_field}}}"#); + serde_json::from_str(&json).expect("the description must parse") + } + + #[test] + fn a_description_claiming_measured_is_downgraded_to_restored() { + // The core of the rule. A file cannot establish that it is the machine + // you are running on, however sincerely it asserts it -- and a + // hand-edited description is the obvious way someone would try. + let topology = load(r#", "provenance": "measured""#); + + assert_eq!(topology.provenance, Provenance::Restored); + assert!(!topology.provenance.is_measured()); + } + + #[test] + fn a_description_claiming_restored_stays_restored() { + assert_eq!( + load(r#", "provenance": "restored""#).provenance, + Provenance::Restored + ); + } + + #[test] + fn a_description_claiming_synthetic_is_not_promoted() { + // The ceiling is a maximum, not an assignment: passing through a loader + // must not launder fabricated data into merely-restored data. + assert_eq!( + load(r#", "provenance": "synthetic""#).provenance, + Provenance::Synthetic + ); + } + + #[test] + fn a_description_without_the_field_loads_as_synthetic() { + // Every description written before this field existed takes this path, + // so the default has to be the safe one here too. + assert_eq!(load("").provenance, Provenance::Synthetic); + } + + #[test] + fn a_measured_topology_does_not_survive_a_round_trip_as_measured() { + // The property that makes persistence honest, stated end to end: you + // may archive a real topology, and what you reload is explicitly a + // description of a machine rather than a claim about this one. + let mut measured = synthetic(); + measured.provenance = Provenance::Measured; + + let json = serde_json::to_string(&measured).expect("must serialize"); + assert!( + json.contains("measured"), + "the marker is not visible in the persisted form: {json}" + ); + + let reloaded: Topology = serde_json::from_str(&json).expect("must parse"); + assert_eq!(reloaded.provenance, Provenance::Restored); + assert!(!reloaded.provenance.is_measured()); + } + + #[test] + fn everything_but_the_provenance_round_trips_unchanged() { + // The downgrade must be the *only* thing a round trip changes, + // otherwise this would be trading one silent corruption for another. + let mut measured = synthetic(); + measured.provenance = Provenance::Measured; + + let json = serde_json::to_string(&measured).expect("must serialize"); + let reloaded: Topology = serde_json::from_str(&json).expect("must parse"); + + assert_eq!(reloaded.processors, measured.processors); + assert_eq!(reloaded.domains, measured.domains); + assert_eq!(reloaded.distances, measured.distances); + } +} From e9948e173eec9494bba960576077531b351f543c Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 11:54:55 -0400 Subject: [PATCH 047/361] feat(probes): render topology provenance inside the fingerprint string TP-2.1 and TP-2.2 land together because 2.2 is satisfied by construction once 2.1 exists: the banner embeds the fingerprint's own Display rather than re-rendering, so there is no second rendering that could drift. The marker goes inside the string rather than beside it, and that is forced rather than stylistic. The fingerprint is documented as canonical -- two hosts rendering the same string can express the same placements, so string equality is a supported comparison. A marker kept alongside would leave a fabricated machine claiming the exact shape of a real one comparing equal to it. There is a test named for that bug. A measured host renders exactly as before, with no prefix, so every fingerprint already recorded in a checklist or design note stays valid rather than being silently reinterpreted. Verified against the real probes: their banners are byte-identical to what they printed yesterday. The prefix leads, so a reader scanning pasted results cannot skip it, and it is removable -- stripping it yields exactly the measured rendering, so a synthetic host can still be compared against a real one deliberately. RESTORED and SYNTHETIC stay distinguished rather than collapsing into one "untrusted": they are different claims, and a reader deciding how far to believe a number needs to know which. Fingerprint::from_topology makes provenance flow rather than be stamped on afterwards; discover is now a thin wrapper, so no path invents an answer. print_banner is split so the line is available as a string, because the marker reaching it is the whole point and should not rest on someone having read a format string correctly. Slice deliberately gains no marker of its own. A slice can only exist from a real measure() run -- measure takes no injected topology and pinning to a processor that does not exist panics -- and it is always printed beneath the banner. If measure ever gains such a seam that reasoning collapses and Slice needs its own marker, which is recorded as a second reason not to add the seam. Verified by sabotage: suppressing the marker fails five tests including the equality one. Completed item: TP-2.1: Fingerprint carries the provenance and renders it first and unmissably when it is not Measured. Completed item: TP-2.2: Every probe banner inherits it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-topology-provenance.md | 16 ++- .../windows-platform-probes/DESIGN-NOTES.md | 29 ++++ .../src/fingerprint.rs | 64 ++++++++- .../src/fingerprint/tests.rs | 132 ++++++++++++++++++ 4 files changed, 233 insertions(+), 8 deletions(-) diff --git a/CHECKLIST-topology-provenance.md b/CHECKLIST-topology-provenance.md index d90f0c68..55195e19 100644 --- a/CHECKLIST-topology-provenance.md +++ b/CHECKLIST-topology-provenance.md @@ -41,16 +41,28 @@ Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is wh ## M2: making it loud where it is read -- [ ] **TP-2.1** -- `Fingerprint` in [crates/windows-platform-probes/src/fingerprint.rs](crates/windows-platform-probes/src/fingerprint.rs) +- [x] **TP-2.1** -- `Fingerprint` in [crates/windows-platform-probes/src/fingerprint.rs](crates/windows-platform-probes/src/fingerprint.rs) carries the provenance and renders it **first and unmissably** when it is not `Measured`. The fingerprint string is documented as canonical, so string equality is a usable comparison -- which means the marker must be *inside* the string, or a synthetic host could compare equal to a real one. That is the specific bug this prevents, not merely a display nicety. -- [ ] **TP-2.2** -- Every probe banner and every persisted probe line inherits it, since +- [x] **TP-2.2** -- Every probe banner and every persisted probe line inherits it, since `print_banner` and `Slice` are what end up pasted into checklists and design notes. A number quoted from a synthetic run must arrive already labelled, because the label is what a reader will not think to ask for. + **Done, and the banner inherits it by construction** -- it embeds the fingerprint's own `Display` + rather than re-rendering, so the two cannot drift. `print_banner` was split so the line is available + as a string (`banner_line`) and the marker's arrival is asserted rather than confirmed by reading a + format string. + **`Slice` deliberately carries no marker of its own, and the reason is structural rather than an + oversight.** A `Slice` records which processors a measurement was pinned to, and one can only exist + from a real `measure()` run: `measure` takes no injected topology (and + [crates/windows-platform-probes/src/core_affinity.rs](crates/windows-platform-probes/src/core_affinity.rs) + now documents why it must not), and pinning to a processor that does not exist panics. A slice is + therefore always real, and it is always printed beneath the banner that carries the host's + provenance. **If `measure` ever does gain such a seam, this reasoning collapses and `Slice` needs its + own marker** -- which is a second, independent reason not to add one. ## M3: closing the loop with the probes diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 287b1a90..7d61bb5f 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -573,3 +573,32 @@ that confirms the null should do. Like `probe-queue-contention`, this probe is **absent from the CI probe job**, and for the same measured reason: the effects it studies are coherence effects that a debug build's overhead buries. + +## The fingerprint carries provenance inside the string, not beside it + +The fingerprint is documented as **canonical**: two hosts rendering the same string can express the +same placements, so string equality is a supported comparison. That property is what forces the +provenance marker to live *inside* the rendered form. A marker kept alongside -- a separate field, a +second printed line, a note in the surrounding prose -- would leave a fabricated machine claiming the +exact shape of a real one **comparing equal to it**. That is a concrete bug rather than a display +preference, and it has a test named for it. + +Three details are deliberate: + +- **A measured host renders exactly as before, with no prefix.** Every fingerprint already recorded in + a checklist or design note came from a real machine, so those strings stay valid and comparable + rather than being silently reinterpreted by this change. +- **The prefix leads**, so a reader scanning a column of pasted results cannot skip it, and it is + removable -- stripping `!!SYNTHETIC!! ` yields exactly the measured rendering, so a synthetic host + can still be compared against a real one on purpose. +- **`RESTORED` and `SYNTHETIC` are distinguished** rather than collapsed into one "untrusted". They + are different claims: one describes some real machine, the other describes none, and a reader + deciding how far to believe a number needs to know which. + +`Fingerprint::from_topology` exists so provenance *flows* from the topology rather than being stamped +on afterwards. `discover` is now a thin wrapper over it, which means there is no path that invents an +answer -- whatever the topology says is what the fingerprint reports. + +`print_banner` was split so the line is available as a string. The taint marker reaching that line is +the entire point of carrying provenance, and a property that load-bearing should not rest on someone +having read a format string correctly. diff --git a/crates/windows-platform-probes/src/fingerprint.rs b/crates/windows-platform-probes/src/fingerprint.rs index 4b11d67b..7d619518 100644 --- a/crates/windows-platform-probes/src/fingerprint.rs +++ b/crates/windows-platform-probes/src/fingerprint.rs @@ -25,6 +25,7 @@ //! ```text //! aarch64 12p/12c smt- L2[6,6] ec[0:6,1:6] numa[12] //! x86_64 16p/8c smt+ L3[16] ec[0:16] numa[16] +//! !!SYNTHETIC!! x86_64 32p/16c smt+ L3[16,16] ec[0:32] numa[16,16] //! ``` //! //! - `` -- the target architecture. @@ -41,6 +42,17 @@ //! entry means a homogeneous machine. //! - `numa[...]` -- processors per NUMA node. //! +//! A line **prefixed `!!SYNTHETIC!!` or `!!RESTORED!!` did not come from the +//! machine that printed it.** The first was fabricated; the second was loaded +//! from a description of some machine, which is not the same as a description +//! of this one. A measured host carries no prefix at all, so every fingerprint +//! recorded before this marker existed remains valid and comparable. +//! +//! The prefix is deliberately inside the string rather than reported beside +//! it. Because the string is canonical (below), a marker kept outside would let +//! a synthetic host compare *equal* to a real one -- and the comparison is the +//! whole point of having a canonical form. +//! //! **It is canonical**, so two hosts that render the same string can express //! the same placements, and string equality is a usable comparison. It //! deliberately omits clock speeds, cache sizes, and model names: those vary @@ -49,7 +61,7 @@ use std::fmt; -use windows_topology_sys::{DomainKind, Topology}; +use windows_topology_sys::{DomainKind, Provenance, Topology}; /// One logical processor's position in the machine. /// @@ -217,6 +229,15 @@ pub struct Fingerprint { pub efficiency_classes: Vec<(u8, usize)>, /// Processors per NUMA node, ascending. pub numa_node_sizes: Vec, + /// Where the topology behind this fingerprint came from. + /// + /// Rendered *inside* the string, not beside it. The fingerprint is + /// documented as canonical -- two hosts rendering the same string can + /// express the same placements, so string equality is a usable comparison. + /// A marker kept outside the string would leave a synthetic host comparing + /// equal to a real one, which is the specific bug this prevents rather than + /// a display nicety. + pub provenance: Provenance, } impl Fingerprint { @@ -226,8 +247,17 @@ impl Fingerprint { /// /// Returns whatever [`Topology::discover`] failed with. pub fn discover() -> std::io::Result { - let topology = Topology::discover()?; + Ok(Self::from_topology(&Topology::discover()?)) + } + /// Read a shape from any topology, discovered or not. + /// + /// Separate from [`Self::discover`] so provenance *flows* rather than being + /// stamped on afterwards: whatever the topology says about where it came + /// from is what the fingerprint reports, and there is no path here that + /// invents the answer. + #[must_use] + pub fn from_topology(topology: &Topology) -> Self { let cores: Vec<_> = topology.cores().collect(); let processors: usize = cores.iter().map(|core| core.processors.len()).sum(); let smt = cores.iter().any(|core| core.processors.len() > 1); @@ -277,7 +307,7 @@ impl Fingerprint { .collect(); numa_node_sizes.sort_unstable(); - Ok(Self { + Self { arch: std::env::consts::ARCH, processors, cores: cores.len(), @@ -286,7 +316,8 @@ impl Fingerprint { cache_domain_sizes, efficiency_classes, numa_node_sizes, - }) + provenance: topology.provenance, + } } /// Whether this machine is heterogeneous. @@ -303,7 +334,17 @@ impl Fingerprint { } impl fmt::Display for Fingerprint { + /// Renders the shape, preceded by a taint marker when the topology behind + /// it was not measured. + /// + /// A measured fingerprint renders exactly as it always did, so every string + /// already recorded in a checklist or design note stays valid and + /// comparable. Only the untrusted cases gain a prefix, and they gain it at + /// the *front*, where a reader scanning a column of results cannot skip it. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if !self.provenance.is_measured() { + write!(f, "!!{}!! ", self.provenance)?; + } write!( f, "{} {}p/{}c smt{}", @@ -409,9 +450,20 @@ pub fn discover_places() -> std::io::Result> { /// having, and is far better than one that refused to run. But it says so /// loudly, because an unlabelled number is what this exists to prevent. pub fn print_banner() { + println!("{}", banner_line()); +} + +/// The banner as a string, so what a probe prints can be asserted rather than +/// inspected. +/// +/// Separated from [`print_banner`] for one reason: the taint marker reaching +/// this line is the whole point of carrying provenance, and a property that +/// matters that much should not rest on a human having read the format string. +#[must_use] +pub fn banner_line() -> String { match Fingerprint::discover() { - Ok(fingerprint) => println!("host: {fingerprint}"), - Err(error) => println!("host: UNKNOWN -- topology discovery failed: {error}"), + Ok(fingerprint) => format!("host: {fingerprint}"), + Err(error) => format!("host: UNKNOWN -- topology discovery failed: {error}"), } } diff --git a/crates/windows-platform-probes/src/fingerprint/tests.rs b/crates/windows-platform-probes/src/fingerprint/tests.rs index e802085a..dbf948bd 100644 --- a/crates/windows-platform-probes/src/fingerprint/tests.rs +++ b/crates/windows-platform-probes/src/fingerprint/tests.rs @@ -10,6 +10,8 @@ //! assertion about its output would be an assertion about whatever hardware //! happens to run the suite. +use windows_topology_sys::Provenance; + use super::Fingerprint; /// The development host: 12 cores, no SMT, two L2 domains, two efficiency @@ -24,6 +26,10 @@ fn arm64_dev_host() -> Fingerprint { cache_domain_sizes: vec![6, 6], efficiency_classes: vec![(0, 6), (1, 6)], numa_node_sizes: vec![12], + // These fixtures stand in for real hosts, so they render as real hosts + // do -- without a taint prefix. The exact-string assertions below are + // assertions about what those machines actually print. + provenance: Provenance::Measured, } } @@ -38,6 +44,7 @@ fn x64_smt_host() -> Fingerprint { cache_domain_sizes: vec![16], efficiency_classes: vec![(0, 16)], numa_node_sizes: vec![16], + provenance: Provenance::Measured, } } @@ -86,6 +93,7 @@ fn a_machine_no_cache_partitions_says_so() { cache_domain_sizes: vec![4], efficiency_classes: vec![(0, 4)], numa_node_sizes: vec![4], + provenance: Provenance::Measured, }; assert!( flat.to_string().contains("L-[4]"), @@ -158,3 +166,127 @@ fn every_field_that_changes_the_answer_appears_in_the_render() { ); } } + +#[test] +fn a_measured_host_renders_without_any_marker() { + // Every fingerprint recorded in a checklist or design note before the + // marker existed was measured, so this is what keeps those strings valid + // and comparable rather than silently reinterpreted. + let rendered = arm64_dev_host().to_string(); + + assert!(!rendered.contains("!!"), "got {rendered}"); + assert!(rendered.starts_with("aarch64"), "got {rendered}"); +} + +#[test] +fn a_synthetic_host_is_marked_at_the_front() { + let mut fabricated = x64_smt_host(); + fabricated.provenance = Provenance::Synthetic; + + let rendered = fabricated.to_string(); + + assert!( + rendered.starts_with("!!SYNTHETIC!! "), + "the marker must lead, so a reader scanning a column cannot skip it: {rendered}" + ); +} + +#[test] +fn a_restored_host_is_marked_and_says_which_kind_of_untrusted_it_is() { + // Restored and synthetic are different claims -- one describes some real + // machine, the other describes none -- and a reader deciding how much to + // believe a number needs to know which. + let mut loaded = x64_smt_host(); + loaded.provenance = Provenance::Restored; + + let rendered = loaded.to_string(); + + assert!(rendered.starts_with("!!RESTORED!! "), "got {rendered}"); + assert!(!rendered.contains("SYNTHETIC"), "got {rendered}"); +} + +#[test] +fn an_untrusted_host_never_compares_equal_to_the_real_one_it_imitates() { + // The specific bug the marker exists to prevent, and the reason it lives + // inside the string rather than beside it. The fingerprint is documented as + // canonical, so equality of the rendered form is a supported comparison -- + // which means a fabricated machine claiming the exact shape of a real one + // must not produce the same string. + let real = x64_smt_host(); + for untrusted in [Provenance::Synthetic, Provenance::Restored] { + let mut imitation = x64_smt_host(); + imitation.provenance = untrusted; + + assert_eq!( + imitation.processors, real.processors, + "the fixtures must otherwise be identical for this test to mean anything" + ); + assert_ne!( + imitation.to_string(), + real.to_string(), + "{untrusted:?} rendered identically to a measured host" + ); + } +} + +#[test] +fn the_marker_is_the_only_difference_an_untrusted_host_renders() { + // The taint must not disturb the shape it prefixes, or a tainted + // fingerprint could not be compared against a real one at all -- which is + // exactly what someone validating synthetic selection logic needs to do. + let real = x64_smt_host(); + let mut fabricated = x64_smt_host(); + fabricated.provenance = Provenance::Synthetic; + + let rendered = fabricated.to_string(); + let stripped = rendered + .strip_prefix("!!SYNTHETIC!! ") + .expect("the marker must be a removable prefix"); + + assert_eq!(stripped, real.to_string()); +} + +#[test] +fn a_fingerprint_read_from_this_machine_reports_itself_as_measured() { + // Ties the rendering to the real path: `discover` goes through + // `Topology::discover`, which is the only thing entitled to claim the + // machine. If provenance ever stopped flowing, every probe banner would + // quietly start printing a taint marker -- or worse, stop printing one. + let fingerprint = Fingerprint::discover().expect("this machine must be discoverable"); + + assert!(fingerprint.provenance.is_measured()); + assert!(!fingerprint.to_string().contains("!!")); +} + +#[test] +fn a_fingerprint_built_from_a_hand_made_topology_is_not_measured() { + // The path a synthetic host takes. `Topology::default` is untrusted by + // construction, and `from_topology` must carry that through rather than + // inventing an answer. + let fingerprint = Fingerprint::from_topology(&windows_topology_sys::Topology::default()); + + assert!(!fingerprint.provenance.is_measured()); + assert!( + fingerprint.to_string().starts_with("!!SYNTHETIC!! "), + "got {fingerprint}" + ); +} + +#[test] +fn the_banner_carries_whatever_the_fingerprint_says() { + // On this machine the topology is real, so the banner must be clean. The + // point is that the banner is not a second, independent rendering that + // could drift from the fingerprint's own. + let banner = super::banner_line(); + let fingerprint = Fingerprint::discover().expect("this machine must be discoverable"); + + assert!(banner.starts_with("host: "), "got {banner}"); + assert!( + banner.contains(&fingerprint.to_string()), + "the banner must embed the fingerprint verbatim: {banner}" + ); + assert!( + !banner.contains("!!"), + "a real machine's banner must carry no taint marker: {banner}" + ); +} From 919d0448d331c2450f69a68bbf8cf291844c50ae Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 12:01:09 -0400 Subject: [PATCH 048/361] test(probes): make the topology conversion testable, closing an unverified NUMA mapping TP-3.1 asked which level the synthetic hosts belong at. The answer is both, and the evidence that settled it was a hole rather than a preference. classify, representative_pairs and node_pairs take ProcessorPlace -- that is their input type, so those fixtures test them at their own boundary and stay. But discover_places, which carries the rules for the partitioning cache level, core and class membership, and the NUMA node, took no argument and appeared in zero tests. It was untestable, not merely untested. That mattered concretely. The NUMA lookup added earlier could not be verified on any host available here: with a single node, a correct map and a completely broken one both yield node 0. Replacing the whole lookup with a hardcoded 0 was run against the suite as it stood before this change and it passed everything. Against the suite now, three tests fail. The ProcessorPlace fixtures could never have caught it, because they encode what a test author assumed the conversion produces -- which is exactly the depend-on-incidental-behavior trap rather than a gap in their coverage. Adds a pure places_from_topology seam. measure() still has none, and the distinction is the rule worth keeping: a seam that only moves data is safe, a seam that lets fabricated labels reach real hardware is not. A synthetic topology fed to a conversion yields synthetic positions, which is what the caller asked for. Fed to measure() it would yield genuine timings under fabricated node ids, because the processor numbers are still valid on the real host and every pin would succeed. Eight tests build a two-node SMT topology the way Windows reports one and drive it through the real conversion into the classifier end to end. Completes the checklist, so it is archived and its PLANS row moved. Completed item: TP-3.1: Decide whether the synthetic hosts should be expressed as Topology values, and record the decision either way. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-topology-provenance.md | 76 ----- COMPLETED-CHECKLIST.md | 101 +++++++ COMPLETED-PLANS.md | 1 + PLANS.md | 1 - .../windows-platform-probes/DESIGN-NOTES.md | 34 +++ .../src/fingerprint.rs | 30 +- .../src/fingerprint/tests.rs | 283 ++++++++++++++++++ 7 files changed, 446 insertions(+), 80 deletions(-) delete mode 100644 CHECKLIST-topology-provenance.md diff --git a/CHECKLIST-topology-provenance.md b/CHECKLIST-topology-provenance.md deleted file mode 100644 index 55195e19..00000000 --- a/CHECKLIST-topology-provenance.md +++ /dev/null @@ -1,76 +0,0 @@ -# Checklist: topology provenance - -**Problem.** [crates/windows-topology-sys/src/topology.rs](crates/windows-topology-sys/src/topology.rs) -documents that a `Topology` is "built either by `Topology::discover` from the running system, by hand, -or (with the `serde` feature) by deserializing a fed-in description" -- and **nothing distinguishes the -three once built**. `Topology` derives `Default`, has public fields, and derives `Deserialize`. There is -a passing test that parses a *Linux-shaped* description, complete with an ACPI SLIT-style distance -matrix, on a Windows-only crate. A consumer handed that value treats another machine's topology, or a -fabricated one, as this machine's truth. - -This is not hypothetical for the work in flight. `probe-core-affinity` needs synthetic multi-node -topologies precisely because no NUMA machine is available, and the whole point of a probe is that its -output is believed. - -**Decision.** Topology content carries its own provenance, defaulting to the *untrusted* value so that -forgetting is safe and claiming is deliberate. Persisted forms carry it visibly, and loading can only -ever downgrade -- a file cannot assert that it is this machine. - -Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is what surfaced this. - -## M1: the marker, and its invariants - -- [x] **TP-1.1** -- Add `Provenance` to `windows-topology-sys` with three states ordered by trust: - `Measured` (read from the running system), `Restored` (deserialized from a description of some - machine), `Synthetic` (constructed by hand). **`Synthetic` is `Default`.** That is the load-bearing - choice: `Topology::default()`, `..Default::default()`, and any construction that omits the field all - come out tainted, so a caller must do work to claim data is real rather than work to admit it is not. - Document that the threat model is *accident*, not forgery -- a caller who writes - `provenance: Measured` over fabricated data has lied deliberately, and no type prevents that. - -- [x] **TP-1.2** -- Add the field to `Topology` and set `Measured` in `discover()`. This is a **breaking - change** for struct-literal construction, and deliberately so: every existing site is forced to state - which kind of data it holds. Update the crate's own tests and every dependent that constructs a - `Topology` by hand. - -- [x] **TP-1.3** -- Serde: serialize the marker so it is *visible* in the persisted form, and - **downgrade on load** -- `Measured` becomes `Restored`, everything else is unchanged. The rule is - **never upgrade**, so a hand-edited `"provenance": "measured"` is ignored rather than honoured. A - description absent the field loads as `Synthetic`. Test each of the four load cases, including that a - round trip of a measured topology does not come back measured. - -## M2: making it loud where it is read - -- [x] **TP-2.1** -- `Fingerprint` in [crates/windows-platform-probes/src/fingerprint.rs](crates/windows-platform-probes/src/fingerprint.rs) - carries the provenance and renders it **first and unmissably** when it is not `Measured`. The - fingerprint string is documented as canonical, so string equality is a usable comparison -- which - means the marker must be *inside* the string, or a synthetic host could compare equal to a real one. - That is the specific bug this prevents, not merely a display nicety. - -- [x] **TP-2.2** -- Every probe banner and every persisted probe line inherits it, since - `print_banner` and `Slice` are what end up pasted into checklists and design notes. A number quoted - from a synthetic run must arrive already labelled, because the label is what a reader will not think - to ask for. - **Done, and the banner inherits it by construction** -- it embeds the fingerprint's own `Display` - rather than re-rendering, so the two cannot drift. `print_banner` was split so the line is available - as a string (`banner_line`) and the marker's arrival is asserted rather than confirmed by reading a - format string. - **`Slice` deliberately carries no marker of its own, and the reason is structural rather than an - oversight.** A `Slice` records which processors a measurement was pinned to, and one can only exist - from a real `measure()` run: `measure` takes no injected topology (and - [crates/windows-platform-probes/src/core_affinity.rs](crates/windows-platform-probes/src/core_affinity.rs) - now documents why it must not), and pinning to a processor that does not exist panics. A slice is - therefore always real, and it is always printed beneath the banner that carries the host's - provenance. **If `measure` ever does gain such a seam, this reasoning collapses and `Slice` needs its - own marker** -- which is a second, independent reason not to add one. - -## M3: closing the loop with the probes - -- [ ] **TP-3.1** -- Reconsider whether `probe-core-affinity`'s synthetic hosts should be expressed as - `Topology` values rather than as `Vec`. Going through `Topology` would exercise the - provenance path end to end and let a synthetic *NUMA* host drive selection through the real - `discover_places` conversion; staying at `ProcessorPlace` keeps the tests pure and fast. **Decide on - the evidence, and record the decision either way** -- this item is not "do it", it is "choose". - Note the constraint from - [crates/windows-platform-probes/src/core_affinity.rs](crates/windows-platform-probes/src/core_affinity.rs): - `measure()` must still not gain a topology-injection seam, whatever is decided here. diff --git a/COMPLETED-CHECKLIST.md b/COMPLETED-CHECKLIST.md index 9b2f84eb..3ada5e67 100644 --- a/COMPLETED-CHECKLIST.md +++ b/COMPLETED-CHECKLIST.md @@ -1584,3 +1584,104 @@ with the three rejected alternatives). `rust-version`, `channel`, or `edition`, deleting a claim's value, and planting a stale version in prose each produce a distinct located failure; exit 2 is reserved for configuration errors and the script is cwd-independent. + +## Moved 2026-08-31 -- topology provenance: a topology now carries where it came from, and cannot pass as measured + +# Checklist: topology provenance + +**Problem.** [crates/windows-topology-sys/src/topology.rs](crates/windows-topology-sys/src/topology.rs) +documents that a `Topology` is "built either by `Topology::discover` from the running system, by hand, +or (with the `serde` feature) by deserializing a fed-in description" -- and **nothing distinguishes the +three once built**. `Topology` derives `Default`, has public fields, and derives `Deserialize`. There is +a passing test that parses a *Linux-shaped* description, complete with an ACPI SLIT-style distance +matrix, on a Windows-only crate. A consumer handed that value treats another machine's topology, or a +fabricated one, as this machine's truth. + +This is not hypothetical for the work in flight. `probe-core-affinity` needs synthetic multi-node +topologies precisely because no NUMA machine is available, and the whole point of a probe is that its +output is believed. + +**Decision.** Topology content carries its own provenance, defaulting to the *untrusted* value so that +forgetting is safe and claiming is deliberate. Persisted forms carry it visibly, and loading can only +ever downgrade -- a file cannot assert that it is this machine. + +Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is what surfaced this. + +## M1: the marker, and its invariants + +- [x] **TP-1.1** -- Add `Provenance` to `windows-topology-sys` with three states ordered by trust: + `Measured` (read from the running system), `Restored` (deserialized from a description of some + machine), `Synthetic` (constructed by hand). **`Synthetic` is `Default`.** That is the load-bearing + choice: `Topology::default()`, `..Default::default()`, and any construction that omits the field all + come out tainted, so a caller must do work to claim data is real rather than work to admit it is not. + Document that the threat model is *accident*, not forgery -- a caller who writes + `provenance: Measured` over fabricated data has lied deliberately, and no type prevents that. + +- [x] **TP-1.2** -- Add the field to `Topology` and set `Measured` in `discover()`. This is a **breaking + change** for struct-literal construction, and deliberately so: every existing site is forced to state + which kind of data it holds. Update the crate's own tests and every dependent that constructs a + `Topology` by hand. + +- [x] **TP-1.3** -- Serde: serialize the marker so it is *visible* in the persisted form, and + **downgrade on load** -- `Measured` becomes `Restored`, everything else is unchanged. The rule is + **never upgrade**, so a hand-edited `"provenance": "measured"` is ignored rather than honoured. A + description absent the field loads as `Synthetic`. Test each of the four load cases, including that a + round trip of a measured topology does not come back measured. + +## M2: making it loud where it is read + +- [x] **TP-2.1** -- `Fingerprint` in [crates/windows-platform-probes/src/fingerprint.rs](crates/windows-platform-probes/src/fingerprint.rs) + carries the provenance and renders it **first and unmissably** when it is not `Measured`. The + fingerprint string is documented as canonical, so string equality is a usable comparison -- which + means the marker must be *inside* the string, or a synthetic host could compare equal to a real one. + That is the specific bug this prevents, not merely a display nicety. + +- [x] **TP-2.2** -- Every probe banner and every persisted probe line inherits it, since + `print_banner` and `Slice` are what end up pasted into checklists and design notes. A number quoted + from a synthetic run must arrive already labelled, because the label is what a reader will not think + to ask for. + **Done, and the banner inherits it by construction** -- it embeds the fingerprint's own `Display` + rather than re-rendering, so the two cannot drift. `print_banner` was split so the line is available + as a string (`banner_line`) and the marker's arrival is asserted rather than confirmed by reading a + format string. + **`Slice` deliberately carries no marker of its own, and the reason is structural rather than an + oversight.** A `Slice` records which processors a measurement was pinned to, and one can only exist + from a real `measure()` run: `measure` takes no injected topology (and + [crates/windows-platform-probes/src/core_affinity.rs](crates/windows-platform-probes/src/core_affinity.rs) + now documents why it must not), and pinning to a processor that does not exist panics. A slice is + therefore always real, and it is always printed beneath the banner that carries the host's + provenance. **If `measure` ever does gain such a seam, this reasoning collapses and `Slice` needs its + own marker** -- which is a second, independent reason not to add one. + +## M3: closing the loop with the probes + +- [x] **TP-3.1** -- Reconsider whether `probe-core-affinity`'s synthetic hosts should be expressed as + `Topology` values rather than as `Vec`. Going through `Topology` would exercise the + provenance path end to end and let a synthetic *NUMA* host drive selection through the real + `discover_places` conversion; staying at `ProcessorPlace` keeps the tests pure and fast. **Decide on + the evidence, and record the decision either way** -- this item is not "do it", it is "choose". + Note the constraint from + [crates/windows-platform-probes/src/core_affinity.rs](crates/windows-platform-probes/src/core_affinity.rs): + `measure()` must still not gain a topology-injection seam, whatever is decided here. + + **Decided: both, because they are tests of different units -- and the evidence that settled it was a + hole, not a preference.** `classify`, `representative_pairs` and `node_pairs` take `ProcessorPlace`; + that *is* their input type, so `ProcessorPlace` fixtures test them at their own boundary and stay. + What was missing is that `discover_places` -- which carries the rules for which cache level + partitions the machine, which core and class each processor belongs to, and which NUMA node -- took + no argument, called `Topology::discover()` internally, and appeared in **zero tests**. It was not + merely untested; it was untestable. + + **That hole was load-bearing and is now proven closed.** The NUMA lookup added earlier could not be + verified on a single-node host, because a correct map and a completely broken one both yield node 0. + Replacing the whole lookup with a hardcoded `0` was tried against the suite as it stood before this + item: **it passed everything.** Against the suite now, three tests fail. The `ProcessorPlace` + fixtures could never have caught it, because they encode what a test author *assumed* the conversion + produces -- the exact "depend on specified primitives, never on incidental behavior" trap. + + **A pure `places_from_topology` seam was added; `measure()` still has none.** The distinction is the + rule worth keeping: *a seam that only moves data is safe; a seam that lets fabricated labels reach + real hardware is not.* Feeding a synthetic topology to a conversion yields synthetic positions, which + is what the caller asked for and cannot be mistaken for a measurement. Feeding one to `measure()` + would produce genuine timings under fabricated node ids, because a synthetic topology's processor + *numbers* are still valid on the real host and every pin would succeed. diff --git a/COMPLETED-PLANS.md b/COMPLETED-PLANS.md index 3bbced07..92435130 100644 --- a/COMPLETED-PLANS.md +++ b/COMPLETED-PLANS.md @@ -8,6 +8,7 @@ and in [crates/windows-threadpool-sys/COMPLETED-CHECKLIST.md](crates/windows-thr | Path to CHECKLIST.md | Completion Date | Brief description | Design Notes | |---|---|---|---| +| CHECKLIST-topology-provenance.md (archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md)) | 2026-08-31 | Topology content carries its own provenance. `Topology` is documented as constructible by hand and by deserializing a description written for "a machine you do not have", and nothing distinguished either from `discover()`. `Provenance` is `Synthetic` by `Default`, so forgetting is safe and claiming is deliberate; only `discover` yields `Measured`; deserialization can only downgrade, so a file cannot assert it is this machine. The marker renders *inside* the canonical fingerprint string, because a marker beside it would let a fabricated host compare equal to a real one. A pure `places_from_topology` seam was added while `measure()` was deliberately left without one -- a seam that only moves data is safe, one that lets fabricated labels reach real hardware is not -- which closed an unverifiable NUMA mapping: hardcoding the node to 0 passed the entire suite beforehand and fails three tests now. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) `D-12`, [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST.md](CHECKLIST.md) | 2026-08-17 | Workspace metadata, release automation, name reservation, shared cross-crate invariants, generation-stamped operation identities so a retained `OperationId` cannot alias a recycled operation, and six rounds of review hardening: typed wait provenance, teardown-gated re-arming, borrow-checked callback environments, reusable cleanup groups, `stop_and_drain`, borrow-checked exclusivity for the blocking backend, a documented wait-overlap contract, and rejection of values the Win32 fields cannot honour across every adapter. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | 2026-08-17 | Overlapped-I/O foundation complete: endpoints/provenance, operation storage, raw IOCP and blocking backends, cancellation/rundown, submission seam, safe per-family adapters for file read/write plus scatter/gather (`fs`) and sockets on both backends (`socket`), and a buffer-owning but `unsafe` raw-control-code `DeviceIoControl` seam (`device`). | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | | [crates/windows-threadpool-sys/CHECKLIST.md](crates/windows-threadpool-sys/CHECKLIST.md) | 2026-08-17 | Thread pool complete: callback environment, private pools, cleanup groups, work, one-shot and periodic timers as distinct types, waits that own a handle of proven provenance, and the `TP_IO` backend over the shared seam, with examples, documentation, and an opt-in timer stress suite. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | diff --git a/PLANS.md b/PLANS.md index 835c9d19..1b06d36d 100644 --- a/PLANS.md +++ b/PLANS.md @@ -19,7 +19,6 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil |---|---|---|---| | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | not started | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | -| [CHECKLIST-topology-provenance.md](CHECKLIST-topology-provenance.md) | in progress | Topology content carries its own provenance. `Topology` is documented as constructible by hand and by deserializing a fed-in description -- there is a passing test that parses a *Linux-shaped* description on a Windows-only crate -- and nothing currently distinguishes either from `discover()`. M1 adds a three-state marker defaulting to the untrusted value, so forgetting is safe and claiming is deliberate, with serde downgrading on load so a file cannot assert it is this machine. M2 makes it loud in the fingerprint string, which is documented as canonical for comparison and would otherwise let a synthetic host compare equal to a real one. M3 decides whether the probes' synthetic hosts should route through `Topology`. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | | [crates/windows-ioring-sys/CHECKLIST.md](crates/windows-ioring-sys/CHECKLIST.md) | in progress | Memory-safe Rust over the Windows `IoRing` submission/completion ring, as a new crate. M1-M19 are complete (0.2.0 shipped 2026-08-30, restoring availability after all three 0.1.x versions were yanked); M1-M18 are archived. **M20** queues documentation and policy-test repairs from the 2026-08-30 NUMA-sharding measurement, and the pinned-thread `M6+` work stays parked. | [crates/windows-ioring-sys/DESIGN-NOTES.md](crates/windows-ioring-sys/DESIGN-NOTES.md) | diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 7d61bb5f..e6d2952f 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -602,3 +602,37 @@ answer -- whatever the topology says is what the fingerprint reports. `print_banner` was split so the line is available as a string. The taint marker reaching that line is the entire point of carrying provenance, and a property that load-bearing should not rest on someone having read a format string correctly. + +## Which seams are safe: data may be injected, labels may not reach hardware + +Two topology-injection seams were considered during this work and they were decided opposite ways. +The rule that separates them is worth stating on its own, because "add a seam for testability" reads +as unambiguously good and here it is only half true: + +**A seam that only moves data is safe. A seam that lets fabricated labels reach real hardware is +not.** + +- [`places_from_topology`](src/fingerprint.rs) **has** a seam. It is a pure conversion -- topology in, + processor positions out, nothing pinned and nothing timed. A synthetic topology yields synthetic + positions, which is what the caller asked for and cannot be mistaken for a measurement. +- [`measure`](src/core_affinity.rs) **must not**, and its documentation says so at the definition. + A synthetic topology's processor *numbers* are still valid on the real host, so every pin would + succeed and the run would produce genuine timings filed under fabricated node ids -- output + indistinguishable from a real NUMA measurement that measured no such thing. The pin assertion does + not catch it: it rejects a processor that does not exist, not a label that is wrong. + +The absence of the second seam is also what lets `Slice` carry no provenance marker of its own, so +the two decisions hold each other up. + +### The hole this closed, and how it was proven + +`discover_places` took no argument and appeared in no test. It was untestable, not merely untested, +and it carries the rules for the partitioning cache level, core and class membership, and the NUMA +node. The NUMA lookup in particular was **unverifiable on every host available to this workspace**: +with a single node, a correct map and a completely broken one both yield node 0. + +Replacing the entire lookup with a hardcoded `0` was run against the suite as it stood before this +change. **It passed everything.** Against the suite now, three tests fail. That is the difference the +seam bought, and it is why the existing `ProcessorPlace` fixtures were kept rather than treated as +sufficient: they encode what a test author assumed the conversion produces, which is precisely the +thing that cannot catch the conversion being wrong. diff --git a/crates/windows-platform-probes/src/fingerprint.rs b/crates/windows-platform-probes/src/fingerprint.rs index 7d619518..e1798300 100644 --- a/crates/windows-platform-probes/src/fingerprint.rs +++ b/crates/windows-platform-probes/src/fingerprint.rs @@ -388,8 +388,32 @@ impl fmt::Display for Fingerprint { /// /// Returns whatever [`Topology::discover`] failed with. pub fn discover_places() -> std::io::Result> { - let topology = Topology::discover()?; + Ok(places_from_topology(&Topology::discover()?)) +} +/// Work out where each logical processor sits, in any topology. +/// +/// # Why this seam exists when `measure` deliberately has none +/// +/// This is a **pure conversion**: topology in, positions out, nothing measured +/// and nothing pinned. Feeding it a synthetic topology yields synthetic +/// positions, which is exactly what a caller asked for and cannot be mistaken +/// for a measurement. The seam refused on +/// [`measure`](crate::core_affinity::measure) is a different thing entirely -- +/// there, a synthetic topology's processor *numbers* would still be valid on +/// the real host, so every pin would succeed and real timings would be filed +/// under fabricated labels. +/// +/// The distinction is the rule: **a seam that only moves data is safe; a seam +/// that lets fabricated labels reach real hardware is not.** +/// +/// Without this, the rules below -- which cache level partitions the machine, +/// which core and class each processor belongs to, and which NUMA node -- could +/// only ever execute against whatever machine ran the suite. The NUMA mapping +/// in particular was unverifiable on a single-node host, where a completely +/// broken lookup and a correct one both yield node 0. +#[must_use] +pub fn places_from_topology(topology: &Topology) -> Vec { let mut class_of = std::collections::BTreeMap::new(); let mut core_of = std::collections::BTreeMap::new(); for core in topology.cores() { @@ -431,7 +455,7 @@ pub fn discover_places() -> std::io::Result> { } } - Ok(class_of + class_of .into_iter() .map(|(number, efficiency_class)| ProcessorPlace { number, @@ -440,7 +464,7 @@ pub fn discover_places() -> std::io::Result> { cache_domain: cache_of.get(&number).copied(), numa_node: numa_of.get(&number).copied().unwrap_or(0), }) - .collect()) + .collect() } /// Print the host fingerprint as a probe's first line, or say why it could not diff --git a/crates/windows-platform-probes/src/fingerprint/tests.rs b/crates/windows-platform-probes/src/fingerprint/tests.rs index dbf948bd..7cf68eb9 100644 --- a/crates/windows-platform-probes/src/fingerprint/tests.rs +++ b/crates/windows-platform-probes/src/fingerprint/tests.rs @@ -290,3 +290,286 @@ fn the_banner_carries_whatever_the_fingerprint_says() { "a real machine's banner must carry no taint marker: {banner}" ); } + +// --------------------------------------------------------------------------- +// Converting a whole topology into processor positions. +// +// `places_from_topology` carries real rules -- which cache level partitions the +// machine, which core and efficiency class each processor belongs to, and which +// NUMA node -- and until it gained a seam none of them could be exercised +// against anything but whatever machine ran the suite. The NUMA lookup was the +// worst case: on a single-node host a completely broken map and a correct one +// both yield node 0, so it was shipped unverified. +// --------------------------------------------------------------------------- + +mod from_topology { + use windows_topology_sys::{ + Domain, DomainKind, Processor, ProcessorId, ProcessorSet, Topology, + }; + + use crate::fingerprint::places_from_topology; + + /// How many processors each core carries, and where each core sits. + struct CoreSpec { + efficiency_class: u8, + cache_domain: u32, + numa_node: u32, + threads: u8, + } + + /// Assemble a topology from a list of cores, the way Windows would report + /// one: a group domain, a core domain per core, a cache domain per distinct + /// cache id, and a memory domain per distinct node. + fn topology_of(cores: &[CoreSpec]) -> Topology { + let mut processors = Vec::new(); + let mut domains = Vec::new(); + let mut next_number = 0_u8; + let mut cache_members: Vec<(u32, Vec)> = Vec::new(); + let mut node_members: Vec<(u32, Vec)> = Vec::new(); + let mut all = Vec::new(); + + for (index, core) in cores.iter().enumerate() { + let mut members = Vec::new(); + for _ in 0..core.threads { + processors.push(Processor { + id: ProcessorId { + group: 0, + number: next_number, + }, + online: true, + capacity: 0, + }); + members.push(next_number); + all.push(next_number); + next_number += 1; + } + + domains.push(Domain { + kind: DomainKind::Core { + simultaneous_multithreading: core.threads > 1, + efficiency_class: core.efficiency_class, + }, + id: index as u32, + processors: set_of(&members), + }); + + push_members(&mut cache_members, core.cache_domain, &members); + push_members(&mut node_members, core.numa_node, &members); + } + + domains.insert( + 0, + Domain { + kind: DomainKind::Group, + id: 0, + processors: set_of(&all), + }, + ); + + for (id, members) in cache_members { + domains.push(Domain { + kind: DomainKind::Cache { + level: 2, + associativity: 8, + line_size: 64, + size_bytes: 512 * 1024, + cache_type: windows_topology_sys::CacheKind::Unified, + }, + id, + processors: set_of(&members), + }); + } + for (id, members) in node_members { + domains.push(Domain { + kind: DomainKind::Memory { memory_bytes: None }, + id, + processors: set_of(&members), + }); + } + + Topology { + processors, + domains, + distances: None, + ..Default::default() + } + } + + fn set_of(numbers: &[u8]) -> ProcessorSet { + let mask = numbers.iter().fold(0_usize, |mask, n| mask | (1 << n)); + ProcessorSet::from_group_mask(0, mask) + } + + fn push_members(into: &mut Vec<(u32, Vec)>, id: u32, members: &[u8]) { + match into.iter_mut().find(|(existing, _)| *existing == id) { + Some((_, list)) => list.extend_from_slice(members), + None => into.push((id, members.to_vec())), + } + } + + /// Two nodes, two cores each, two threads per core. + fn two_node_host() -> Topology { + topology_of(&[ + CoreSpec { + efficiency_class: 0, + cache_domain: 0, + numa_node: 0, + threads: 2, + }, + CoreSpec { + efficiency_class: 0, + cache_domain: 1, + numa_node: 0, + threads: 2, + }, + CoreSpec { + efficiency_class: 0, + cache_domain: 2, + numa_node: 1, + threads: 2, + }, + CoreSpec { + efficiency_class: 0, + cache_domain: 3, + numa_node: 1, + threads: 2, + }, + ]) + } + + #[test] + fn every_processor_is_placed() { + let places = places_from_topology(&two_node_host()); + + assert_eq!(places.len(), 8); + let mut numbers: Vec = places.iter().map(|p| p.number).collect(); + numbers.sort_unstable(); + assert_eq!(numbers, (0..8).collect::>()); + } + + #[test] + fn numa_nodes_are_read_from_the_memory_domains() { + // The assertion that could not be made before this seam existed. On a + // single-node host this passes whether the lookup works or returns the + // fallback, so it was previously untested in the only way that matters. + let places = places_from_topology(&two_node_host()); + + for place in &places { + let expected = u32::from(place.number >= 4); + assert_eq!( + place.numa_node, expected, + "cpu{} landed on node {} rather than {expected}", + place.number, place.numa_node + ); + } + } + + #[test] + fn both_nodes_are_actually_represented() { + // Guards the degenerate pass: if the lookup silently returned 0 for + // everything, the test above would still fail, but a future refactor + // that collapsed the map could otherwise leave a suite that only ever + // sees one node. + let places = places_from_topology(&two_node_host()); + let mut nodes: Vec = places.iter().map(|p| p.numa_node).collect(); + nodes.sort_unstable(); + nodes.dedup(); + + assert_eq!(nodes, vec![0, 1]); + } + + #[test] + fn smt_siblings_share_a_core_id() { + let places = places_from_topology(&two_node_host()); + + for pair in places.chunks(2) { + assert_eq!( + pair[0].core, pair[1].core, + "cpu{} and cpu{} were reported on different cores", + pair[0].number, pair[1].number + ); + } + assert_ne!(places[0].core, places[2].core); + } + + #[test] + fn the_partitioning_cache_level_is_the_outermost_one_that_divides() { + // Four distinct L2 domains here, so every core sits behind its own and + // the two siblings of a core share one. + let places = places_from_topology(&two_node_host()); + + assert_eq!(places[0].cache_domain, places[1].cache_domain); + assert_ne!(places[0].cache_domain, places[2].cache_domain); + } + + #[test] + fn a_single_cache_domain_partitions_nothing() { + // One cache covering the whole machine divides it into one piece, which + // is no division at all -- the rule the real hosts exercise from the + // other side, since one reports a single L3 and falls back to L2. + let flat = topology_of(&[ + CoreSpec { + efficiency_class: 0, + cache_domain: 0, + numa_node: 0, + threads: 1, + }, + CoreSpec { + efficiency_class: 0, + cache_domain: 0, + numa_node: 0, + threads: 1, + }, + ]); + + let places = places_from_topology(&flat); + + assert!( + places.iter().all(|p| p.cache_domain.is_none()), + "an undivided machine reported a partitioning cache domain" + ); + } + + #[test] + fn efficiency_classes_are_carried_through() { + let hybrid = topology_of(&[ + CoreSpec { + efficiency_class: 1, + cache_domain: 0, + numa_node: 0, + threads: 2, + }, + CoreSpec { + efficiency_class: 0, + cache_domain: 1, + numa_node: 0, + threads: 1, + }, + ]); + + let places = places_from_topology(&hybrid); + + assert_eq!(places[0].efficiency_class, 1); + assert_eq!(places[1].efficiency_class, 1); + assert_eq!(places[2].efficiency_class, 0); + } + + #[test] + fn a_synthetic_topology_drives_the_classifier_end_to_end() { + // The whole point of routing through `Topology`: selection now runs on + // positions the real conversion produced, not on positions a test + // author assumed it would produce. + use crate::core_affinity::{Placement, node_pairs, representative_pairs}; + + let places = places_from_topology(&two_node_host()); + let pairs = representative_pairs(&places); + + assert!(pairs.contains_key(&Placement::SameCoreSiblings)); + assert!(pairs.contains_key(&Placement::CrossCacheSameClass)); + assert!(pairs.contains_key(&Placement::CrossNumaNode)); + + let hops = node_pairs(&places); + assert_eq!(hops.len(), 1); + assert!(hops.contains_key(&(0, 1))); + } +} From e185355a0e3e573e5c175c34aec3adf08d785f4e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 12:18:00 -0400 Subject: [PATCH 049/361] docs: plan a shareable placement-cost tool, gated on topology and queues shipping Every host available to this workspace has exactly one NUMA node, so the cross NUMA node row and the whole inter-node hop matrix are unmeasured and no local work will change that. The way out is other people's machines, which means a tool a stranger can install, run once, and send one structured result back from. Extracted rather than published in place: windows-platform-probes is publish=false, version 0.0.0, and every binary opens by saying it is an experiment and not a component. That boundary is deliberate. It also carries ~13 probes irrelevant to this question that would become public surface for no benefit. A published crate cannot depend on unpublished code, so the measurement modules must move into the new crate with the probes crate consuming it -- inverting today's direction, correctly: the published thing owns the measurement, the grab-bag borrows it. M1 is deliberately three decisions rather than code: the name, what the record says about the machine beyond the fingerprint (the CPU model question, which has a privacy dimension), and whether the existing probe binaries survive the move as wrappers or are deleted to avoid two renderings of one measurement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 116 ++++++++++++++++++++++++++++++++++++ PLANS.md | 1 + 2 files changed, 117 insertions(+) create mode 100644 CHECKLIST-placement-tool.md diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md new file mode 100644 index 00000000..28c68f41 --- /dev/null +++ b/CHECKLIST-placement-tool.md @@ -0,0 +1,116 @@ +# Checklist: a shareable placement-cost tool + +**Goal.** A small, publishable Windows tool that a stranger can install, run once, and send back a +single structured result -- so that this workspace can collect placement and NUMA-hop measurements +from hardware it does not own. **The motivating gap is concrete: every host available here has exactly +one NUMA node**, so the entire `cross NUMA node` row and the whole inter-node hop matrix are +unmeasured, and no amount of local work will change that. + +**Gated on shipping [crates/windows-topology-sys](crates/windows-topology-sys) and +[crates/windows-waitable-queues](crates/windows-waitable-queues) first.** Not a preference: the tool +depends on the former, and calibrates against the latter's `spsc`. Both are `0.1.0` and the topology +crate now carries an unreleased breaking change (`feat(topology)!`), so it wants a release before +anything downstream is published against it. + +**Why a new crate rather than publishing the existing probes.** +[crates/windows-platform-probes](crates/windows-platform-probes) is `publish = false`, `version = +0.0.0`, and every binary opens by saying it is "an experiment, not a component". That boundary is +deliberate and stays. It also carries ~13 probes irrelevant to this question, which would be public +surface and a maintenance obligation for no benefit. + +Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4 and M-inf.5, both of which are +waiting on numbers only other people's machines can produce. + +## M1: decisions that shape everything after + +- [ ] **PT-1.1** -- **Name the crate**, and record the reasoning. It measures what a producer/consumer + handoff costs as a function of where the two threads run, which is broader than queues and narrower + than "topology". Candidates to weigh rather than a foregone answer: `windows-placement-probe`, + `windows-handoff-cost`, `windows-locality-report`. Check availability on crates.io before settling. + +- [ ] **PT-1.2** -- **Decide what the submission record carries about the machine beyond the + fingerprint**, specifically the CPU model name. The fingerprint deliberately omits model names + because "a fingerprint that changes when the answer does not is a fingerprint nobody can compare" -- + correct for comparing placements, and a real loss when a stranger sends a result you cannot ask + follow-up questions about. Likely answer is that the canonical string stays clean and the submission + record carries the model *separately*, but that is a decision with a privacy dimension and is made + here, once, explicitly. Whatever is decided, the tool must be able to state plainly what it collects. + +- [ ] **PT-1.3** -- **Decide the fate of the three existing probe binaries** (`probe-topology`, + `probe-core-affinity`, `probe-peer-index-cache`) once their modules move. Keeping them as thin + wrappers preserves the internal workflow; deleting them removes a second way to run the same + measurement and a second place for output to drift. **Do not decide by taste -- the risk being + weighed is two renderings of one measurement disagreeing**, which this investigation has already hit + three times. + +## M2: the move + +- [ ] **PT-2.1** -- Move `fingerprint`, `core_affinity` and `peer_index_cache` into the new crate, and + make `windows-platform-probes` depend on it. This inverts today's direction deliberately: the + published crate owns the measurement, the internal grab-bag borrows it. A **pure relocation** with + the provenance trail the repository requires for a split -- commit trailers and per-file headers -- + because these modules carry a session's worth of hard-won reasoning in their comments and blame must + survive. + +- [ ] **PT-2.2** -- Keep `queue_contention` and every unrelated probe where they are. The new crate is + not a home for "measurement code in general"; it is one tool with one question, and admitting a + second unrelated probe is how it becomes the grab-bag it was extracted from. + +- [ ] **PT-2.3** -- Verify the move changed no behaviour: the three probe binaries (or their + replacements per PT-1.3) produce the same numbers on this host as recorded in + [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, and the full sabotage set still fails + where it should. + +## M3: the submission record + +- [ ] **PT-3.1** -- Emit **one** machine-readable record per run, carrying: a **schema version** + (separate from the tool version -- a collector needs to know whether it can parse the file at all), + the **tool version**, the topology **provenance**, a UTC timestamp, the host fingerprint, every + placement measurement, and every node-hop measurement. + **The tool version is the load-bearing field.** Results will arrive over months from different + builds, and a measurement that does not say which build produced it is an unlabelled number -- the + exact failure this workspace spent [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) + `D-12` fixing one layer down. There is currently **no** version stamped in any probe output. + +- [ ] **PT-3.2** -- Keep the human-readable report as well, and derive both from the same measured + values so they cannot disagree. The reader running the tool should be able to see, in prose, the + same conclusion the record encodes -- otherwise nobody notices when a run is nonsense. + +- [ ] **PT-3.3** -- Write the record to a **file** by default, named predictably, and tell the user + exactly where it is and what to do with it. Asking someone to copy terminal output invites truncated + and reflowed submissions. + +## M4: the runner's experience, and their trust + +- [ ] **PT-4.1** -- **One entry point.** A single binary that runs everything and produces one record. + "Run these three and send me all three outputs" is friction for someone doing a favour, and invites + partial submissions that cannot be compared. + +- [ ] **PT-4.2** -- **State the runtime before doing the work**, from the discovered topology rather + than a guess: the hop matrix alone is `n*(n-1)/2` hops times two strategies times three repetitions + times two million items, on top of the placements. On a four-node machine that is a materially + longer run than on this one, and the person deserves to know before it starts. + +- [ ] **PT-4.3** -- **Say exactly what is collected and what is not**, in the tool's own output and in + its README, and make it verifiable by reading the record: core/cache/NUMA shape, timings, and + whatever PT-1.2 decides -- **not** hostname, username, paths, or environment. **The tool makes no + network connections**; the person sends the file themselves, deliberately. + +- [ ] **PT-4.4** -- Pin the thread-pinning failure behaviour for a stranger's machine. It currently + panics, which is right for us (a silently unpinned thread measures the scheduler, not the placement) + but reads as a crash to someone doing a favour. It must fail with an explanation of what could not + be pinned and why the run cannot continue honestly -- **and must not fall back to an unpinned + measurement**, which would produce a plausible number that means nothing. + +## M5: publishing + +- [ ] **PT-5.1** -- A README written for someone who has never seen this repository: what question the + tool answers, why their machine is interesting, how to install and run it, what to send back, and + what it collects. Assume no context and no obligation. + +- [ ] **PT-5.2** -- Package metadata, and a statement of what is and is not covered by semver. The + **record's schema is a compatibility surface** the moment anyone stores one; the internal + measurement code is not. + +- [ ] **PT-5.3** -- Release, and confirm a clean `cargo install` from crates.io on a machine without + this repository checked out. An install path nobody has walked is an install path that does not work. diff --git a/PLANS.md b/PLANS.md index 1b06d36d..a9d15af7 100644 --- a/PLANS.md +++ b/PLANS.md @@ -19,6 +19,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil |---|---|---|---| | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | not started | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | +| [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | not started | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Gated on releasing `windows-topology-sys` and `windows-waitable-queues` first** -- the tool depends on the former and calibrates against the latter. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 publishes. | [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | | [crates/windows-ioring-sys/CHECKLIST.md](crates/windows-ioring-sys/CHECKLIST.md) | in progress | Memory-safe Rust over the Windows `IoRing` submission/completion ring, as a new crate. M1-M19 are complete (0.2.0 shipped 2026-08-30, restoring availability after all three 0.1.x versions were yanked); M1-M18 are archived. **M20** queues documentation and policy-test repairs from the 2026-08-30 NUMA-sharding measurement, and the pinned-thread `M6+` work stays parked. | [crates/windows-ioring-sys/DESIGN-NOTES.md](crates/windows-ioring-sys/DESIGN-NOTES.md) | From 6c6048265c8a1082db969fb800d0c8327612a302 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 12:34:29 -0400 Subject: [PATCH 050/361] docs: settle the tool's build identity, schema versioning, and binary layout Build identity: the record stamps the git commit, whether the tree was dirty, the crate version, and whether it came from CI. This is the same problem as topology Provenance one layer up and takes the same shape -- a CI build from a clean tree is the trusted case, everything else is visibly marked, and the untrusted reading is the default when the answer cannot be established. CI-built artifacts become the canonical distribution, because the download itself is the provenance in a way a local build of identical source is not. Schema version: a linearly increasing integer that cannot silently drift. The counter is easy for consumers to compare; the hazard is forgetting to bump it. So derive rather than restate -- hash the record's actual serialized shape and assert it matches the hash recorded for the current version. Change the shape without bumping and the test fails. The record still carries a plain integer, so no consumer has to understand the mechanism. Binaries: keep them, and move the rendering into the library so there is only one of it. The two worries were about different things -- accretion is about entry points, drift is about renderings -- and sharing the render code kills the drift risk, after which extra entry points cost nothing. The shared tool is one binary for a stranger; the internal probes stay separate and thin for the development loop. A binary that formats its own output is the defect, not a binary that exists. M3 renumbered into dependency order: the two identity fields are settled before the record that carries them. Completed item: PT-1.3: Decide the fate of the three existing probe binaries once their modules move. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 100 ++++++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 22 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 28c68f41..395ad5b5 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -36,12 +36,22 @@ waiting on numbers only other people's machines can produce. record carries the model *separately*, but that is a decision with a privacy dimension and is made here, once, explicitly. Whatever is decided, the tool must be able to state plainly what it collects. -- [ ] **PT-1.3** -- **Decide the fate of the three existing probe binaries** (`probe-topology`, +- [x] **PT-1.3** -- **Decide the fate of the three existing probe binaries** (`probe-topology`, `probe-core-affinity`, `probe-peer-index-cache`) once their modules move. Keeping them as thin wrappers preserves the internal workflow; deleting them removes a second way to run the same measurement and a second place for output to drift. **Do not decide by taste -- the risk being weighed is two renderings of one measurement disagreeing**, which this investigation has already hit three times. + **Decided: keep them, and move the *rendering* into the library so there is only one of it.** The + two stated worries turn out not to be in tension, because they are about different things. The + engineer's -- that a combined binary accretes flags and modes until it is the grab-bag this crate was + extracted from -- is about **entry points**. The drift worry is about **renderings**. Sharing the + render code kills the drift risk outright, after which extra entry points cost nothing. + So: the shared tool is **one binary, one run, one record**, because a stranger doing a favour must + not be asked to run three things and collate them. The internal probes stay **separate and thin**, + because running one measurement in isolation is the whole point of a development loop. Every binary + becomes an entry point only; measurement *and* rendering live in the library and are called, never + reimplemented. A binary that formats its own output is the defect, not a binary that exists. ## M2: the move @@ -63,20 +73,46 @@ waiting on numbers only other people's machines can produce. ## M3: the submission record -- [ ] **PT-3.1** -- Emit **one** machine-readable record per run, carrying: a **schema version** - (separate from the tool version -- a collector needs to know whether it can parse the file at all), - the **tool version**, the topology **provenance**, a UTC timestamp, the host fingerprint, every - placement measurement, and every node-hop measurement. - **The tool version is the load-bearing field.** Results will arrive over months from different - builds, and a measurement that does not say which build produced it is an unlabelled number -- the - exact failure this workspace spent [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) +Ordered so each item's prerequisites land first: the two identity fields are decided before the record +that carries them is written. + +- [ ] **PT-3.1** -- **A linearly increasing integer schema version that cannot silently drift.** The + counter itself is easy for a consumer to compare (`schema >= 2`); the hazard is forgetting to bump it + when the record's shape changes, which no amount of care reliably prevents. So derive rather than + restate, per this repository's own rule: compute a hash over the record's **actual serialized shape** + (every key path, sorted, recursively) and assert in a test that it matches the hash recorded for the + current `SCHEMA_VERSION`. + Change the shape without bumping and the test fails, naming the new hash. Bumping then means adding a + row, deliberately. **The version stays a plain integer in the record** -- the hash is a development- + time guard, not something a consumer parses -- so nothing downstream has to understand this + mechanism. Verify by sabotage: add a field, confirm the test fails; bump and re-record, confirm it + passes. + +- [ ] **PT-3.2** -- **Stamp the exact build, and say loudly when it is not an official one.** The + record carries the git commit, whether the working tree was dirty when it was built, the crate + version, and whether it came from CI or a local build. + **This is the same problem as `Provenance` one layer up, and takes the same shape**: an official + CI-built binary from a clean tree is the trusted case, and everything else -- a local build, a dirty + tree, an unknown commit -- must be visibly marked so a result that arrives from one is not silently + pooled with the rest. Default to the untrusted reading when the answer cannot be established, for + the same reason `Provenance::Synthetic` is `Default`: forgetting must be safe. + A `build.rs` reads the commit from an environment variable when CI sets one, falls back to `git` + when there is a repository, and records *unknown* otherwise -- which is exactly what a `cargo + install` from a crates.io tarball will produce, and is the honest answer there. + +- [ ] **PT-3.3** -- Emit **one** machine-readable record per run, carrying: the schema version + (PT-3.1), the build identity (PT-3.2), the topology **provenance**, a UTC timestamp, the host + fingerprint, every placement measurement, and every node-hop measurement. + **Build identity is the load-bearing field.** Results will arrive over months from different builds, + and a measurement that does not say which build produced it is an unlabelled number -- the exact + failure this workspace spent [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) `D-12` fixing one layer down. There is currently **no** version stamped in any probe output. -- [ ] **PT-3.2** -- Keep the human-readable report as well, and derive both from the same measured +- [ ] **PT-3.4** -- Keep the human-readable report as well, and derive both from the same measured values so they cannot disagree. The reader running the tool should be able to see, in prose, the same conclusion the record encodes -- otherwise nobody notices when a run is nonsense. -- [ ] **PT-3.3** -- Write the record to a **file** by default, named predictably, and tell the user +- [ ] **PT-3.5** -- Write the record to a **file** by default, named predictably, and tell the user exactly where it is and what to do with it. Asking someone to copy terminal output invites truncated and reflowed submissions. @@ -102,15 +138,35 @@ waiting on numbers only other people's machines can produce. be pinned and why the run cannot continue honestly -- **and must not fall back to an unpinned measurement**, which would produce a plausible number that means nothing. -## M5: publishing - -- [ ] **PT-5.1** -- A README written for someone who has never seen this repository: what question the - tool answers, why their machine is interesting, how to install and run it, what to send back, and - what it collects. Assume no context and no obligation. - -- [ ] **PT-5.2** -- Package metadata, and a statement of what is and is not covered by semver. The - **record's schema is a compatibility surface** the moment anyone stores one; the internal - measurement code is not. - -- [ ] **PT-5.3** -- Release, and confirm a clean `cargo install` from crates.io on a machine without - this repository checked out. An install path nobody has walked is an install path that does not work. +## M5: distribution + +**The CI-built artifact is the canonical way to get this tool**, not `cargo install`. Two reasons, and +the second is the real one: a downloader needs no Rust toolchain, and **the download itself is the +provenance**. A binary attached to a release in this repository is traceable to the commit that built +it, in a way a locally built copy of the same source is not -- which is what makes PT-3.5's "official +build" distinction meaningful rather than decorative. + +- [ ] **PT-5.1** -- CI builds the tool on tag and attaches the binary to a GitHub release, injecting + the commit into the environment variable PT-3.5 reads. **Verify the negative case**: a locally built + binary must produce a record marked as an unofficial build, and a CI-built one must not. A + distinction nobody has watched fail is a distinction that does not work. + +- [ ] **PT-5.2** -- A README written for someone who has never seen this repository: what question the + tool answers, why their machine is interesting, where to download it, how to run it, what to send + back, and what it collects. Assume no context and no obligation. Lead with the download, not with + `cargo install`. + +- [ ] **PT-5.3** -- Decide whether to publish to crates.io **as well**, and record the reasoning. It + costs a semver obligation and yields records whose commit is *unknown* by construction (a crates.io + tarball carries no repository), which is a strictly weaker submission. The case for it is reach; the + case against is that the weaker path is also the more discoverable one, and submissions will drift + towards it. + +- [ ] **PT-5.4** -- Package metadata and a statement of what is and is not covered by semver. The + **record's schema is a compatibility surface** the moment anyone stores one; the internal measurement + code is not. + +- [ ] **PT-5.5** -- Walk the whole path end to end on a machine without this repository checked out: + download, run, find the record, read the README's instructions for sending it. A path nobody has + walked is a path that does not work, and the person walking it will be doing a favour rather than + debugging. From 9e8605f249c5418963eebe9a107715f59fe89f6c Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 12:37:22 -0400 Subject: [PATCH 051/361] docs: archive the schema shape rather than hashing it, so history stays verifiable A hash table of version -> shape digest does not survive its own history. Only the current version's hash can ever be recomputed; every earlier row is a frozen constant nobody can verify, which makes the hash function an unversioned contract -- change the traversal, the digest, or the key-path canonicalisation and every historical row silently becomes wrong with nothing to detect it. A digest is also opaque, reporting that the shape moved but never what moved, so a review cannot tell an additive change from a breaking one. Archive the shape itself instead: one golden file per schema version listing the record's key paths, with a test asserting the current shape matches the golden for the current SCHEMA_VERSION. A stored submission can then be validated against the schema it declares years later, the version-to-version diff is reviewable, and there is no hash function to keep stable. Golden files are append-only and a published version is never redefined. Once a record exists in the wild claiming schema N, N's meaning is fixed, because the record cannot be regenerated. The same asymmetry sharpens PT-1.2: a field the tool did not collect is missing permanently from every result gathered before the omission was noticed, and the machines belong to other people. Under-collecting is unrecoverable; over-collecting is a privacy cost correctable going forward. That argues for more context in the record, but as an argument to weigh against consent rather than a licence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 40 +++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 395ad5b5..b9e1abeb 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -35,6 +35,13 @@ waiting on numbers only other people's machines can produce. follow-up questions about. Likely answer is that the canonical string stays clean and the submission record carries the model *separately*, but that is a decision with a privacy dimension and is made here, once, explicitly. Whatever is decided, the tool must be able to state plainly what it collects. + **This must be settled before the first submission arrives, because the asymmetry is brutal.** A + record cannot be regenerated: whatever a field the tool did not collect, every result gathered before + the omission was noticed lacks it *permanently*, and the machines are other people's. Under-collecting + is unrecoverable; over-collecting is a privacy cost that can at least be corrected going forward by + collecting less. That asymmetry argues for erring towards more context in the record -- but it is an + argument to weigh against what a stranger will consent to, not a licence, and the two must be + answered together rather than by defaulting to whichever is easier to implement. - [x] **PT-1.3** -- **Decide the fate of the three existing probe binaries** (`probe-topology`, `probe-core-affinity`, `probe-peer-index-cache`) once their modules move. Keeping them as thin @@ -76,17 +83,28 @@ waiting on numbers only other people's machines can produce. Ordered so each item's prerequisites land first: the two identity fields are decided before the record that carries them is written. -- [ ] **PT-3.1** -- **A linearly increasing integer schema version that cannot silently drift.** The - counter itself is easy for a consumer to compare (`schema >= 2`); the hazard is forgetting to bump it - when the record's shape changes, which no amount of care reliably prevents. So derive rather than - restate, per this repository's own rule: compute a hash over the record's **actual serialized shape** - (every key path, sorted, recursively) and assert in a test that it matches the hash recorded for the - current `SCHEMA_VERSION`. - Change the shape without bumping and the test fails, naming the new hash. Bumping then means adding a - row, deliberately. **The version stays a plain integer in the record** -- the hash is a development- - time guard, not something a consumer parses -- so nothing downstream has to understand this - mechanism. Verify by sabotage: add a field, confirm the test fails; bump and re-record, confirm it - passes. +- [ ] **PT-3.1** -- **A linearly increasing integer schema version that cannot silently drift, guarded + by an archived schema rather than a hash.** The counter itself is easy for a consumer to compare + (`schema >= 2`); the hazard is forgetting to bump it when the record's shape changes, which no amount + of care reliably prevents. So derive rather than restate, per this repository's own rule -- but + derive into something that survives. + **A hash was considered first and rejected, because it does not survive its own history.** With a + table of `version -> hash`, only the *current* version's hash can ever be recomputed; every earlier + row is a frozen constant nobody can verify. The hash function then becomes an unversioned contract -- + change the traversal, the digest, or how key paths are canonicalised, and every historical row + silently becomes wrong, with nothing to detect it. A digest is also opaque: it reports *that* the + shape moved and never *what* moved, so a review cannot see whether a change was additive or breaking. + **Archive the shape itself.** One golden file per schema version, listing the record's key paths + (sorted, recursively) as text. A test generates the current shape and asserts it equals the golden + for the current `SCHEMA_VERSION`; a change fails the test and the diff *shows what changed*. Bumping + means adding the next golden, deliberately. + This buys three things a hash cannot: a stored submission can be **validated against the schema it + declares**, years later; the version-to-version diff is **reviewable**; and there is **no hash + function to keep stable**, so no way for history to rot. + **Golden files are append-only and a published version is never redefined** -- the same discipline as + [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md). Once a record exists in the wild claiming schema N, + N's meaning is fixed, because the record cannot be regenerated. Verify by sabotage: add a field, + confirm the test fails and names the difference; bump, add the golden, confirm it passes. - [ ] **PT-3.2** -- **Stamp the exact build, and say loudly when it is not an official one.** The record carries the git commit, whether the working tree was dirty when it was built, the crate From 7ba6dc527a4f04c693e120c221f2e8f0335b2b34 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 12:42:53 -0400 Subject: [PATCH 052/361] docs: collect the CPU model, and the OS build and virtualisation hint alongside it A CPU model is not personal data -- it is a hardware characteristic shared by millions of machines -- and the sensitive fields (hostname, user name, paths, domain, serials, installed software) are not collected and must not be. That argument stands on its own. That a detailed topology plus cache geometry would narrow the part to a small class anyway is the supporting reason withholding it gains nothing, and is deliberately not promoted to a principle: 'it could be inferred, so collect it' would justify almost anything. Two fields ride along by the same reasoning and are more explanatory for this dataset than the model is. The OS build, because placement cost is a scheduler behaviour and schedulers change between builds, so two results that disagree are otherwise indistinguishable from two builds disagreeing. And a virtualisation hint, because this workspace has already established that VM slices flatten topology -- the EPYC slice reports one L3 domain and one NUMA node for silicon with eight and two -- so separating bare metal from VM submissions is the distinction that decides whether a submission can supply the missing rows at all. The hint is labelled a hint: hypervisor presence is not decidable from user mode, and a negative means not detected rather than bare metal. The runner can suppress the model with a flag. Not because it is sensitive in general, but because the narrow case where it might be is real -- an engineering sample would leak an unreleased part name -- and because offering the switch is a stronger thing to say to someone doing a favour than asking to be trusted. Completed item: PT-1.2: Decide what the submission record carries about the machine beyond the fingerprint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 74 ++++++++++++++++++++++++++++++------- 1 file changed, 60 insertions(+), 14 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index b9e1abeb..1b05ac5e 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -28,20 +28,49 @@ waiting on numbers only other people's machines can produce. than "topology". Candidates to weigh rather than a foregone answer: `windows-placement-probe`, `windows-handoff-cost`, `windows-locality-report`. Check availability on crates.io before settling. -- [ ] **PT-1.2** -- **Decide what the submission record carries about the machine beyond the +- [x] **PT-1.2** -- **Decide what the submission record carries about the machine beyond the fingerprint**, specifically the CPU model name. The fingerprint deliberately omits model names because "a fingerprint that changes when the answer does not is a fingerprint nobody can compare" -- correct for comparing placements, and a real loss when a stranger sends a result you cannot ask - follow-up questions about. Likely answer is that the canonical string stays clean and the submission - record carries the model *separately*, but that is a decision with a privacy dimension and is made - here, once, explicitly. Whatever is decided, the tool must be able to state plainly what it collects. - **This must be settled before the first submission arrives, because the asymmetry is brutal.** A - record cannot be regenerated: whatever a field the tool did not collect, every result gathered before - the omission was noticed lacks it *permanently*, and the machines are other people's. Under-collecting - is unrecoverable; over-collecting is a privacy cost that can at least be corrected going forward by - collecting less. That asymmetry argues for erring towards more context in the record -- but it is an - argument to weigh against what a stranger will consent to, not a licence, and the two must be - answered together rather than by defaulting to whichever is easier to implement. + follow-up questions about. + **This had to be settled before the first submission arrives, because the asymmetry is brutal.** A + record cannot be regenerated: a field the tool did not collect is missing *permanently* from every + result gathered before the omission was noticed, and the machines are other people's. + Under-collecting is unrecoverable; over-collecting is a privacy cost that can at least be corrected + going forward by collecting less. + + **Decided: collect the CPU model, the OS build, and a virtualisation hint. The canonical fingerprint + string stays clean; all three live in the record beside it.** + + The reasoning, in the order it actually holds: + - **A CPU model is not personal data.** It is a hardware characteristic shared by millions of + machines. The things that would be sensitive -- hostname, user name, file paths, domain membership, + serial numbers, installed software -- are not collected and must not be. That is the primary + argument; it stands whether or not the model could be inferred. + - **Withholding it gains nothing anyway**, because a detailed topology plus cache geometry narrows + the field to a small class of parts. This is the supporting argument, and it is deliberately *not* + treated as a principle: "it could be inferred, so collect it" would justify almost anything, and + the test remains whether the field is sensitive on its own merits. + + **Two fields ride along by the same reasoning, and both are more explanatory for this dataset than + the model is:** + - **The OS build.** Placement cost is a scheduler behaviour, and the scheduler changes between + Windows builds. Two results that disagree are otherwise indistinguishable from two builds + disagreeing, and that is unrecoverable after the fact. + - **A virtualisation hint.** This workspace has already established that **VM slices flatten + topology** -- the EPYC slice reports one L3 domain and one NUMA node for silicon that has eight and + two -- which is precisely why the interesting rows are unmeasured here. Being able to separate bare + metal from VM submissions is therefore not incidental: it is the distinction that decides whether a + submission can supply the missing rows at all. **Record it as a hint and label it as one**; + hypervisor detection is not reliably decidable from user mode, and a field that overstates its + confidence is worse than an absent one. + + **The runner can suppress the model**, with a flag, and the tool says so where it lists what it + collects. Not because the field is sensitive in general, but because the one case where it might be + is real and narrow -- an engineering sample or unreleased part would leak a name that is not yet + public -- and because "here is what I collect, and you may turn this off" is a materially stronger + thing to say to someone doing a favour than "trust me". The field is optional in the record, so a + suppressed submission stays valid rather than becoming unparseable. - [x] **PT-1.3** -- **Decide the fate of the three existing probe binaries** (`probe-topology`, `probe-core-affinity`, `probe-peer-index-cache`) once their modules move. Keeping them as thin @@ -134,6 +163,20 @@ that carries them is written. exactly where it is and what to do with it. Asking someone to copy terminal output invites truncated and reflowed submissions. +- [ ] **PT-3.6** -- Read the three machine-description fields PT-1.2 settled, each of which needs a + source this crate does not currently use. **Every one of them is optional in the record**, so a host + that will not answer produces a record missing a field rather than a failed run or a fabricated + value. + - **CPU model** -- the registry's `ProcessorNameString` under + `HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0` is the pragmatic source and works on both + x64 and ARM64, unlike the CPUID brand string. + - **OS build** -- the reported version must be the real one. The Win32 compatibility shims lie to + unmanifested processes about the major version, so verify against a known build rather than + trusting the first API that returns a number. + - **Virtualisation hint** -- record confidence honestly. There is no user-mode call that decides + this, so whatever signal is used, the field says "hint" and a negative means *not detected* rather + than *bare metal*. + ## M4: the runner's experience, and their trust - [ ] **PT-4.1** -- **One entry point.** A single binary that runs everything and produces one record. @@ -146,9 +189,12 @@ that carries them is written. longer run than on this one, and the person deserves to know before it starts. - [ ] **PT-4.3** -- **Say exactly what is collected and what is not**, in the tool's own output and in - its README, and make it verifiable by reading the record: core/cache/NUMA shape, timings, and - whatever PT-1.2 decides -- **not** hostname, username, paths, or environment. **The tool makes no - network connections**; the person sends the file themselves, deliberately. + its README, and make it verifiable by reading the record. Collected, per PT-1.2: core/cache/NUMA + shape, timings, CPU model, OS build, and the virtualisation hint. **Not** collected: hostname, user + name, file paths, environment variables, serial numbers, or anything about installed software -- + and that list is a commitment, not a description of the current implementation. **The tool makes no + network connections**; the person sends the file themselves, deliberately. Mention the model + suppression flag here, where someone deciding whether to run it will actually see it. - [ ] **PT-4.4** -- Pin the thread-pinning failure behaviour for a stranger's machine. It currently panics, which is right for us (a silently unpinned thread measures the scheduler, not the placement) From 220ce96f535e3ee5e4e68bb7a3db963a32f7c1a0 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 12:44:23 -0400 Subject: [PATCH 053/361] docs: record what the model-suppression flag does not solve, and let the runner inspect first The flag addresses the smaller half of the engineering-sample case. A pre-release part is identified at least as well by its topology -- an unusual core count, a novel cache arrangement, an unreleased NUMA layout -- and the topology is the entire point of the submission, so it cannot be suppressed without making the record worthless. The tool cannot make an NDA-covered machine safe to submit from and must not imply that it can; the README says so plainly, which is worth more to the audience most likely to own a multi-socket machine than a reassurance would be. Suppression is recorded rather than merely absent. A field missing because the runner withheld it and a field missing because the host would not answer are different facts, and a collector that cannot distinguish them will eventually read one as the other -- the same reason an inexpressible placement is reported rather than skipped. Adds PT-4.5: let the runner see the record before sending it, which is a stronger privacy property than any flag and cheaper. Two things make that usable rather than theatre -- a fast preview of the machine-description fields that does not require the multi-minute measurement, and a record whose field names mean something without a schema in hand. A file that must be decoded to be checked is not honestly inspectable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 1b05ac5e..0d58943e 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -71,6 +71,19 @@ waiting on numbers only other people's machines can produce. public -- and because "here is what I collect, and you may turn this off" is a materially stronger thing to say to someone doing a favour than "trust me". The field is optional in the record, so a suppressed submission stays valid rather than becoming unparseable. + **Suppression is recorded, not merely absent.** A field that is missing because the runner withheld + it and a field that is missing because the host would not answer are different facts, and a + collector that cannot tell them apart will eventually read one as the other -- the same reason an + inexpressible placement is reported rather than skipped. + + **And the flag must not be oversold, which is the more important half.** For the engineering-sample + case it addresses the *smaller* leak. A pre-release part is identified at least as well by its + **topology** -- an unusual core count, a novel cache arrangement, an unreleased NUMA layout -- and + the topology is the entire point of the submission, so it cannot be suppressed without making the + record worthless. **The tool therefore cannot make an NDA-covered machine safe to submit from, and + must not imply that it can.** Say so plainly in the README: if the hardware is confidential, the + whole output describes it, and the right answer is not to send it. That is worth more to the + audience most likely to own a multi-socket machine than any reassurance would be. - [x] **PT-1.3** -- **Decide the fate of the three existing probe binaries** (`probe-topology`, `probe-core-affinity`, `probe-peer-index-cache`) once their modules move. Keeping them as thin @@ -194,7 +207,9 @@ that carries them is written. name, file paths, environment variables, serial numbers, or anything about installed software -- and that list is a commitment, not a description of the current implementation. **The tool makes no network connections**; the person sends the file themselves, deliberately. Mention the model - suppression flag here, where someone deciding whether to run it will actually see it. + suppression flag here, where someone deciding whether to run it will actually see it -- alongside + the honest limit from PT-1.2, that the flag does not make confidential hardware safe to submit, + because the topology describes the part regardless. - [ ] **PT-4.4** -- Pin the thread-pinning failure behaviour for a stranger's machine. It currently panics, which is right for us (a silently unpinned thread measures the scheduler, not the placement) @@ -202,6 +217,19 @@ that carries them is written. be pinned and why the run cannot continue honestly -- **and must not fall back to an unpinned measurement**, which would produce a plausible number that means nothing. +- [ ] **PT-4.5** -- **Let the runner see everything before sending it, and decide with the real values + rather than a promise.** This is a stronger privacy property than any suppression flag, and cheaper: + the record is a text file, so the honest instruction is "open it and read it -- if you are not happy + with something in there, do not send it." + Two things make that instruction usable rather than theatre: + - **A fast preview of the machine-description fields**, available without running the measurement. + The full run takes minutes and grows with node count; nobody should have to spend that to discover + what the tool would learn about their machine. Someone can then look, decide, and only then commit + to the run. + - **A record a human can actually read** -- field names that mean something without a schema in hand, + and no opaque blobs. A file that must be decoded to be checked cannot honestly be described as + inspectable. + ## M5: distribution **The CI-built artifact is the canonical way to get this tool**, not `cargo install`. Two reasons, and From 9d01367db9713d2b4d4860db4bb0f1315f608c7b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 12:51:29 -0400 Subject: [PATCH 054/361] docs: queue a test of whether a set of equivalent processors really is equivalent Several designs here treat a set of processors as interchangeable and place threads by domain rather than by processor; M-inf.5 rests on it. Every measurement taken so far pins to a single processor (mask = 1 << cpu), so the assumption has never been tested, only assumed while being carefully avoided. There is a structural reason to doubt it before any scheduling subtlety. A set mask permits placements a single-processor mask forbids, including both threads on one logical processor, which turns an SPSC handoff from concurrency into time-slicing with the spin-wait burning its quantum before the peer can run. On an SMT host the same-cache set is the two siblings of one core, so that is the common case there rather than a corner one. The measurement must report migration count and co-residency fraction, not just elapsed time. Without them, the two modes matching is indistinguishable from the scheduler never having moved anything -- the same trap the peer-index probe's read counters were added to escape. Two interference models: spinners confined to the set, which forces the scheduler to choose within the class and proves whether it can break the equivalence, and a concurrent copy of the real workload, which says whether it does under load anyone would generate. Both reported, since a difference appearing only under adversarial load is a real finding with a narrower consequence. Not gated on the release, unlike the rest of that file: it extends the affinity measurement where it lives today and travels with it under PT-2.1. M-inf.5 now records that if the sets are not equivalent, its 5.6x is a number about pinned threads and the item needs restating rather than re-measuring. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 11 +++++++ CHECKLIST-placement-tool.md | 64 +++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 45eecf2b..61b133d7 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -609,6 +609,17 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio across domains on the ARM64 host -- 5.6x for nothing but where the two threads run**. That is far larger than any micro-optimisation this crate has considered, and it is a *placement* decision rather than a code one, which puts it squarely in the runtime's remit rather than the queue's. + **This item's premise -- that a domain is a set of interchangeable processors -- is itself untested, + and is now queued.** Every measurement behind the 5.6x pins to a *single* processor (`mask = 1 << + cpu`), so "place the thread in the domain" has only ever been evaluated as "place the thread on one + chosen member of the domain". A set mask permits placements a single-processor mask forbids, + including both ends of a queue on one logical processor, which on an SMT host is the *common* case + for a same-cache set rather than a corner one. See + [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) M6, which measures set-wide against pinned + affinity under two interference models and reports migration and co-residency counts so a null result + can be distinguished from a scheduler that never moved anything. **If the sets are not equivalent, + the 5.6x is a number about pinned threads and this item needs restating**, not merely re-measuring. + The design already intends one pinned thread per domain, so the queue between two threads of the same domain is the common case and is fine. What this measurement bounds is the **cross-domain** queue -- the one M30's design deferred on the grounds that N=1 does not need it -- and the number to carry into diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 0d58943e..cc085aea 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -262,3 +262,67 @@ build" distinction meaningful rather than decorative. download, run, find the record, read the README's instructions for sending it. A path nobody has walked is a path that does not work, and the person walking it will be doing a favour rather than debugging. + +## M6: is a set of "equivalent" processors actually equivalent? + +**Not gated on the release, unlike the rest of this file.** The work is an extension of the affinity +measurement, which today lives in [crates/windows-platform-probes](crates/windows-platform-probes) and +moves wholesale under PT-2.1. Build it there now; it travels with everything else. + +**The assumption under test.** Several designs in this workspace treat a *set* of processors as +interchangeable -- any processor in this cache domain, any processor in this NUMA node -- and place +threads by domain rather than by processor. [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.5 +rests on exactly that. **Every measurement taken so far pins to a single processor** (`mask = 1 << cpu`), +so the assumption has never been tested; it has only been assumed while being carefully avoided. + +**There is a structural reason to doubt it, before any scheduling subtlety.** A set mask permits +placements a single-processor mask forbids -- including **both threads on one logical processor**, +which turns an SPSC handoff from concurrency into time-slicing, with the spin-wait burning its quantum +before the peer can run. On an SMT host the `same cache domain` set *is* the two siblings of one core, +so this is not a corner case there, it is the common one. + +- [ ] **M6.1** -- Derive each processor's **equivalence set** from the topology -- SMT siblings, cache + domain, NUMA node, efficiency class -- and pin down which sets a given host can express, the same way + placements already are. A set with one member is not a test of anything and must be reported as + inexpressible rather than measured. + +- [ ] **M6.2** -- Add **affinity mode** as a dimension beside placement and strategy: `Pinned` (today's + single bit) and `SetWide` (each thread masked to *its own* equivalence set, which preserves the + placement relation while relaxing the choice within it). For `CrossCacheSameClass` that means the + producer may use any processor of its cache domain and the consumer any of its own; for + `SameCacheSameClass` both threads share one set, which is where co-residency becomes possible. + **In `SetWide` the placement label states intent, not outcome** -- the scheduler may do something + else entirely, and saying otherwise would be the "asserts its conclusion" defect again. + +- [ ] **M6.3** -- Measure the **mechanism**, not only the elapsed time, or the result cannot be read. + Sample `GetCurrentProcessorNumber` in both loops and report **migration count** (did the thread move + at all?) and **co-residency fraction** (how often were producer and consumer on the same processor?). + Co-residency is the killer observable and can only be non-zero in `SetWide`. + Without these, "the two modes matched" is indistinguishable from "the scheduler never moved + anything", which is precisely the false-equivalence this milestone exists to rule out -- and is the + same trap the peer-index probe's read counters were added to escape. + +- [ ] **M6.4** -- Run **long enough for the scheduler to act**. The present 2M items is roughly 40 ms + on an idle host, over which nothing migrates and both modes will look identical for want of any + reason to differ. Choose the duration from measured migration counts -- long enough that migrations + are actually observed under load -- rather than from a round number, and record the reasoning. + +- [ ] **M6.5** -- **Interference pass one: competing spinners confined to the same equivalence set.** + Adversarial and controlled: it forces the scheduler to choose *within* the class, which is the + precise claim under test. Vary the number of competitors relative to set size, since one spinner in a + four-processor set is a different question from four. Keep it reproducible -- an interference model + that varies run to run turns every comparison into noise. + +- [ ] **M6.6** -- **Interference pass two: a concurrent copy of the real workload.** A second + producer/consumer pair on the same set, which is what a domain runtime actually looks like when more + than one queue is live. Pass one establishes whether the scheduler *can* break the equivalence; this + establishes whether it *does* under load anyone would really generate. **Report both**: a difference + that appears only under adversarial spinners is a real finding with a narrower consequence, and + collapsing the two would lose exactly that distinction. + +- [ ] **M6.7** -- Report per set kind whether the equivalence holds, in the tool's own words, derived + from the measurement rather than asserted. Feed the answer back into + [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.5, whose premise this is. **A null result is + a real result here** -- "the sets behaved equivalently under both interference models, and here are + the migration counts showing the scheduler was genuinely exercised" retires a long-standing doubt, + and is worth as much as a difference would be. From 02d0c33c3053ccacccbbbb17d249ebca4b8c3208 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 12:59:08 -0400 Subject: [PATCH 055/361] docs: plan the topology and queue releases, and record two blockers found while doing it Deliberately redundant with CHECKLIST-io-domains.md. That file plans the design; this one plans the release, and a release has failure modes a design checklist does not surface. Writing it found two. windows-waitable-queues-v* is missing from the tag trigger list in publish-crate.yml. The crate is registered with release-please, so release-please will raise the release PR and push the tag, and then nothing will publish it -- no error, because no workflow matches the tag. The symptom is a tag that exists, a changelog that looks right, and a crate that never appears on crates.io. windows-ioring-sys 0.2.0 is published and pins windows-topology-sys = 0.1.0, so the breaking bump to 0.2.0 obliges updating and re-releasing it. A workspace that builds locally through path dependencies will not reveal this; the first symptom is a consumer unable to resolve the two together. M31.8 is release-blocking for a mechanical reason rather than a design one: the decision may delete a public type, which is free before 0.1.0 and a yank-and-migrate after it. The measurement behind it is already done and agrees across both architectures, so it needs a decision rather than more work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 104 ++++++++++++++++++++++++++ PLANS.md | 1 + 2 files changed, 105 insertions(+) create mode 100644 CHECKLIST-ship-topology-and-queues.md diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md new file mode 100644 index 00000000..464a9da7 --- /dev/null +++ b/CHECKLIST-ship-topology-and-queues.md @@ -0,0 +1,104 @@ +# Checklist: ship the topology and queue crates + +**Goal.** Get `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0 released, so the +placement tool in [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) has something to build +against and other people can run it on hardware this workspace does not own. + +**Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md).** That file plans the +*design*; this one plans the *release*, and a release has its own failure modes that a design checklist +will not surface. Where the two overlap -- M31.8 in particular -- this file states why the item is +release-blocking rather than restating the decision itself. + +## The state this starts from, verified rather than assumed + +- `windows-topology-sys` **0.1.0 is published**. The `provenance` field added on this branch is a + breaking change to a struct with public fields, so the next release is **0.2.0**, not 0.1.1. +- `windows-waitable-queues` **is not published**. First release, and its packaging is already complete: + description, keywords, categories, README, documentation link and a workspace license are all + present. Packaging is not a blocker; do not re-investigate it. +- `windows-ioring-sys` **0.2.0 is published and depends on `windows-topology-sys = "0.1.0"`.** +- This branch is **54 commits ahead of `main` with no pull request**, and release automation runs on + `main`. Nothing ships until it merges. + +## M1: settle the public surface before it is public + +- [ ] **SH-1.1** -- **Decide M31.8 (merge-or-delete for `mpsc` and `reserving_mpsc`) before the first + publish, not after.** This is the highest-leverage item in the file and it is release-blocking for a + mechanical reason: the decision may *delete a public type*. Doing that before 0.1.0 costs nothing; + doing it after means a breaking release, a yank-and-migrate for anyone who adopted it, and a + permanent line in the changelog explaining why a shape existed for one version. + The measurement is already done and agrees across both architectures -- see M31.5 and M31.7 in + [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) -- so this needs a decision, not more work. + +- [ ] **SH-1.2** -- **Decide explicitly whether M31.6 (loom verification) gates 0.1.0**, and record the + answer either way rather than letting it drift into "not yet". + The reason it deserves a deliberate answer rather than a default: the sabotage sweep demonstrated + that weakening the producer's `Acquire` load of `head` to `Relaxed` left **all twenty tests green**, + while every logic defect injected beside it was caught. So this is not an untested-by-omission gap, + it is a gap this workspace has *evidence* the existing tests cannot close. Publishing a lock-free + queue with it open is a defensible choice; making it unknowingly is not. + +## M2: repair the release plumbing before relying on it + +- [ ] **SH-2.1** -- **Add `windows-waitable-queues-v*` to the tag trigger list in + [.github/workflows/publish-crate.yml](.github/workflows/publish-crate.yml).** It is missing. The + crate *is* registered with release-please, so release-please will happily raise the release PR and + push the tag -- and then nothing will publish it, with no error, because no workflow matches the tag. + **This is a silent failure, which is why it is its own item**: the symptom is a tag that exists, a + changelog that looks right, and a crate that never appears on crates.io. + +- [ ] **SH-2.2** -- Plan the **`windows-topology-sys` 0.2.0 ripple**. `windows-ioring-sys` is published + and pins `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating that dependency and + releasing `windows-ioring-sys` too. Decide the order and whether ioring's release is part of this + push or follows it -- but decide it, because a workspace that builds locally via `path` dependencies + will not reveal this and the first symptom is a consumer unable to resolve the two together. + +- [ ] **SH-2.3** -- Dry-run both publishes (`cargo publish --dry-run`) from the merge commit, and read + the packaged file list rather than only the exit code. A crate that builds in a workspace can still + fail to package -- excluded files, a path dependency without a version, a README that is not in the + package. + +## M3: land the branch + +- [ ] **SH-3.1** -- Open the pull request, and **review it as a diff rather than as a memory of having + written it**. 54 commits across the topology crate, the queue crate and the probes is more than fits + in a session's recollection, and the branch contains at least one deliberate breaking change plus + several documented reversals of earlier conclusions. + +- [ ] **SH-3.2** -- Run the full gate on the merge result, not merely on the branch tip: `cargo fmt + --check`, `cargo clippy --all-targets`, `cargo check --all-targets` in **both** debug and release, + and the in-scope test suites including doctests. Release-mode warnings differ from debug ones, which + is why the milestone discipline names both. + +- [ ] **SH-3.3** -- Run the `windows-waitable-queues` sabotage sweep on a clean tree and confirm every + entry still behaves as declared. It is the crate about to become public and the sweep is what has + caught its real defects -- including a lost wakeup that only surfaced because a *baseline* run hung + once in an otherwise green suite. + +- [ ] **SH-3.4** -- Merge to `main`, and confirm release-please raises a release PR proposing + **0.2.0** for the topology crate. If it proposes 0.1.1, the breaking-change marker did not take and + the version would silently understate the break -- fix the marker rather than editing the version by + hand, or the next break will do the same thing. + +## M4: release + +- [ ] **SH-4.1** -- Release `windows-topology-sys` 0.2.0 and confirm it appears on crates.io and builds + on docs.rs. Docs.rs builds under its own configuration, so a crate that documents locally can still + fail there. + +- [ ] **SH-4.2** -- Update `windows-ioring-sys` to depend on the published 0.2.0 and release it, per + the order settled in SH-2.2. + +- [ ] **SH-4.3** -- Release `windows-waitable-queues` 0.1.0, with SH-2.1's fix in place. Confirm the + tag triggered a publish rather than assuming it did. + +## M5: verify from outside the workspace + +- [ ] **SH-5.1** -- In a scratch project **outside this repository**, depend on both crates from + crates.io and build something that uses each. This is the first exercise of the crates as + *dependencies* rather than as path members, and it is where a missing `version` on a path dependency, + an unexported type, or a feature that only resolves inside the workspace will show up. + +- [ ] **SH-5.2** -- Confirm the published `windows-topology-sys` still reports `Provenance::Measured` + from `discover()` when consumed as a dependency, and that a `Topology::default()` is `Synthetic`. + The provenance rules are the newest thing in the crate and the least exercised outside it. diff --git a/PLANS.md b/PLANS.md index a9d15af7..c46c1c1c 100644 --- a/PLANS.md +++ b/PLANS.md @@ -19,6 +19,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil |---|---|---|---| | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | not started | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | +| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | not started | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | | [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | not started | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Gated on releasing `windows-topology-sys` and `windows-waitable-queues` first** -- the tool depends on the former and calibrates against the latter. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 publishes. | [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | From d445438813871c33e2fdfa8229924435f2807934 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 13:12:20 -0400 Subject: [PATCH 056/361] docs: make the cross-plan links reciprocal instructions rather than footnotes The ship checklist deliberately duplicates items from the design plans, so completing one leaves a second plan describing a world that no longer exists. That reciprocal edit is exactly the step that gets skipped -- the work feels finished, the box gets ticked, and the drift is silent. Four marker kinds, each stating what completing its item obliges, and each with a counterpart at the other end. MIRRORS/MIRRORED BY means the same work in two plans, so both boxes are checked in one commit citing both IDs. GOVERNS/GOVERNED BY means this item decides something about the other and never completes it -- SH-1.2 decides whether loom gates the release, and checking it off must not check off M31.6. UNBLOCKS/GATED BY obliges editing the gated file's gate paragraph, since a gate that has silently lifted is as harmful as one that has not. FEEDS/FED BY obliges writing the answer into the fed item without checking it off. All five pairs verified reciprocal. A marker whose counterpart is missing is a defect to fix rather than a link to ignore, and that rule is stated where it will be read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 23 +++++++++++----- CHECKLIST-placement-tool.md | 16 ++++++++--- CHECKLIST-ship-topology-and-queues.md | 39 +++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 61b133d7..66b0aa9a 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -452,7 +452,12 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m > **-> CROSS-COMPONENT NOTE:** this run also contradicted D-28, which is recorded against that decision > and against M31.8's use of it below, not here. -- [ ] **M31.8** -- Decide merge-or-delete for `mpsc` and `reserving_mpsc`, now that M31.5 has measured +- [ ] **M31.8** -- **MIRRORED BY [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) + SH-1.1 -- one piece of work seen from two plans. Check both off in the same commit; neither is done + alone.** That file also records why this is *release*-blocking rather than merely design-blocking: + the decision may delete a public type, which is free before `windows-waitable-queues` 0.1.0 and a + yank-and-migrate after it. + Decide merge-or-delete for `mpsc` and `reserving_mpsc`, now that M31.5 has measured them and M31.7 will have checked the other architecture. **The decision changed shape once the investigation ran.** M31.2 framed it as "if the shared-line read is cheap, the two merge and the non-reserving one goes". The read is not merely cheap -- it is cheaper @@ -485,7 +490,11 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m protocol; whether any shape then *adopts* caching is a separate question that needs a policy for a measurement that inverts by host, and that question is M-inf.4 rather than this item. -- [ ] **M31.6** -- Verify the memory orderings with a model checker, because stress testing demonstrably +- [ ] **M31.6** -- **GOVERNED BY [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) + SH-1.2, which decides only whether this blocks the 0.1.0 release. SH-1.2 completing does NOT complete + this item**; it records an answer here. Once that answer exists, note it on this line so the reader + knows whether the crate shipped with this open deliberately. + Verify the memory orderings with a model checker, because stress testing demonstrably cannot. **Measured, not assumed:** during M30.3's sabotage sweep, weakening the producer's `Acquire` load of `head` to `Relaxed` left all twenty tests green, while every *logic* defect injected alongside it was caught. A stress test can only observe the interleavings the hardware and scheduler happen to @@ -614,10 +623,12 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio cpu`), so "place the thread in the domain" has only ever been evaluated as "place the thread on one chosen member of the domain". A set mask permits placements a single-processor mask forbids, including both ends of a queue on one logical processor, which on an SMT host is the *common* case - for a same-cache set rather than a corner one. See - [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) M6, which measures set-wide against pinned - affinity under two interference models and reports migration and co-residency counts so a null result - can be distinguished from a scheduler that never moved anything. **If the sets are not equivalent, + for a same-cache set rather than a corner one. + **FED BY [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) M6.7, which does not check this + item off** -- this item is the domain-local placement work itself -- **but whose answer must be + written in here when it lands.** M6 measures set-wide against pinned affinity under two interference + models and reports migration and co-residency counts, so a null result can be distinguished from a + scheduler that never moved anything. **If the sets are not equivalent, the 5.6x is a number about pinned threads and this item needs restating**, not merely re-measuring. The design already intends one pinned thread per domain, so the queue between two threads of the same diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index cc085aea..c2d17223 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -6,6 +6,12 @@ from hardware it does not own. **The motivating gap is concrete: every host avai one NUMA node**, so the entire `cross NUMA node` row and the whole inter-node hop matrix are unmeasured, and no amount of local work will change that. +**GATED BY [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) SH-4.1 +(topology 0.2.0) and SH-4.3 (queues 0.1.0). This paragraph is the gate of record: when those land, +edit it to say the gate is lifted and name the two published versions.** Leaving it as-is after the +releases is the failure mode -- a reader arriving here should never have to reconstruct whether the +gate still applies. M6 below is deliberately *outside* this gate and says so. + **Gated on shipping [crates/windows-topology-sys](crates/windows-topology-sys) and [crates/windows-waitable-queues](crates/windows-waitable-queues) first.** Not a preference: the tool depends on the former, and calibrates against the latter's `spsc`. Both are `0.1.0` and the topology @@ -320,9 +326,13 @@ so this is not a corner case there, it is the common one. that appears only under adversarial spinners is a real finding with a narrower consequence, and collapsing the two would lose exactly that distinction. -- [ ] **M6.7** -- Report per set kind whether the equivalence holds, in the tool's own words, derived - from the measurement rather than asserted. Feed the answer back into - [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.5, whose premise this is. **A null result is +- [ ] **M6.7** -- **FEEDS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.5, whose premise + this tests. On completing this, edit M-inf.5 with the answer** -- it does not check M-inf.5 off (that + item is the domain-local placement work itself), but M-inf.5's 5.6x is a number about *pinned* + threads until this says otherwise, and leaving that unstated is how a measured caveat quietly becomes + an assumed fact. + Report per set kind whether the equivalence holds, in the tool's own words, derived + from the measurement rather than asserted. **A null result is a real result here** -- "the sets behaved equivalently under both interference models, and here are the migration counts showing the scheduler was genuinely exercised" retires a long-standing doubt, and is worth as much as a difference would be. diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 464a9da7..43638689 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -4,6 +4,26 @@ placement tool in [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) has something to build against and other people can run it on hardware this workspace does not own. +## Before checking anything off in this file + +Items here are cross-linked to the other plans, and **a cross-reference is an instruction, not a +footnote**. Every marker names its counterpart and states what completing this item obliges, because +the reciprocal edit is the step that gets skipped: the work feels finished, the box gets ticked, and +a second plan silently keeps describing a world that no longer exists. + +The markers, and what each obliges when its item completes: + +- **MIRRORS / MIRRORED BY** -- the same work seen from two plans. **Check both boxes in the same + commit**, and cite both IDs in the message. Neither is done alone. +- **GOVERNS / GOVERNED BY** -- this item decides something *about* the other; it never completes it. + Write the decision onto the governed item, and leave its box alone. +- **UNBLOCKS / LIFTS THE GATE ON / GATED BY** -- edit the gated file's gate paragraph so it states the + new reality. A gate that has silently lifted is as harmful as one that has not. +- **FEEDS / FED BY** -- write the *answer* into the fed item. It does not check that item off. + +Every marker in this repository's root checklists is reciprocal: if you follow one and find no +counterpart at the other end, that is a defect to fix, not a link to ignore. + **Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md).** That file plans the *design*; this one plans the *release*, and a release has its own failure modes that a design checklist will not surface. Where the two overlap -- M31.8 in particular -- this file states why the item is @@ -22,7 +42,9 @@ release-blocking rather than restating the decision itself. ## M1: settle the public surface before it is public -- [ ] **SH-1.1** -- **Decide M31.8 (merge-or-delete for `mpsc` and `reserving_mpsc`) before the first +- [ ] **SH-1.1** -- **MIRRORS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.8 -- one piece of + work seen from two plans. Check both off in the same commit; neither is done alone.** + **Decide M31.8 (merge-or-delete for `mpsc` and `reserving_mpsc`) before the first publish, not after.** This is the highest-leverage item in the file and it is release-blocking for a mechanical reason: the decision may *delete a public type*. Doing that before 0.1.0 costs nothing; doing it after means a breaking release, a yank-and-migrate for anyone who adopted it, and a @@ -30,7 +52,12 @@ release-blocking rather than restating the decision itself. The measurement is already done and agrees across both architectures -- see M31.5 and M31.7 in [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) -- so this needs a decision, not more work. -- [ ] **SH-1.2** -- **Decide explicitly whether M31.6 (loom verification) gates 0.1.0**, and record the +- [ ] **SH-1.2** -- **GOVERNS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.6 -- this is not + that item and does not complete it.** It decides only whether M31.6 blocks SH-4.3. If the answer is + "it gates", record that on M31.6 and SH-4.3 cannot proceed until M31.6 is done; if "it does not", + record that too, so a later reader does not mistake a considered choice for an oversight. **Checking + this off never checks off M31.6.** + **Decide explicitly whether M31.6 (loom verification) gates 0.1.0**, and record the answer either way rather than letting it drift into "not yet". The reason it deserves a deliberate answer rather than a default: the sabotage sweep demonstrated that weakening the producer's `Acquire` load of `head` to `Relaxed` left **all twenty tests green**, @@ -85,12 +112,20 @@ release-blocking rather than restating the decision itself. - [ ] **SH-4.1** -- Release `windows-topology-sys` 0.2.0 and confirm it appears on crates.io and builds on docs.rs. Docs.rs builds under its own configuration, so a crate that documents locally can still fail there. + **UNBLOCKS half of [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md), which is gated on both + releases. On completing this, update that file's gate note to record that topology has shipped** -- + the gate lifts only when SH-4.3 lands too, and a half-lifted gate that reads as lifted is how work + starts against a dependency that is not there yet. - [ ] **SH-4.2** -- Update `windows-ioring-sys` to depend on the published 0.2.0 and release it, per the order settled in SH-2.2. - [ ] **SH-4.3** -- Release `windows-waitable-queues` 0.1.0, with SH-2.1's fix in place. Confirm the tag triggered a publish rather than assuming it did. + **LIFTS THE GATE ON [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md). On completing this, + edit that file's opening gate paragraph to say the gate is lifted and name the two published + versions**, so a reader arriving there later does not have to reconstruct whether it still applies. + Blocked by SH-1.1, and by M31.6 as well if SH-1.2 decided that it gates. ## M5: verify from outside the workspace From 72bb9fa498b2b0c568ce7b20f8bb29d881c40ff8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 13:44:20 -0400 Subject: [PATCH 057/361] docs(waitable-queues): keep both MPSC shapes, and publish the data instead of a verdict D-26 falsified D-16's premise -- reading the consumer's position was supposed to make reserving_mpsc the expensive shape, and it is the faster one under contention by up to 4x on x64 and 6.4x on ARM64 -- which reopened merge-or-delete. The answer is neither. The two are not one queue with a feature flag. mpsc implements Vyukov's bounded array protocol; reserving_mpsc counts free slots against the consumer's position, which is the only way a reservation can be answered at all. Both are independently studied designs with production track records. Our own workload having settled which we want is a fact about our workload, and deleting a shape because no visible consumer wants it is what PLATFORM INTEGRITY forbids. What the crate owes instead is the data and the means to gather it: the measured numbers for both architectures, stated as two data points rather than a law, and probe-core-affinity so a caller can settle it on their own hardware. Two available justifications are refused, because a rationale that evaporates on inspection is worse than none. Not capacity: mpsc reaches 2^63 slots and reserving_mpsc 2^31, but that counts slots allocated at construction rather than items ever pushed, and 2^31 slots is tens of gigabytes before the ring holds anything useful. Not mpsc being faster somewhere: its one measured advantage is a single producer with a live consumer, and at one producer the right shape is spsc, which is faster still and which this crate also ships. The split rests on capability alone -- reserving_mpsc implements Reserving and mpsc structurally cannot. Everything else is profile, and profile is the caller's to measure. Swept the falsified cost premise across 6 sites: D-16's table row and detail section, mpsc.rs's BOUNDS doc, its bounded_with note and its high-water branch comment, reserving_mpsc.rs's module header, and the README's "will not make you pay" bullet, which was the most user-visible statement of it and told readers the slower shape was the cheap one. Adds caller-facing guidance in both lib.rs and the README, because docs.rs shows one and crates.io the other and neither audience sees both. Records the obligation this creates: M31.6's loom verification covers both shapes or neither is verified, and M-inf.4's peer-index policy is decided for both or the crate ships two answers to one question. SH-2.4 queues eight pre-existing rustdoc warnings found while doing this. They are not introduced here, and they are release-blocking because docs.rs is the face of a first release. Completed item: SH-1.1: Decide M31.8 before the first publish, not after. Completed item: M31.8: Decide merge-or-delete for mpsc and reserving_mpsc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 2 +- CHECKLIST-ship-topology-and-queues.md | 11 +++- .../windows-waitable-queues/DESIGN-NOTES.md | 59 ++++++++++++++++- crates/windows-waitable-queues/README.md | 64 +++++++++++++++++-- crates/windows-waitable-queues/src/lib.rs | 41 ++++++++++++ crates/windows-waitable-queues/src/mpsc.rs | 33 +++++++--- .../src/reserving_mpsc.rs | 15 ++++- 7 files changed, 204 insertions(+), 21 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 66b0aa9a..8ef205d5 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -452,7 +452,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m > **-> CROSS-COMPONENT NOTE:** this run also contradicted D-28, which is recorded against that decision > and against M31.8's use of it below, not here. -- [ ] **M31.8** -- **MIRRORED BY [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) +- [x] **M31.8** -- **MIRRORED BY [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) SH-1.1 -- one piece of work seen from two plans. Check both off in the same commit; neither is done alone.** That file also records why this is *release*-blocking rather than merely design-blocking: the decision may delete a public type, which is free before `windows-waitable-queues` 0.1.0 and a diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 43638689..7652e256 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -42,7 +42,7 @@ release-blocking rather than restating the decision itself. ## M1: settle the public surface before it is public -- [ ] **SH-1.1** -- **MIRRORS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.8 -- one piece of +- [x] **SH-1.1** -- **MIRRORS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.8 -- one piece of work seen from two plans. Check both off in the same commit; neither is done alone.** **Decide M31.8 (merge-or-delete for `mpsc` and `reserving_mpsc`) before the first publish, not after.** This is the highest-leverage item in the file and it is release-blocking for a @@ -80,6 +80,15 @@ release-blocking rather than restating the decision itself. push or follows it -- but decide it, because a workspace that builds locally via `path` dependencies will not reveal this and the first symptom is a consumer unable to resolve the two together. +- [ ] **SH-2.4** -- Clear the **eight rustdoc warnings** in `windows-waitable-queues` before it is + published. They pre-date this branch and were found while doing SH-1.1: an unresolved link to + `MIN_CAPACITY`, six links from public documentation to private items (`Shared::len`, + `Doorbell::clear`, `Doorbell`, `BOUNDS`), and one redundant explicit link target. + Ordinarily out of scope for the item that found them, and in scope here for one reason: **docs.rs is + the face of a first release.** A link that silently resolves to nothing in a workspace build renders + as a dead or missing reference to the first person who ever reads these docs, and a link to a private + item points at a page they cannot open. + - [ ] **SH-2.3** -- Dry-run both publishes (`cargo publish --dry-run`) from the merge commit, and read the packaged file list rather than only the exit code. A crate that builds in a workspace can still fail to package -- excluded files, a path dependency without a version, a README that is not in the diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 64f13c8d..70d84f16 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -39,7 +39,7 @@ preferred. | D-13 | **The arming protocol is written once, in `blocking.rs`, and a shape binds to it by implementing a crate-private `Parked` trait.** The blocking receive loop *is* [D-9](#d-9), not glue around it; a second shape spelling it out again would be a second copy of a rule -- the exact mistake this crate has already paid for once. | | D-14 | **`mpsc`'s arming asks "would `pop` find something", not "is `len` zero".** The two disagree over a slot a producer has claimed but not published, and only the first answer lets the consumer park on it instead of spinning until that producer is rescheduled. | | D-15 | **`Doorbell::clear` resets the event *before* clearing the flag that mirrors it, and the original order was a lost wakeup.** A producer signalling between the two lines set the flag and issued a real `SetEvent`; the `ResetEvent` that followed erased the signal and left the flag set, wedging the doorbell dark while it claimed to be lit. **Amends [D-9](#d-9)**, whose "there is no third case" holds only for a queue whose emptiness is one position comparison. | -| D-16 | **Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `mpsc` rather than replacing it.** Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `mpsc`'s push deliberately never reads. Rather than charge every caller for a capability not every caller wants, both ship. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. | +| D-16 | **Its cost premise is falsified by [D-26](#d-26); the conclusion stands on capability instead -- see [D-29](#d-29).** Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `mpsc` rather than replacing it. Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `mpsc`'s push deliberately never reads. The original rationale added that this made reserving the *more expensive* shape and that both should ship rather than charge every caller for it; measurement reversed that, and the split is now justified by the capability alone. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. | | D-17 | **The reservation count and the claim position live in one word, because a check-and-claim over both must be a single atomic operation.** Two atomics cannot be made correct with any amount of fencing: the pushing producer is load-then-store and the reserving one store-then-load, so the Dekker argument does not apply and both can miss each other. The 32/32 split is forced by the arithmetic, and caps this shape at 2^31 items. | | D-18 | **A 128-bit compare-and-swap is refused.** It would lift the 2^31 cap and nothing else -- the consumer's position still has to be read -- at the cost of a dependency, a target-feature floor not in the x86-64 baseline, and a different instruction on the ARM64 machine this workspace measures on. Revisit only for a tagged pointer, which is what [M-inf.1](../../CHECKLIST-io-domains.md)'s linked and sharded shapes would need. | | D-19 | **The coalesced loss latch is deliberately not generalised from the file watcher.** Coalescing there is sound because a desync is *idempotent* -- two mean the same as one, and the answer to both is a re-scan. A queue of arbitrary `T` has no such property, so what generalises is a loss *count*, which is [M31.4](../../CHECKLIST-io-domains.md)'s observability rather than a policy. | @@ -52,6 +52,7 @@ preferred. | D-26 | **Measured: the tail claim contends badly, and `reserving_mpsc` is up to 4x FASTER than `mpsc` under contention -- the opposite of what [D-16](#d-16) assumed.** Aggregate throughput *falls* as producers are added, for both shapes and far more than a bare contended atomic explains. D-16's premise, that reading the consumer's position makes the reserving shape the expensive one, is falsified everywhere except a single producer with a live consumer. | | D-27 | **The gap is intrinsic to Vyukov's sequence protocol, not a fixable flaw in `mpsc`'s retry loop.** Its producer must read a slot's sequence *before* claiming, and that slot marches through memory as the tail advances while other producers write it. Padding slots onto their own cache lines was tested and rejected: it recovers about a fifth at eight producers, for four times the memory, and leaves the shape still 2.8x slower. | | D-28 | **Amended -- the blanket rejection is withdrawn; the verdict depends on thread placement, and the open question is queued as [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M-inf.4.** Caching the peer's index was measured, and it engaged as designed. It cost ~1.8x on x64 with the threads across cores, and *won* 17x on ARM64 and 1.8x on x64 SMT siblings. Batch depth decides the sign, and batch depth is set by where the two threads are scheduled -- not by the architecture and not by our code. A prefetch-only "warming" control changed nothing on any host. | +| D-29 | **Both multi-producer shapes ship. The crate publishes what it measured and declines to choose for the caller.** [D-26](#d-26) falsified [D-16](#d-16)'s cost premise, which reopened merge-or-delete; the answer is neither. Vyukov's sequence protocol and the head-based one are independently researched designs, both in production use, and our own workload having settled which *we* want is not evidence about anyone else's. Deleting a shape because no visible consumer wants it is what PLATFORM INTEGRITY forbids. What the crate owes instead is the data and, through `probe-core-affinity`, the means to gather it on the caller's own hardware. | ## D-2: capabilities are sliced, not gathered @@ -468,6 +469,10 @@ and thrown away the reason the flag exists. [D-6](#d-6) said overflow "fails or reserves, and never overwrites", and quietly assumed one queue would carry both policies. Building the second one showed that assumption was wrong, and why. +**The cost claim in this section is falsified; the structural claim is not.** Read what follows as an +account of *why the two shapes differ*, which remains correct, and not as an account of which is +cheaper, which [D-26](#d-26) reversed. [D-29](#d-29) records what the split rests on now. + **Honouring a reservation costs the producer something on every push, including the pushes that never reserve anything.** `mpsc`'s producer never reads the consumer's position: it asks the slot's own sequence number "are you free?", and those are spread across the slot array, so producers working at @@ -1008,3 +1013,55 @@ report it. This is the second time in this investigation that an instrument's *p than its measurement nearly produced a wrong conclusion (the first was the fixed-prose interpretation noted above). The fix also makes the "near vs far" summary fall back to the sibling pair on hosts where `same cache, same class` is not expressible, which would otherwise have printed nothing here. + + +## D-29: both multi-producer shapes ship, and the caller is given the data instead of a verdict + +[D-26](#d-26) falsified [D-16](#d-16)'s premise -- reading the consumer's position was supposed to make +`reserving_mpsc` the expensive shape, and it is instead the faster one under contention, by up to 4x on +x64 and 6.4x on ARM64. That reopened a question D-16 had treated as settled: if the split does not buy +what it claimed, should the shapes merge, or should one be deleted? + +**Neither. Both ship, and the crate declines to choose between them on the caller's behalf.** + +The two are not one queue with a feature flag. `mpsc` implements Vyukov's bounded array protocol, where +a producer asks a slot's own sequence number whether it is free; `reserving_mpsc` counts free slots +against the consumer's position, which is the only way a reservation can be answered at all. Both are +independently studied designs with production track records, chosen by different systems for different +reasons. **Our own workload having settled which we want is a fact about our workload**, and treating it +as a fact about queueing would be exactly the narrowing PLATFORM INTEGRITY forbids: the absence of a +visible consumer for a design is not evidence that none exists. + +What the crate owes a caller instead is honesty and equipment: + +- **The measurements, stated plainly**, including the regimes where each wins and the fact that the + answer inverted once already when a second architecture was tried. +- **The means to measure their own domain.** `probe-core-affinity` and the placement tool exist so a + caller can settle this on their own hardware and workload rather than inheriting ours. A queue + library that publishes one benchmark and calls it a recommendation is asserting a conclusion about + machines it has never seen. + +### What the split does *not* rest on + +Two justifications are available and both are refused, because a rationale that evaporates on +inspection is worse than none: + +- **Not capacity.** `mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, and that difference is + unreachable: it counts slots allocated at construction, not items ever pushed, and 2^31 slots is tens + of gigabytes before the ring holds anything useful. See [D-17](#d-17) for why the packing forces it. +- **Not `mpsc` being faster somewhere.** Its one measured advantage is a single producer with a live + consumer -- and at one producer the right shape is [`spsc`](#d-1), which is faster still and which + this crate also ships. A shape kept for a regime already better served elsewhere is kept on + sentiment. + +The split rests on **capability**: `reserving_mpsc` implements `Reserving` and `mpsc` cannot, for the +structural reason D-16's surviving half explains. Everything else is profile, and profile is the +caller's to measure. + +### The obligation this creates + +Keeping both doubles the surface that every later decision must cover, and that is accepted knowingly +rather than discovered later. `M31.6`'s loom verification covers both shapes or neither is verified; +`M-inf.4`'s peer-index policy is decided for both or the crate ships two different answers to one +question. **A shape kept for others' benefit is still a shape this crate maintains**, and the moment +that maintenance is skipped for one of them, the argument above stops being true. diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 9a49c663..af680e64 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -67,18 +67,70 @@ says so rather than the documentation: `spsc` accepts one slot, and `mpsc` needs two, because its per-slot sequence cannot distinguish "just published" from "free again next lap" in a one-slot ring. +## Choosing between `mpsc` and `reserving_mpsc` + +They are **two different claim protocols**, not one queue with a switch. `mpsc` +is Vyukov's bounded array queue: a producer asks a slot's own sequence number +whether it is free. `reserving_mpsc` counts free slots against the consumer's +position, which is the only way a reservation can be answered at all. Both are +well-studied designs in production use elsewhere, which is why this crate ships +both rather than picking one for you. + +**Start here:** + +- Need `reserve`? Only `reserving_mpsc` has it, and `mpsc` structurally cannot. +- Otherwise, **start with `reserving_mpsc`.** It was the faster of the two at + every producer count we measured above one. +- Only one producer *and* one consumer? Use `spsc`, which beats both. + +**What we measured**, in ns per push, isolated regime, median of three runs. +Higher producer counts oversubscribe both hosts: + +| producers | `mpsc` (x64) | `reserving` (x64) | `mpsc` (ARM64) | `reserving` (ARM64) | +|---|---|---|---|---| +| 1 | 9.0 | 8.6 | 6.5 | 6.1 | +| 2 | 49.0 | 28.0 | 29.8 | 9.4 | +| 4 | 84.4 | 33.3 | 60.6 | 12.9 | +| 8 | 140.8 | 38.5 | 167.4 | 29.8 | +| 16 | 193.5 | 52.2 | 194.9 | 30.6 | +| 32 | 239.7 | 56.9 | 195.0 | 30.6 | + +x64 is an AMD EPYC 7763 slice (8 cores, 16 threads); ARM64 is a Snapdragon X2 +Elite (12 cores, no SMT). **Read these as two data points, not as a law.** This +comparison has already inverted once: it was designed on the assumption that +`mpsc` would be the cheaper shape, and measurement said otherwise on both +machines. + +**Measure your own workload before treating any of this as settled.** Producer +count, how hard the consumer drains, and where the threads are scheduled all +move the answer -- thread placement alone moved an SPSC handoff by 5.6x on one +of these hosts. The `probe-core-affinity` tool in this repository exists so you +can run that measurement on your hardware instead of inheriting ours. + +Two things that look like reasons to choose and are not: + +- **Capacity.** `mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, but that + counts slots allocated up front, not items ever pushed. A ring of 2^31 slots + is tens of gigabytes before it holds anything useful. +- **`mpsc` winning at one producer.** True in one regime, and at one producer + you want `spsc` anyway. + ## What it will not do - **It will not overwrite.** A full queue fails, or a reservation guarantees a slot. Overwrite-oldest is right for telemetry, where a lost entry is a lost sample; here an entry may be an I/O submission, where a lost entry is a lost operation. -- **It will not make you pay for reservation if you do not want it.** Honouring - a reservation means counting free slots, which costs a producer a read of the - consumer's position on every push. `mpsc` does not offer reservation and does - not pay; `reserving_mpsc` offers it and does. That is why `mpsc` does not - implement the `Reserving` trait -- it genuinely cannot, which is the whole - reason the traits are narrow. +- **It will not decide between two real queue designs on your behalf.** `mpsc` + and `reserving_mpsc` are different claim protocols, both well studied and both + used in production and in research. `mpsc` asks each slot's own sequence + number "are you free?"; `reserving_mpsc` counts free slots against the + consumer's position, which is what makes a reservation answerable at all -- + and why `mpsc` does not implement the `Reserving` trait. It genuinely cannot, + which is the whole reason the traits are narrow. + **Which is faster is a property of your workload, not of the designs**, and we + publish what we measured rather than choosing for you -- see "Choosing between + them" below. - **It will not allocate on push.** Bounded shapes allocate once, at construction. - **It will not create a kernel object you never use.** The doorbell is created diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index c04af197..6fc0223e 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -46,6 +46,47 @@ //! [`Reserving`] -- each naming one thing a queue can do, so a caller can be //! generic over exactly what it needs and nothing more. //! +//! # Choosing between `mpsc` and `reserving_mpsc` +//! +//! They are **two different claim protocols, not one queue with a switch**. +//! [`mpsc`] is Vyukov's bounded array queue, where a producer asks a slot's own +//! sequence number whether it is free. [`reserving_mpsc`] counts free slots +//! against the consumer's position, which is the only way a reservation can be +//! answered at all. Both are well-studied designs in production use elsewhere, +//! which is why this crate ships both instead of picking one for you. +//! +//! - Need [`Reserving`]? Only [`reserving_mpsc`] has it; [`mpsc`] structurally +//! cannot. +//! - Otherwise **start with [`reserving_mpsc`]**: it was the faster of the two +//! at every producer count above one that we measured. +//! - One producer *and* one consumer? Use [`spsc`], which beats both. +//! +//! Measured ns per push, isolated regime, median of three. An AMD EPYC 7763 +//! slice (8 cores, 16 threads) and a Snapdragon X2 Elite (12 cores, no SMT): +//! +//! | producers | `mpsc` x64 | `reserving` x64 | `mpsc` ARM64 | `reserving` ARM64 | +//! |---|---|---|---|---| +//! | 1 | 9.0 | 8.6 | 6.5 | 6.1 | +//! | 2 | 49.0 | 28.0 | 29.8 | 9.4 | +//! | 4 | 84.4 | 33.3 | 60.6 | 12.9 | +//! | 8 | 140.8 | 38.5 | 167.4 | 29.8 | +//! | 16 | 193.5 | 52.2 | 194.9 | 30.6 | +//! | 32 | 239.7 | 56.9 | 195.0 | 30.6 | +//! +//! **Read these as two data points, not as a law**, and measure your own +//! workload before treating them as settled. This comparison has already +//! inverted once: the split was designed on the assumption that `mpsc` would be +//! the cheaper shape, and measurement disagreed on both machines. Producer +//! count, how hard the consumer drains, and where the threads are scheduled all +//! move the answer -- placement alone moved an SPSC handoff by 5.6x on one of +//! these hosts. +//! +//! Two things that look like reasons to choose and are not. **Capacity**: +//! `mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, but that counts slots +//! allocated up front rather than items ever pushed, and 2^31 slots is tens of +//! gigabytes before the ring holds anything useful. **`mpsc` winning at one +//! producer**: true in one regime, and at one producer you want [`spsc`]. +//! //! # Shutting down //! //! A consumer learns that every producer is gone from diff --git a/crates/windows-waitable-queues/src/mpsc.rs b/crates/windows-waitable-queues/src/mpsc.rs index 473f8fde..ab54d341 100644 --- a/crates/windows-waitable-queues/src/mpsc.rs +++ b/crates/windows-waitable-queues/src/mpsc.rs @@ -108,7 +108,12 @@ use crate::options::Options; /// The maximum is the widest any shape may be, because this one's positions are /// full-width [`usize`] values with nothing packed beside them -- /// [`reserving_mpsc`](crate::reserving_mpsc) pays for its reservations with a -/// far lower ceiling. +/// far lower ceiling of 2^31. +/// +/// **Do not choose between the shapes on this.** That ceiling counts *slots +/// allocated at construction*, not items ever pushed, and a ring of 2^31 slots +/// is tens of gigabytes before it holds anything useful. The difference is real +/// and practically unreachable. const BOUNDS: Bounds = Bounds { min: 2, max: WRAPPING_MAX_CAPACITY, @@ -157,10 +162,13 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit /// **Note which switch costs this shape something.** /// [`Options::tracking_high_water`] makes the producer read the consumer's /// position on every push -- the single shared line this shape's push is built -/// to avoid touching, and the reason -/// [`reserving_mpsc`](crate::reserving_mpsc) exists as a separate shape at all. -/// Off, which is the default, it costs one predictable branch on a field that -/// is written once at construction. +/// to avoid touching. Off, which is the default, it costs one predictable +/// branch on a field that is written once at construction. +/// +/// That avoidance is what distinguishes the two multi-producer shapes, but +/// **it is not what makes either one faster**: measurement found this shape the +/// slower of the two under contention, by up to 6.4x. See the crate +/// documentation for the numbers and for how to choose. /// /// # Errors /// @@ -459,11 +467,16 @@ impl Producer { .store(position.wrapping_add(1), Ordering::Release); // **Guarded, and this branch is the whole reason high-water is a - // switch.** This shape's producer never reads `head` -- that property - // is what keeps its push off the one line every thread touches, and it - // is why `reserving_mpsc` is a separate shape rather than a method - // here. Depth cannot be known without that read, so the read is taken - // only when somebody asked for the answer. + // switch.** This shape's producer never reads `head`, which is what + // keeps its push off the one line every thread touches. Depth cannot be + // known without that read, so the read is taken only when somebody + // asked for the answer. + // + // Note what this property does *not* buy: measurement found this shape + // slower than `reserving_mpsc` under contention despite it, because the + // slot sequence a producer must read instead marches through memory + // while other producers write it. Staying off the shared line is why + // the two shapes are different, not why either is quick. // // Off, the cost is one predictable branch on a field written once at // construction, so the line is shared but read-only -- the cheap kind. diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 6bb75bbc..f11bdff1 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -8,8 +8,19 @@ //! //! # Why this is a separate shape rather than a method on `mpsc` //! -//! Because honouring a reservation costs the producer something on **every** -//! push, including the pushes that never reserve anything. +//! Because the two ask different questions to claim a slot, and only this one's +//! question can answer a reservation. They are two claim protocols, not one +//! queue with a switch. +//! +//! Honouring a reservation costs the producer a read of the consumer's position +//! on **every** push, including the pushes that never reserve anything -- which +//! is what `mpsc` avoids and why it cannot offer reservation at all. +//! +//! **That cost is not what makes either shape slower.** This one measured +//! *faster* than `mpsc` under contention on both architectures tried, by up to +//! 6.4x, because the slot sequence `mpsc` reads instead marches through memory +//! while other producers write it. See the crate documentation for the numbers +//! and for how to choose. //! //! `mpsc`'s producer never reads the consumer's position. It asks a different //! question -- "is the slot I am about to claim free?" -- and reads that from From f7878b47b43c3a2ed6cea68c080631349c85ea06 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 14:09:05 -0400 Subject: [PATCH 058/361] refactor(waitable-queues)!: rename mpsc to slotwise_mpsc, and document pedigree and prior art Two pieces of release-readiness work that land together because they touch the same files. Both are public-surface changes, free before the first publish and expensive after it. **The rename.** A bare `mpsc` beside `reserving_mpsc` made one shape canonical by implication, which contradicts this crate's own "no shape is the canonical one, so there is deliberately no type named Queue" -- and after D-29 said neither is preferred, it was simply false. `slotwise_mpsc` names its claim protocol: it claims slot by slot, with no shared counter. `sequence_mpsc` was considered and rejected because it invites the reading that it alone preserves FIFO order, which both shapes do. Recorded as D-30. Two over-replacements caught while doing it, both from `\bmpsc\b` matching more than intended: `std::sync::mpsc` in five files, and two doctests left calling `slotwise_mpsc::channel()` against a repaired `use std::sync::mpsc`. Every sabotage entry's find-pattern was re-verified to anchor exactly once after the rename -- 39 of 39 -- which itself needed a corrected checker, because `find` is an array of lines and a first attempt compared against its element count. **The pedigree.** A public concurrent-queue crate must say where its algorithms come from, or a reader reasonably assumes they are homegrown. They are not, and that is deliberate: a concurrent queue is a bad place to be original, because the failure mode is a reordering that appears on one machine, under load, months later. spsc is the classic padded SPSC ring; slotwise_mpsc is Vyukov's bounded array queue specialised to one consumer; reserving_mpsc uses credit-style counting against the consumer's position, which is the only way to answer "will there be room later". **And why not an existing crate**, which is the other question a reader has. The answer is structural rather than dismissive: on Windows, waiting is a kernel-object operation, so a queue whose readiness is not a HANDLE cannot join a WaitForMultipleObjects beside an I/O completion, a process handle, or a cancellation event -- however good its blocking receive, and however rich its select, which can only select over its own channels. The three workarounds (poll on a timer, dedicate a thread to convert a condvar back into an event, move everything to async) are named with what each costs. Both written into the crate docs and the README, because docs.rs shows one and crates.io the other and neither audience sees both. Completed item: SH-1.3: Qualify both MPSC shapes by name. Completed item: SH-1.4: State the algorithms' pedigree and why an existing crate is not used. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 42 +++++----- CHECKLIST-ship-topology-and-queues.md | 22 ++++- .../windows-waitable-queues/DESIGN-NOTES.md | 77 ++++++++--------- crates/windows-waitable-queues/README.md | 84 +++++++++++++++---- crates/windows-waitable-queues/sabotage.json | 54 ++++++------ .../windows-waitable-queues/src/capacity.rs | 4 +- .../windows-waitable-queues/src/doorbell.rs | 2 +- crates/windows-waitable-queues/src/error.rs | 2 +- crates/windows-waitable-queues/src/lib.rs | 77 +++++++++++++++-- .../src/metrics/tests.rs | 2 +- crates/windows-waitable-queues/src/options.rs | 4 +- .../src/reserving_mpsc.rs | 32 +++---- .../src/reserving_mpsc/tests.rs | 8 +- .../src/{mpsc.rs => slotwise_mpsc.rs} | 16 ++-- .../src/{mpsc => slotwise_mpsc}/tests.rs | 0 crates/windows-waitable-queues/src/spsc.rs | 8 +- .../windows-waitable-queues/src/spsc/tests.rs | 2 +- crates/windows-waitable-queues/src/traits.rs | 10 +-- .../src/traits/tests.rs | 12 +-- 19 files changed, 297 insertions(+), 161 deletions(-) rename crates/windows-waitable-queues/src/{mpsc.rs => slotwise_mpsc.rs} (98%) rename crates/windows-waitable-queues/src/{mpsc => slotwise_mpsc}/tests.rs (100%) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 8ef205d5..b5387a7c 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -220,7 +220,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m `ResetEvent` that followed erased the signal while leaving the flag set -- so the doorbell was dark while claiming to be lit and every later signal skipped. The order had a written argument behind it ("the caller's re-check sees the racing producer's item") that is **true for `spsc` and false for - `mpsc`**, whose re-check asks only whether the *head* slot is published. The fix moves the guarantee + `slotwise_mpsc`**, whose re-check asks only whether the *head* slot is published. The fix moves the guarantee from the caller to the type: once `clear` returns the flag is false, so no future shape has to have a re-check strong enough to cover the window. [D-15](crates/windows-waitable-queues/DESIGN-NOTES.md#d-15), which amends D-9 rather than being filed beside it. @@ -242,10 +242,10 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m **Done, and the multi-producer case forced a decision the item did not anticipate.** Honouring a reservation means knowing how many slots remain, which means reading the consumer's position -- one line every thread touches -- on *every* push, including the pushes that never reserve anything. - `mpsc`'s producer avoids that read by design: it asks the slot's own sequence "are you free", and those - are dispersed across the slot array. So `mpsc` genuinely cannot answer the reservation question, and + `slotwise_mpsc`'s producer avoids that read by design: it asks the slot's own sequence "are you free", and those + are dispersed across the slot array. So `slotwise_mpsc` genuinely cannot answer the reservation question, and rather than charge every caller for a capability not every caller wants, **`reserving_mpsc` ships as a - peer and `mpsc` is untouched** ([D-16](crates/windows-waitable-queues/DESIGN-NOTES.md#d-16)). The + peer and `slotwise_mpsc` is untouched** ([D-16](crates/windows-waitable-queues/DESIGN-NOTES.md#d-16)). The engineer chose this split over the alternatives when it was raised. **The reservation count and the claim position share one 64-bit word, and that is the correctness argument rather than tidiness** ([D-17](crates/windows-waitable-queues/DESIGN-NOTES.md#d-17)). With the @@ -257,7 +257,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m the shape at 2^31 items, reported through the same per-shape bound D-12 introduced for the minimum. Two consequences worth noting: redeeming is a single exchange that moves both halves, so `occupied + reserved` is never momentarily wrong; and the producer stops needing the slot sequence for - the "free" direction, so this shape's `pop` is one store *shorter* than `mpsc`'s. + the "free" direction, so this shape's `pop` is one store *shorter* than `slotwise_mpsc`'s. **A 128-bit compare-and-swap was raised and refused** ([D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18)): it lifts the cap and nothing else, since the consumer's position still has to be read, and it costs a new dependency, a target-feature floor not @@ -343,10 +343,10 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m **High-water is the one that cannot be placed that way, and the cost is uneven in a way that lands on D-16.** A peak must observe every change. On `spsc` that is free (the producer already reads `head` and owns `tail`) and on `reserving_mpsc` near-free (its producer reads `head` for the room check), but - `mpsc`'s producer **never reads `head`** -- that is the property D-16 built a separate shape to - preserve. Always-on would have imposed D-16's refused cost on every `mpsc` user, to serve a metric most + `slotwise_mpsc`'s producer **never reads `head`** -- that is the property D-16 built a separate shape to + preserve. Always-on would have imposed D-16's refused cost on every `slotwise_mpsc` user, to serve a metric most will never read, immediately before M31.5 measures that exact path. Omitting it would have narrowed the - shape. So it is **opt-in at construction**, off by default, and `mpsc` pays one predictable branch on a + shape. So it is **opt-in at construction**, off by default, and `slotwise_mpsc` pays one predictable branch on a read-only field when it is off ([D-23](crates/windows-waitable-queues/DESIGN-NOTES.md#d-23)). The engineer chose this over the narrow-trait and always-on alternatives when it was raised. Untracked reports `None` rather than `0`, because "nobody was counting" and "it never filled" are @@ -361,7 +361,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m the same patch now has to be **caught**, and the entry changed sides. That is R9 working rather than a regression: an optimisation nobody can measure is an assumption. What it costs is that the skip is now part of what the queue promises, which is the right trade for a queue whose reason to exist is a wakeup - protocol -- but it is a trade. The vacated control is replaced rather than dropped, by `mpsc`'s + protocol -- but it is a trade. The vacated control is replaced rather than dropped, by `slotwise_mpsc`'s tracking guard, which is genuinely an optimisation and must still survive removal. **`Observable` deliberately does not restate depth** ([D-25](crates/windows-waitable-queues/DESIGN-NOTES.md#d-25)), though D-2's sketch listed it: `len` @@ -378,7 +378,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m Record the result either way -- a measurement that says "the simple thing is fine" is worth as much as one that does not, and is the cheaper outcome to lose track of. - **Also measure `reserving_mpsc` against `mpsc`, and decide their merge-or-delete here.** M31.2 shipped + **Also measure `reserving_mpsc` against `slotwise_mpsc`, and decide their merge-or-delete here.** M31.2 shipped them as two shapes because reservation costs the producer a read of the consumer's position on every push, and *how much* that costs was a judgement rather than a measurement ([D-16](crates/windows-waitable-queues/DESIGN-NOTES.md#d-16)). This benchmark already stands up N @@ -396,14 +396,14 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m **The tail claim contends, so the licence to close M-inf.1 was not granted** -- but the gate there is now a number rather than a judgement, because contending and being the bottleneck are different things. See M-inf.1 for the quantified trigger. - **`reserving_mpsc` is up to 4x FASTER than `mpsc` under contention, which inverts D-16's premise** + **`reserving_mpsc` is up to 4x FASTER than `slotwise_mpsc` under contention, which inverts D-16's premise** ([D-26](crates/windows-waitable-queues/DESIGN-NOTES.md#d-26)). The split shipped on the reasoning that reading the consumer's position made the reserving shape the expensive one; it is the cheaper one at every producer count from two upward, and the premise survives only at a single producer against a live consumer -- where `spsc` is the right answer anyway. **Investigated before concluding, at the engineer's direction, and the gap is intrinsic rather than a fixable flaw** ([D-27](crates/windows-waitable-queues/DESIGN-NOTES.md#d-27)). Both protocols do one CAS - plus one load per attempt; the difference is *which* load. `mpsc` must read the slot's own sequence + plus one load per attempt; the difference is *which* load. `slotwise_mpsc` must read the slot's own sequence before claiming -- an address that marches through memory as the tail advances, written by the producers it is racing -- where `reserving_mpsc` reads one fixed `head`. The false-sharing hypothesis was tested and rejected: padding each slot onto its own cache line recovers about a fifth at eight producers for @@ -437,14 +437,14 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m | 16 | 194.9 | 30.6 | 10.6 | 6.4x | 3.7x | | 32 | 195.0 | 30.6 | 9.9 | 6.4x | 4.2x | - Claim 1 (throughput falls as producers are added) holds: `mpsc` costs 30x more per push at 32 + Claim 1 (throughput falls as producers are added) holds: `slotwise_mpsc` costs 30x more per push at 32 producers than at one. Claim 2 (`reserving_mpsc` is up to 4x faster) holds and is exceeded -- **6.4x here against 4.2x on x64**. So M31.8's merge decision is not weakened by the second architecture; the evidence for the head-based protocol is stronger on ARM64 than it was on x64. - Two differences worth having on the record rather than smoothing away. `mpsc` **plateaus at ~195 ns + Two differences worth having on the record rather than smoothing away. `slotwise_mpsc` **plateaus at ~195 ns from 16 producers upward** where x64 kept climbing to 239.7 -- expected, since this host has 12 cores and no SMT, so 16 and 32 are oversubscribed and the curve saturates. And **N=4 is by far the noisiest - point** (`mpsc` ranged 49.5 to 104.1 across the three runs, against under 2% spread at N=16 and above); + point** (`slotwise_mpsc` ranged 49.5 to 104.1 across the three runs, against under 2% spread at N=16 and above); with two six-core L2 clusters and no L3, whether four threads land inside one cluster or straddle both changes the answer, and at N>=8 straddling is forced so the variance disappears. Read the N=4 row as a range, not a point. @@ -457,23 +457,23 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m alone.** That file also records why this is *release*-blocking rather than merely design-blocking: the decision may delete a public type, which is free before `windows-waitable-queues` 0.1.0 and a yank-and-migrate after it. - Decide merge-or-delete for `mpsc` and `reserving_mpsc`, now that M31.5 has measured + Decide merge-or-delete for `slotwise_mpsc` and `reserving_mpsc`, now that M31.5 has measured them and M31.7 will have checked the other architecture. **The decision changed shape once the investigation ran.** M31.2 framed it as "if the shared-line read is cheap, the two merge and the non-reserving one goes". The read is not merely cheap -- it is cheaper than the read it replaces -- so the real question is **which claim protocol survives**: Vyukov's sequence, which reads a marching slot, or the head-based one, which reads a fixed line. The candidates, with what each costs: - - **Delete `mpsc`, keep `reserving_mpsc`.** Simplest surface, and the faster shape under contention. - Loses the 2x advantage `mpsc` holds at one producer with a live consumer, and lowers the maximum + - **Delete `slotwise_mpsc`, keep `reserving_mpsc`.** Simplest surface, and the faster shape under contention. + Loses the 2x advantage `slotwise_mpsc` holds at one producer with a live consumer, and lowers the maximum capacity from 2^63 to 2^31 for every caller. - **Keep both**, and correct their documentation, which currently states D-16's falsified premise as the reason the split exists. The split would then be justified by *profile* -- one shape for few producers, one for many -- which is a real distinction but a harder one to explain. - - **Change `mpsc`'s protocol** to decide freedom from `head`, closing the gap. This makes the two + - **Change `slotwise_mpsc`'s protocol** to decide freedom from `head`, closing the gap. This makes the two shapes genuinely "one queue with and without reservations", which is what D-16 assumed they already were, and is the only option that removes the surprise rather than documenting it. - Whichever is chosen, D-16's and `mpsc`'s own documentation must be corrected in the same change: they + Whichever is chosen, D-16's and `slotwise_mpsc`'s own documentation must be corrected in the same change: they currently assert a cost relationship the measurement reversed. That sweep is part of this item. **An input that was written off has come back, and this paragraph previously said the opposite.** Peer-index caching is available to the head-based protocol and structurally unavailable to Vyukov's. @@ -590,7 +590,7 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio - [ ] **M-inf.1** -- The linked and sharded MPSC shapes, if and only if M31.5 shows the array queue's tail CAS contends at realistic producer counts. **M31.5 has run, and the gate is now quantified rather than open.** The tail claim *does* contend, on - x64: aggregate throughput falls with every producer added, `mpsc` from 111M to 4.2M pushes/sec and + x64: aggregate throughput falls with every producer added, `slotwise_mpsc` from 111M to 4.2M pushes/sec and `reserving_mpsc` from 116M to 17.6M, against a bare contended atomic that falls only to a third. So the licence M31.5 offered to close this item outright -- "if the tail CAS does not contend, the array queue is the only MPSC this crate ever needs" -- was **not** granted. diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 7652e256..e8eac286 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -44,7 +44,7 @@ release-blocking rather than restating the decision itself. - [x] **SH-1.1** -- **MIRRORS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.8 -- one piece of work seen from two plans. Check both off in the same commit; neither is done alone.** - **Decide M31.8 (merge-or-delete for `mpsc` and `reserving_mpsc`) before the first + **Decide M31.8 (merge-or-delete for `slotwise_mpsc` and `reserving_mpsc`) before the first publish, not after.** This is the highest-leverage item in the file and it is release-blocking for a mechanical reason: the decision may *delete a public type*. Doing that before 0.1.0 costs nothing; doing it after means a breaking release, a yank-and-migrate for anyone who adopted it, and a @@ -65,6 +65,26 @@ release-blocking rather than restating the decision itself. it is a gap this workspace has *evidence* the existing tests cannot close. Publishing a lock-free queue with it open is a defensible choice; making it unknowingly is not. +- [x] **SH-1.3** -- **Qualify both MPSC shapes by name.** `mpsc` beside `reserving_mpsc` made one + canonical by implication -- which contradicts this crate's own "no shape is the canonical one", and + after SH-1.1 is simply false. Renamed to `slotwise_mpsc`, which names its claim protocol: it claims + slot by slot with no shared counter. `sequence_mpsc` was considered and rejected for inviting the + reading that it alone preserves FIFO order, which both shapes do. Recorded as D-30. + **Belongs in M1 for the same reason SH-1.1 does**: it is a public-surface change, free before the + first publish and a breaking rename with a deprecation path afterwards. + +- [x] **SH-1.4** -- **State the algorithms' pedigree and why an existing crate is not used.** A public + concurrent-queue crate has to answer both questions or a reader assumes the worst: that the + algorithms are homegrown, and that the author did not look at the alternatives. + Neither is true, and the honest answers are load-bearing. The algorithms are *published designs* + chosen deliberately, because a concurrent queue is a bad place to be original -- the failure mode is + a reordering that appears on one machine, under load, months later. And the reason no channel crate + fits is structural rather than dismissive: **on Windows, waiting is a kernel-object operation**, so a + queue whose readiness is not a `HANDLE` cannot join a `WaitForMultipleObjects` alongside an I/O + completion, a process handle, or a cancellation event -- however good its own blocking receive, and + however rich its own `select`, which can only select over its own channels. + Written into both the crate docs and the README, because docs.rs shows one and crates.io the other. + ## M2: repair the release plumbing before relying on it - [ ] **SH-2.1** -- **Add `windows-waitable-queues-v*` to the tag trigger list in diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 70d84f16..5fbd2497 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -35,24 +35,25 @@ preferred. | D-8 | **Published, and the obligation is accepted deliberately.** Unlike `windows-guard-alloc`, this is general-purpose and its first consumer is not its only plausible one. | | D-10 | **The multi-producer shape is Vyukov's bounded array queue: a sequence number per slot, claimed by a compare-and-swap on the tail and published by a release store.** The sequence is what lets the consumer tell a *claimed* slot from a *written* one, which a plain fetch-and-add cannot. Lock-free rather than wait-free, bounded by construction, and no allocation after the constructor. | | D-11 | **The capability traits shipped with this second shape, and the signatures `spsc` wrote down in advance held unchanged.** That is [D-3](#d-3)'s check actually being run rather than assumed. The load-bearing choice was `push(&self)`: `&mut self` would have been sound for one producer and would have made the trait unimplementable by this one. | -| D-12 | **A shape's *minimum* capacity belongs to the shape, not to the crate, and `mpsc`'s is two.** One slot cannot encode three states when the lap stride is the capacity, so "published at `p`" and "free again at `p + capacity`" collide. Reported through `CapacityError` rather than worked around, because every available workaround puts a load back on the producer's hot path for every queue in order to serve a capacity of one. | +| D-12 | **A shape's *minimum* capacity belongs to the shape, not to the crate, and `slotwise_mpsc`'s is two.** One slot cannot encode three states when the lap stride is the capacity, so "published at `p`" and "free again at `p + capacity`" collide. Reported through `CapacityError` rather than worked around, because every available workaround puts a load back on the producer's hot path for every queue in order to serve a capacity of one. | | D-13 | **The arming protocol is written once, in `blocking.rs`, and a shape binds to it by implementing a crate-private `Parked` trait.** The blocking receive loop *is* [D-9](#d-9), not glue around it; a second shape spelling it out again would be a second copy of a rule -- the exact mistake this crate has already paid for once. | -| D-14 | **`mpsc`'s arming asks "would `pop` find something", not "is `len` zero".** The two disagree over a slot a producer has claimed but not published, and only the first answer lets the consumer park on it instead of spinning until that producer is rescheduled. | +| D-14 | **`slotwise_mpsc`'s arming asks "would `pop` find something", not "is `len` zero".** The two disagree over a slot a producer has claimed but not published, and only the first answer lets the consumer park on it instead of spinning until that producer is rescheduled. | | D-15 | **`Doorbell::clear` resets the event *before* clearing the flag that mirrors it, and the original order was a lost wakeup.** A producer signalling between the two lines set the flag and issued a real `SetEvent`; the `ResetEvent` that followed erased the signal and left the flag set, wedging the doorbell dark while it claimed to be lit. **Amends [D-9](#d-9)**, whose "there is no third case" holds only for a queue whose emptiness is one position comparison. | -| D-16 | **Its cost premise is falsified by [D-26](#d-26); the conclusion stands on capability instead -- see [D-29](#d-29).** Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `mpsc` rather than replacing it. Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `mpsc`'s push deliberately never reads. The original rationale added that this made reserving the *more expensive* shape and that both should ship rather than charge every caller for it; measurement reversed that, and the split is now justified by the capability alone. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. | +| D-16 | **Its cost premise is falsified by [D-26](#d-26); the conclusion stands on capability instead -- see [D-29](#d-29).** Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `slotwise_mpsc` rather than replacing it. Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `slotwise_mpsc`'s push deliberately never reads. The original rationale added that this made reserving the *more expensive* shape and that both should ship rather than charge every caller for it; measurement reversed that, and the split is now justified by the capability alone. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. | | D-17 | **The reservation count and the claim position live in one word, because a check-and-claim over both must be a single atomic operation.** Two atomics cannot be made correct with any amount of fencing: the pushing producer is load-then-store and the reserving one store-then-load, so the Dekker argument does not apply and both can miss each other. The 32/32 split is forced by the arithmetic, and caps this shape at 2^31 items. | | D-18 | **A 128-bit compare-and-swap is refused.** It would lift the 2^31 cap and nothing else -- the consumer's position still has to be read -- at the cost of a dependency, a target-feature floor not in the x86-64 baseline, and a different instruction on the ARM64 machine this workspace measures on. Revisit only for a tagged pointer, which is what [M-inf.1](../../CHECKLIST-io-domains.md)'s linked and sharded shapes would need. | | D-19 | **The coalesced loss latch is deliberately not generalised from the file watcher.** Coalescing there is sound because a desync is *idempotent* -- two mean the same as one, and the answer to both is a re-scan. A queue of arbitrary `T` has no such property, so what generalises is a loss *count*, which is [M31.4](../../CHECKLIST-io-domains.md)'s observability rather than a policy. | | D-20 | **Undrained items are handed to a caller-supplied sink at teardown, and the sink is chosen at construction because `Drop` has nowhere to hand them back to.** Without one they are destroyed on whichever thread released the last handle -- which may be a pool callback that must not block, and closing a handle to a dead network path can block for a long time. The default is unchanged; what changes is that it is now a named choice. | | D-21 | **A panicking disposal sink is caught and the teardown walk continues.** The sink is caller code inside a destructor: a panic escaping it abandons every item behind it -- the exact handles the mechanism exists to account for -- and during an unwind aborts the process. Catching declines to turn a caller's bug into a much larger one. | | D-22 | **No `into_remaining`, because it would not close the hole and `drain` already covers what it would do.** A consumer can take everything available, but a producer may push afterwards, so an orderly drain covers only the orderly path. The last handle to drop is the only place that sees every survivor. | -| D-23 | **High-water tracking is opt-in at construction; refusals and doorbell rings are always on.** The difference is where each can be paid for: refusals sit on the failure path and rings on a path that already costs a syscall, but a peak has to observe *every* change -- and on `mpsc` that means the producer reading the consumer's position, the shared line [D-16](#d-16) built a separate shape to avoid. Untracked reports `None`, not `0`. | +| D-23 | **High-water tracking is opt-in at construction; refusals and doorbell rings are always on.** The difference is where each can be paid for: refusals sit on the failure path and rings on a path that already costs a syscall, but a peak has to observe *every* change -- and on `slotwise_mpsc` that means the producer reading the consumer's position, the shared line [D-16](#d-16) built a separate shape to avoid. Untracked reports `None`, not `0`. | | D-24 | **Counting the doorbell's rings turns the skip optimisation into part of the observable contract, and that is the point rather than a side effect.** R9 asks for the count precisely so "disabling the skip must change the number" -- so the sabotage entry for removing the skip changed from a control expecting `survives` to a defect expecting `caught`. An optimisation nobody can measure is an assumption. | | D-25 | **`Observable` deliberately does not restate depth.** [D-2](#d-2)'s sketch listed it, but `Bounded::len` already reports it from positions the queue keeps anyway. Naming it twice would give one number two spellings and two places to drift. What belongs on `Observable` is only what must be *accumulated*. | -| D-26 | **Measured: the tail claim contends badly, and `reserving_mpsc` is up to 4x FASTER than `mpsc` under contention -- the opposite of what [D-16](#d-16) assumed.** Aggregate throughput *falls* as producers are added, for both shapes and far more than a bare contended atomic explains. D-16's premise, that reading the consumer's position makes the reserving shape the expensive one, is falsified everywhere except a single producer with a live consumer. | -| D-27 | **The gap is intrinsic to Vyukov's sequence protocol, not a fixable flaw in `mpsc`'s retry loop.** Its producer must read a slot's sequence *before* claiming, and that slot marches through memory as the tail advances while other producers write it. Padding slots onto their own cache lines was tested and rejected: it recovers about a fifth at eight producers, for four times the memory, and leaves the shape still 2.8x slower. | +| D-26 | **Measured: the tail claim contends badly, and `reserving_mpsc` is up to 4x FASTER than `slotwise_mpsc` under contention -- the opposite of what [D-16](#d-16) assumed.** Aggregate throughput *falls* as producers are added, for both shapes and far more than a bare contended atomic explains. D-16's premise, that reading the consumer's position makes the reserving shape the expensive one, is falsified everywhere except a single producer with a live consumer. | +| D-27 | **The gap is intrinsic to Vyukov's sequence protocol, not a fixable flaw in `slotwise_mpsc`'s retry loop.** Its producer must read a slot's sequence *before* claiming, and that slot marches through memory as the tail advances while other producers write it. Padding slots onto their own cache lines was tested and rejected: it recovers about a fifth at eight producers, for four times the memory, and leaves the shape still 2.8x slower. | | D-28 | **Amended -- the blanket rejection is withdrawn; the verdict depends on thread placement, and the open question is queued as [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M-inf.4.** Caching the peer's index was measured, and it engaged as designed. It cost ~1.8x on x64 with the threads across cores, and *won* 17x on ARM64 and 1.8x on x64 SMT siblings. Batch depth decides the sign, and batch depth is set by where the two threads are scheduled -- not by the architecture and not by our code. A prefetch-only "warming" control changed nothing on any host. | | D-29 | **Both multi-producer shapes ship. The crate publishes what it measured and declines to choose for the caller.** [D-26](#d-26) falsified [D-16](#d-16)'s cost premise, which reopened merge-or-delete; the answer is neither. Vyukov's sequence protocol and the head-based one are independently researched designs, both in production use, and our own workload having settled which *we* want is not evidence about anyone else's. Deleting a shape because no visible consumer wants it is what PLATFORM INTEGRITY forbids. What the crate owes instead is the data and, through `probe-core-affinity`, the means to gather it on the caller's own hardware. | +| D-30 | **Both MPSC shapes are qualified by name; neither is `mpsc`.** A bare `mpsc` beside `reserving_mpsc` makes one canonical by implication, which contradicts this crate's own "no shape is the canonical one" and, after [D-29](#d-29), is simply false. `slotwise_mpsc` names its claim protocol -- it claims slot by slot, with no shared counter -- and avoids the reading `sequence_mpsc` invites, that it alone preserves FIFO order when both shapes do. Renamed before first publish, where it is free. | ## D-2: capabilities are sliced, not gathered @@ -187,7 +188,7 @@ signals a doorbell that is no longer about to be reset. **"There is no third case" is what this decision originally said next, and it was wrong** -- see [D-15](#d-15). It holds for `spsc`, where one producer and one position mean that *any* push before the -clear makes the check find something. It fails for `mpsc`, where the check asks whether the *head* slot +clear makes the check find something. It fails for `slotwise_mpsc`, where the check asks whether the *head* slot is published: a producer publishing at a later position before the clear is the third case, invisible to the check. The remedy is in `Doorbell::clear` rather than here, because what that case needs is not a better check but a doorbell that is guaranteed able to ring again once the clear returns. @@ -287,7 +288,7 @@ anything but a test binary. These queues carry no such trap, the first consumer one, and a Windows Rust program that wants to wait on a queue and a kernel object together currently has to write this itself. -## D-10: the MPSC shape is Vyukov's bounded array queue +## D-10: the slot-wise MPSC shape is Vyukov's bounded array queue The obvious multi-producer array queue claims an index with a fetch-and-add, writes the slot, and lets the consumer read it. It does not work, and the reason is worth stating because it is the whole justification @@ -331,7 +332,7 @@ promissory note came due. **The signatures held unchanged.** `push`, `pop`, `is_disconnected`, `capacity`, `len`, `is_empty` are what [`traits.rs`](src/traits.rs) says now and what that comment said then. The check is not rhetorical: -`mpsc` is a lock-free array queue with a per-slot state machine and no structural resemblance to a +`slotwise_mpsc` is a lock-free array queue with a per-slot state machine and no structural resemblance to a two-position ring, so a signature fitted to the first shape would have failed here rather than in a consumer's code. @@ -351,9 +352,9 @@ Two smaller decisions recorded so they are not re-litigated: They belong to work that has not happened (M31.2, M31.4), and shipping an empty trait now would be the design-in-a-vacuum D-3 forbids, one level up. -## D-12: the minimum capacity belongs to the shape, and mpsc's is two +## D-12: the minimum capacity belongs to the shape, and `slotwise_mpsc`'s is two -`spsc` accepts a capacity of one. `mpsc` cannot, and the reason is arithmetic rather than taste. Its slot +`spsc` accepts a capacity of one. `slotwise_mpsc` cannot, and the reason is arithmetic rather than taste. Its slot sequence distinguishes three states by counting -- `pos` is free, `pos + 1` is published, `pos + capacity` is free again on the next lap -- and when `capacity == 1` the second and third are the *same number*. A producer would read the sequence of the item it had just pushed, conclude the slot was free, and overwrite @@ -368,7 +369,7 @@ exactly. The consequence for the error type is small and was anticipated: `CapacityError` already carried a `max_valid` on the argument that a bound "follows from how a shape represents its positions", and it now carries a `min_valid` for the same reason. The suggestion methods respect it, so `bounded::(1)` on an -`mpsc` reports `next_valid() == Some(2)` rather than a correction that would itself be refused. +`slotwise_mpsc` reports `next_valid() == Some(2)` rather than a correction that would itself be refused. Each shape names its own minimum as a documented constant next to the code that needs it, rather than passing a bare literal, so the number is never separated from the reason for it. @@ -390,9 +391,9 @@ being reversed. The `ARM_RACE` hook is shared for the same reason. ask of a queue; `Parked` says what the blocking loop needs from one, and the difference shows in `finish`, whose contract is a precondition no external caller can check. -## D-14: mpsc arms on readiness, not on emptiness +## D-14: ``slotwise_mpsc`` arms on readiness, not on emptiness -`Consumer::arm` must answer "is it safe to park?", and for `mpsc` that is not the same question as "is the +`Consumer::arm` must answer "is it safe to park?", and for `slotwise_mpsc` that is not the same question as "is the queue empty". They disagree over a slot a producer has claimed but not yet published, and the disagreement matters in both directions: @@ -429,7 +430,7 @@ wait. It is sound -- for a queue whose re-check is guaranteed to see anything an `spsc` is such a queue: one producer, one tail, and `is_empty` covers every push. So the argument was tested against the only shape that could not falsify it, and it was written down as a general rule. -`mpsc` falsifies it. Its re-check asks whether the **head** slot is published ([D-14](#d-14)), so a +`slotwise_mpsc` falsifies it. Its re-check asks whether the **head** slot is published ([D-14](#d-14)), so a producer publishing at a later position is invisible to it. The consumer parks in exactly the wedged state, the producer holding the head publishes, its `signal` is skipped, and the queue hangs with an item sitting in it. @@ -438,7 +439,7 @@ sitting in it. suite passed 120 tests in 0.28 s, six runs in a row. It was found because the sabotage harness refuses to sweep against a red baseline, and its *baseline* run -- the one that exists only to prove the suite is green before any defect is injected -- hung once in -`mpsc::tests::many_producers_deliver_every_item_exactly_once`. A single unreproducible hang is exactly +`slotwise_mpsc::tests::many_producers_deliver_every_item_exactly_once`. A single unreproducible hang is exactly the finding it is tempting to dismiss as a slow machine, and the crate's own sabotage documentation already says not to: "a flaky sabotage is a finding, not noise". The same applies to a flaky baseline. @@ -458,7 +459,7 @@ depends on next: `signal` must still be able to ring. Reversed, the test fails e entry keeps it that way. A control with an empty window sits beside it, so the test cannot pass by `clear` simply never leaving the doorbell ringable. -**Two temptations refused.** Making `mpsc` arm on `len` instead of readiness would also have masked this, +**Two temptations refused.** Making `slotwise_mpsc` arm on `len` instead of readiness would also have masked this, by restoring the property that any push makes the re-check find something -- but it would have left the doorbell able to reach the inconsistent state, waiting for the next shape, and it would have cost the consumer a spin whenever a claim was in flight. Adding a lock around the two lines would have fixed it @@ -474,7 +475,7 @@ account of *why the two shapes differ*, which remains correct, and not as an acc cheaper, which [D-26](#d-26) reversed. [D-29](#d-29) records what the split rests on now. **Honouring a reservation costs the producer something on every push, including the pushes that never -reserve anything.** `mpsc`'s producer never reads the consumer's position: it asks the slot's own +reserve anything.** `slotwise_mpsc`'s producer never reads the consumer's position: it asks the slot's own sequence number "are you free?", and those are spread across the slot array, so producers working at different positions touch different cache lines. Avoiding a single shared position is not incidental to Vyukov's design; it is most of the point of it. @@ -483,15 +484,15 @@ A reservation cannot be answered from that question. "Is this slot free" does no remain, and withholding one from the best-effort path requires exactly that count -- which requires the consumer's position, on one line every thread in the system touches. -So the choice was: pay that on every `mpsc` push, or ship two shapes. Two shapes, for three reasons: +So the choice was: pay that on every `slotwise_mpsc` push, or ship two shapes. Two shapes, for three reasons: - **The cost falls on the shape [M31.5](../../CHECKLIST-io-domains.md) exists to measure.** Degrading - `mpsc`'s push before the contention benchmark runs would corrupt the measurement that decides whether + `slotwise_mpsc`'s push before the contention benchmark runs would corrupt the measurement that decides whether the deferred shapes are needed at all. - **The crate is built for this.** It is named in the plural, [D-7](#d-7) makes shapes plain modules, and [D-4](#d-4) already has shapes differing in what they can do. A third one is the pattern working, not an exception to it. -- **It is [D-2](#d-2)'s argument reaching its sharpest case.** `mpsc` does not implement `Reserving` +- **It is [D-2](#d-2)'s argument reaching its sharpest case.** `slotwise_mpsc` does not implement `Reserving` because it genuinely *cannot*, not because nobody got round to it -- which is exactly the situation narrow traits were chosen for. A fat trait would have forced the cost on both shapes or excluded reservation from the contract entirely. @@ -503,7 +504,7 @@ packed word puts one shared *load*. Its only advantage is preserving the crate-w [D-17](#d-17) explains why that ceiling is unreachable anyway. **The merge-or-delete decision is deferred to M31.5, deliberately and with a trigger.** If the benchmark -shows the shared-line read costs little at realistic contention, `mpsc` and `reserving_mpsc` should merge +shows the shared-line read costs little at realistic contention, `slotwise_mpsc` and `reserving_mpsc` should merge and the plain one should go. If it shows the read is expensive, both stay. What must not happen is the duplicated path becoming permanent because nobody circled back, so the decision is recorded as an item on M31.5 rather than as an intention here. @@ -537,7 +538,7 @@ Three consequences fall out, and all three are improvements: - **A racing `reserve` and `push` cannot both win.** The loser's exchange fails and it re-reads, which is the ordinary lock-free retry rather than a special case. - **The producer stops needing the slot sequence for the "free" direction**, because it now reads the - consumer's position anyway. So `reserving_mpsc`'s `pop` is one store shorter than `mpsc`'s: nothing + consumer's position anyway. So `reserving_mpsc`'s `pop` is one store shorter than `slotwise_mpsc`'s: nothing writes a "free again" sequence. **The 32/32 split is forced, not chosen.** A position of `b` bits keeps a wrapping difference unambiguous @@ -706,14 +707,14 @@ Peak depth is the awkward one, and the awkwardness is not uniform: depth is a subtraction of two values in hand, and the counter's line is producer-owned. - **`reserving_mpsc`** -- near-free, for the same reason: its producer reads `head` for the room check that honours reservations. Only the counter's line is shared, and it is written rarely. -- **`mpsc`** -- *not* free. Its producer never reads `head`; that is the whole property +- **`slotwise_mpsc`** -- *not* free. Its producer never reads `head`; that is the whole property [D-16](#d-16) built a separate shape to preserve, because `head` is the one line every thread touches. Tracking makes it read that line on every push. -Making it always-on would have imposed D-16's refused cost on every `mpsc` user to serve a metric most of +Making it always-on would have imposed D-16's refused cost on every `slotwise_mpsc` user to serve a metric most of them will never read -- and would have done it just before [M31.5](../../CHECKLIST-io-domains.md) measures -exactly that path. Omitting it from `mpsc` would have narrowed the shape. So it is a switch, off by -default, and the cost lands only on queues that asked. Off, `mpsc` pays one predictable branch on a field +exactly that path. Omitting it from `slotwise_mpsc` would have narrowed the shape. So it is a switch, off by +default, and the cost lands only on queues that asked. Off, `slotwise_mpsc` pays one predictable branch on a field written once at construction: the line is shared but read-only, which is the cheap kind. **Untracked reports `None`, not `0`.** They are different answers -- "nobody was counting" versus "it @@ -749,7 +750,7 @@ of what the queue promises rather than a private cleverness -- so removing it la change, not a refactor. That is the right trade for a queue whose entire reason to exist is a wakeup protocol, but it is a trade, and it should be made knowingly. -The control it vacated is replaced rather than dropped: `mpsc`'s guard around the `head` load is an +The control it vacated is replaced rather than dropped: `slotwise_mpsc`'s guard around the `head` load is an optimisation and not a correctness device, so removing *that* must still leave the suite green. A sweep with no controls left is a sweep that has stopped asking whether its tests describe the contract. @@ -778,7 +779,7 @@ gap rather than extending the ARM64 record, and the two are not interchangeable. Isolated regime -- producers only, capacity large enough that nothing is refused, so the curve is the claim and nothing else: -| producers | `mpsc` ns/push | `reserving_mpsc` ns/push | contended `fetch_add` | +| producers | `slotwise_mpsc` ns/push | `reserving_mpsc` ns/push | contended `fetch_add` | |---|---|---|---| | 1 | 9.0 | 8.6 | 5.0 | | 2 | 49.0 | 28.0 | 8.1 | @@ -789,12 +790,12 @@ claim and nothing else: **Two findings, and the second one was not the expected result.** -**The tail claim contends, and severely.** Aggregate throughput *falls* as producers are added: `mpsc` +**The tail claim contends, and severely.** Aggregate throughput *falls* as producers are added: `slotwise_mpsc` from 111M to 4.2M pushes per second, `reserving_mpsc` from 116M to 17.6M. A bare contended `fetch_add` falls only to a third and then plateaus, so most of both curves is the queue rather than what this processor does to a fought-over line. -**`reserving_mpsc` is up to 4x faster than `mpsc` under contention**, which inverts [D-16](#d-16). That +**`reserving_mpsc` is up to 4x faster than `slotwise_mpsc` under contention**, which inverts [D-16](#d-16). That decision shipped the two as peers on the reasoning that honouring a reservation costs the producer a read of the consumer's position, making the reserving shape the expensive one. It is the cheaper one at every producer count from two upward. The premise survives in exactly one place: a *single* producer against a @@ -807,12 +808,12 @@ single consumer rather than the claim. ## D-27: why, and why it is not a bug to fix -The obvious response to D-26 is that `mpsc` must have a defect. It does not, and the difference is worth +The obvious response to D-26 is that `slotwise_mpsc` must have a defect. It does not, and the difference is worth understanding because it is a property of the two *protocols* rather than of two implementations of one. Both do one compare-and-swap plus one load per attempt. The load is what differs: -- **`mpsc` reads `slots[tail & mask].sequence`** -- and must, because in Vyukov's protocol the slot's own +- **`slotwise_mpsc` reads `slots[tail & mask].sequence`** -- and must, because in Vyukov's protocol the slot's own sequence is what says the slot is free. That address **marches through memory as the tail advances**, and the slots it walks are being written by the very producers it is racing. - **`reserving_mpsc` reads `head`** -- one fixed address, which stays hot in every core's cache and, in @@ -823,7 +824,7 @@ came out backwards from the prediction. **The false-sharing hypothesis was tested and rejected.** `Slot` is sixteen bytes, so four consecutive positions share a cache line, and the obvious fix is to pad each slot onto its own. Measured: -at eight producers that moves `mpsc` from 140.8 to 109.1 ns -- about a fifth -- for four times the +at eight producers that moves `slotwise_mpsc` from 140.8 to 109.1 ns -- about a fifth -- for four times the memory, and leaves it 2.8x slower than `reserving_mpsc`'s 38.5. False sharing between neighbouring slots is a contributor, not the cause. The padding was reverted; the note on `Slot` that says slots deliberately share lines is therefore correct, and now correct for a measured reason rather than an assumed one. @@ -1024,7 +1025,7 @@ what it claimed, should the shapes merge, or should one be deleted? **Neither. Both ship, and the crate declines to choose between them on the caller's behalf.** -The two are not one queue with a feature flag. `mpsc` implements Vyukov's bounded array protocol, where +The two are not one queue with a feature flag. `slotwise_mpsc` implements Vyukov's bounded array protocol, where a producer asks a slot's own sequence number whether it is free; `reserving_mpsc` counts free slots against the consumer's position, which is the only way a reservation can be answered at all. Both are independently studied designs with production track records, chosen by different systems for different @@ -1046,15 +1047,15 @@ What the crate owes a caller instead is honesty and equipment: Two justifications are available and both are refused, because a rationale that evaporates on inspection is worse than none: -- **Not capacity.** `mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, and that difference is +- **Not capacity.** `slotwise_mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, and that difference is unreachable: it counts slots allocated at construction, not items ever pushed, and 2^31 slots is tens of gigabytes before the ring holds anything useful. See [D-17](#d-17) for why the packing forces it. -- **Not `mpsc` being faster somewhere.** Its one measured advantage is a single producer with a live +- **Not `slotwise_mpsc` being faster somewhere.** Its one measured advantage is a single producer with a live consumer -- and at one producer the right shape is [`spsc`](#d-1), which is faster still and which this crate also ships. A shape kept for a regime already better served elsewhere is kept on sentiment. -The split rests on **capability**: `reserving_mpsc` implements `Reserving` and `mpsc` cannot, for the +The split rests on **capability**: `reserving_mpsc` implements `Reserving` and `slotwise_mpsc` cannot, for the structural reason D-16's surviving half explains. Everything else is profile, and profile is the caller's to measure. diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index af680e64..242bceb7 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -6,7 +6,7 @@ Bounded producer/consumer queues whose readiness is a waitable Windows `HANDLE`. an empty shell on other platforms. **Status: three shapes, all waitable.** `spsc` is a bounded ring with no -compare-and-swap on either side; `mpsc` is a bounded array queue using Vyukov's +compare-and-swap on either side; `slotwise_mpsc` is a bounded array queue using Vyukov's sequence protocol, so any number of producers may push without a lock; and `reserving_mpsc` is that queue plus the ability to claim a slot in advance. Any of them can be polled with no kernel object at all, blocked on directly, or @@ -54,7 +54,7 @@ cardinality is carried by whether those handles are `Clone`: | Shape | Producer | Consumer | Reserves | Shipped | |---|---|---|---|---| | `spsc` | not `Clone` | not `Clone` | yes | yes | -| `mpsc` | `Clone` | not `Clone` | **no** | yes | +| `slotwise_mpsc` | `Clone` | not `Clone` | **no** | yes | | `reserving_mpsc` | `Clone` | not `Clone` | yes | yes | | MPMC | `Clone` | `Clone` | -- | not yet | @@ -63,13 +63,69 @@ comment: the handles are also not `Sync`, so a handle that cannot be cloned and cannot be shared is held by exactly one thread. The two shapes also disagree about their smallest usable capacity, and the error -says so rather than the documentation: `spsc` accepts one slot, and `mpsc` needs +says so rather than the documentation: `spsc` accepts one slot, and `slotwise_mpsc` needs two, because its per-slot sequence cannot distinguish "just published" from "free again next lap" in a one-slot ring. -## Choosing between `mpsc` and `reserving_mpsc` - -They are **two different claim protocols**, not one queue with a switch. `mpsc` +## Where these algorithms come from + +**None of the queue algorithms here are novel, and that is deliberate.** A +concurrent queue is a bad place to be original: the failure mode is a reordering +that shows up on one machine, under load, months later. Each shape implements a +published design, and what this crate adds is the waiting, not the queueing. + +- **`spsc`** is the classic single-producer single-consumer ring buffer, with the + two positions on separate cache lines so the ends stop invalidating each + other. The structure is old -- Lamport gave the concurrent reader/writer + treatment in 1983 -- and the padding is standard modern practice. +- **`slotwise_mpsc`** implements Dmitry Vyukov's bounded MPMC array queue, + specialised to one consumer. Each slot carries its own sequence number, so a + producer claims a position and asks *that slot* whether it is ready, which + keeps producers off any single shared line. It is among the most widely + reimplemented concurrent queues in existence. +- **`reserving_mpsc`** uses the other classic approach: count free slots against + the consumer's position, so space can be **claimed in advance**. Credit- and + ticket-based admission is long established in flow control, and counting is + the only way to answer "will there be room later?". + +Where this crate departs from a reference implementation it says so, and why, in +[DESIGN-NOTES.md](DESIGN-NOTES.md). The measured behaviour of both MPSC shapes is +below -- including one case where the published intuition turned out to be wrong +on our hardware. + +## Why not an existing queue crate + +Rust has excellent channel crates, and for most programs one of them is the right +answer. **They are not usable here for one structural reason: on Windows, +waiting is a kernel-object operation, and a queue whose readiness is not a +`HANDLE` cannot take part in one.** + +A thread that must wait for *an item arrived* **or** *an I/O completed* **or** +*this process exited* **or** *cancellation was requested* waits on all of them at +once, in a single `WaitForMultipleObjects`. Every participant has to be a kernel +object. A channel that signals readiness through a condition variable, a futex, +or a parked-thread list cannot be one of them -- however good its blocking +receive is, and however rich its `select`, because that select can only cover its +own channels. + +The alternatives are all worse in the same way: + +- **Poll on a timer.** Trades latency against wakeups, and the thread wakes to + discover nothing happened. +- **Dedicate a thread to blocking on the channel and signalling an event.** + Correct, and costs a thread plus a hop per item to convert a condition variable + back into the kernel object you needed in the first place. +- **Move everything to async.** A real answer if the program is already async; + not one for a thread whose other obligations are `HANDLE`s. + +So the queue owns a manual-reset event and keeps it consistent with the queue's +state. That consistency is the hard part and is what this crate is actually for. +The event is created lazily, so a consumer that only polls never allocates a +kernel object at all. + +## Choosing between `slotwise_mpsc` and `reserving_mpsc` + +They are **two different claim protocols**, not one queue with a switch. `slotwise_mpsc` is Vyukov's bounded array queue: a producer asks a slot's own sequence number whether it is free. `reserving_mpsc` counts free slots against the consumer's position, which is the only way a reservation can be answered at all. Both are @@ -78,7 +134,7 @@ both rather than picking one for you. **Start here:** -- Need `reserve`? Only `reserving_mpsc` has it, and `mpsc` structurally cannot. +- Need `reserve`? Only `reserving_mpsc` has it, and `slotwise_mpsc` structurally cannot. - Otherwise, **start with `reserving_mpsc`.** It was the faster of the two at every producer count we measured above one. - Only one producer *and* one consumer? Use `spsc`, which beats both. @@ -86,7 +142,7 @@ both rather than picking one for you. **What we measured**, in ns per push, isolated regime, median of three runs. Higher producer counts oversubscribe both hosts: -| producers | `mpsc` (x64) | `reserving` (x64) | `mpsc` (ARM64) | `reserving` (ARM64) | +| producers | `slotwise_mpsc` (x64) | `reserving` (x64) | `slotwise_mpsc` (ARM64) | `reserving` (ARM64) | |---|---|---|---|---| | 1 | 9.0 | 8.6 | 6.5 | 6.1 | | 2 | 49.0 | 28.0 | 29.8 | 9.4 | @@ -98,7 +154,7 @@ Higher producer counts oversubscribe both hosts: x64 is an AMD EPYC 7763 slice (8 cores, 16 threads); ARM64 is a Snapdragon X2 Elite (12 cores, no SMT). **Read these as two data points, not as a law.** This comparison has already inverted once: it was designed on the assumption that -`mpsc` would be the cheaper shape, and measurement said otherwise on both +`slotwise_mpsc` would be the cheaper shape, and measurement said otherwise on both machines. **Measure your own workload before treating any of this as settled.** Producer @@ -109,10 +165,10 @@ can run that measurement on your hardware instead of inheriting ours. Two things that look like reasons to choose and are not: -- **Capacity.** `mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, but that +- **Capacity.** `slotwise_mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, but that counts slots allocated up front, not items ever pushed. A ring of 2^31 slots is tens of gigabytes before it holds anything useful. -- **`mpsc` winning at one producer.** True in one regime, and at one producer +- **`slotwise_mpsc` winning at one producer.** True in one regime, and at one producer you want `spsc` anyway. ## What it will not do @@ -121,12 +177,12 @@ Two things that look like reasons to choose and are not: slot. Overwrite-oldest is right for telemetry, where a lost entry is a lost sample; here an entry may be an I/O submission, where a lost entry is a lost operation. -- **It will not decide between two real queue designs on your behalf.** `mpsc` +- **It will not decide between two real queue designs on your behalf.** `slotwise_mpsc` and `reserving_mpsc` are different claim protocols, both well studied and both - used in production and in research. `mpsc` asks each slot's own sequence + used in production and in research. `slotwise_mpsc` asks each slot's own sequence number "are you free?"; `reserving_mpsc` counts free slots against the consumer's position, which is what makes a reservation answerable at all -- - and why `mpsc` does not implement the `Reserving` trait. It genuinely cannot, + and why `slotwise_mpsc` does not implement the `Reserving` trait. It genuinely cannot, which is the whole reason the traits are narrow. **Which is faster is a property of your workload, not of the designs**, and we publish what we measured rather than choosing for you -- see "Choosing between diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json index 5597b96c..7d4f612e 100644 --- a/crates/windows-waitable-queues/sabotage.json +++ b/crates/windows-waitable-queues/sabotage.json @@ -1,6 +1,6 @@ { "package": "windows-waitable-queues", - "description": "Sabotages for the SPSC ring, the MPSC array queue, the doorbell they share, and the blocking receive loop they share. Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format and for why the results are read the way they are.", + "description": "Sabotages for the SPSC ring, the two MPSC shapes, the doorbell they share, and the blocking receive loop they share. Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format and for why the results are read the way they are.", "notCoveredHere": "The two SeqCst fences in doorbell.rs are deliberately ABSENT from this manifest, and their absence is not an oversight. Removing either leaves every test green, because the defect they prevent is a store-buffer reordering that no amount of stress testing reliably produces -- it is a fact about the memory model, not about any interleaving a scheduler will hand you. Adding them here with expect:'caught' would fail the sweep; adding them with expect:'survives' would assert they are harmless, which is false and far worse. They are verifiable only under a model checker, and are the named target of checklist item M31.6.", "sabotages": [ { @@ -193,7 +193,7 @@ "name": "clear clears the mirror flag before resetting the event", "file": "src/doorbell.rs", "expect": "caught", - "why": "The historical order, and a lost wakeup. A producer signalling between the two lines finds a clear flag, sets it, and issues a real SetEvent; the ResetEvent that follows erases that signal and leaves the flag set, so the doorbell is dark while claiming to be lit and every later signal skips its syscall. It survived review and a whole sabotage sweep because the argument for it -- 'the caller's re-check sees the racing producer's item' -- is true for spsc and false for mpsc, whose re-check asks only whether the HEAD slot is published. Found by a sabotage BASELINE hanging once in a run that was otherwise green six times over. The race_hooks::CLEAR hook drives the real clear through its own window on one thread, so this is caught every run rather than occasionally.", + "why": "The historical order, and a lost wakeup. A producer signalling between the two lines finds a clear flag, sets it, and issues a real SetEvent; the ResetEvent that follows erases that signal and leaves the flag set, so the doorbell is dark while claiming to be lit and every later signal skips its syscall. It survived review and a whole sabotage sweep because the argument for it -- 'the caller's re-check sees the racing producer's item' -- is true for spsc and false for slotwise_mpsc, whose re-check asks only whether the HEAD slot is published. Found by a sabotage BASELINE hanging once in a run that was otherwise green six times over. The race_hooks::CLEAR hook drives the real clear through its own window on one thread, so this is caught every run rather than occasionally.", "find": [ " unsafe {", " ResetEvent(event.as_raw_handle());", @@ -212,8 +212,8 @@ ] }, { - "name": "mpsc push does not signal the doorbell", - "file": "src/mpsc.rs", + "name": "slotwise_mpsc push does not signal the doorbell", + "file": "src/slotwise_mpsc.rs", "expect": "caught", "why": "A producer that never rings the bell leaves a parked consumer asleep on a queue with items in it. Caught as a hang, which is the correct shape for this defect.", "find": [ @@ -225,8 +225,8 @@ ] }, { - "name": "mpsc: the last producer's drop does not signal", - "file": "src/mpsc.rs", + "name": "slotwise_mpsc: the last producer's drop does not signal", + "file": "src/slotwise_mpsc.rs", "expect": "caught", "why": "Disconnection is a wakeup, and the only one no other party can deliver. Without it a blocked consumer waits forever for an item that can no longer be sent. NOTE the shape of this patch -- it deletes the live call rather than inserting unreachable code beside it, because a sabotage that does not sabotage retires a question that was never asked.", "find": [ @@ -244,8 +244,8 @@ ] }, { - "name": "CONTROL: mpsc signals on every producer's departure, not only the last", - "file": "src/mpsc.rs", + "name": "CONTROL: slotwise_mpsc signals on every producer's departure, not only the last", + "file": "src/slotwise_mpsc.rs", "expect": "survives", "why": "A control, not a defect. Ringing when a non-final producer leaves is a SPURIOUS wakeup: the consumer wakes, finds nothing, sees producers still alive, and parks again. The contract says a wakeup may be spurious, so the suite MUST stay green. If this is ever reported as caught, a test has started asserting that no extra wakeups occur -- which is asserting the implementation -- and that test is the thing to fix. Note this is NOT the same as the entry above: that one deletes the last producer's signal, which is a lost wakeup and a hang.", "find": [ @@ -258,8 +258,8 @@ ] }, { - "name": "mpsc arm checks readiness before clearing", - "file": "src/mpsc.rs", + "name": "slotwise_mpsc arm checks readiness before clearing", + "file": "src/slotwise_mpsc.rs", "expect": "caught", "why": "The lost wakeup itself, in the second shape. A push landing between the check and the clear both signals and has its signal erased, so the consumer sleeps on a queue that is not empty and will never be signalled again. Driven deterministically through the REAL arm by the shared ARM_RACE hook rather than through a copy of it, which is what makes this caught every run rather than one in three.", "find": [ @@ -280,8 +280,8 @@ ] }, { - "name": "mpsc arm does not create the doorbell before checking", - "file": "src/mpsc.rs", + "name": "slotwise_mpsc arm does not create the doorbell before checking", + "file": "src/slotwise_mpsc.rs", "expect": "caught", "why": "Lazy creation is the same hazard a third time: a producer running while no event exists skips signalling, so the readiness check has to come after the event exists to catch what that skip left behind.", "find": [ @@ -293,8 +293,8 @@ ] }, { - "name": "mpsc frees a slot one short of the next lap", - "file": "src/mpsc.rs", + "name": "slotwise_mpsc frees a slot one short of the next lap", + "file": "src/slotwise_mpsc.rs", "expect": "caught", "why": "The sequence protocol's whole arithmetic in one line. A slot freed at `pos + capacity - 1` is never equal to the position that next claims it, so every producer reads a negative difference and reports Full for ever: the queue works for exactly one lap and then wedges. An off-by-one here is invisible to any test that never wraps, which is why the wrap tests run a thousand rounds through four slots.", "find": [ @@ -305,8 +305,8 @@ ] }, { - "name": "mpsc cloning a producer does not count it", - "file": "src/mpsc.rs", + "name": "slotwise_mpsc cloning a producer does not count it", + "file": "src/slotwise_mpsc.rs", "expect": "caught", "why": "The count is what makes multi-producer disconnection work, and a clone that does not register makes the FIRST departure look like the last. The consumer then ends the stream while producers are still pushing into it.", "find": [ @@ -317,8 +317,8 @@ ] }, { - "name": "mpsc accepts a capacity of one", - "file": "src/mpsc.rs", + "name": "slotwise_mpsc accepts a capacity of one", + "file": "src/slotwise_mpsc.rs", "expect": "caught", "why": "With one slot, 'published at position p' and 'free again at position p + capacity' are the SAME number, so a producer reads the sequence of the item it just pushed, concludes the slot is free, and overwrites an item the consumer has not read. spsc accepts one, which is exactly why the minimum belongs to the shape rather than to the crate -- and why it is asserted rather than assumed.", "find": [ @@ -433,7 +433,7 @@ "name": "high-water is tracked even when nobody asked", "file": "src/metrics.rs", "expect": "caught", - "why": "Tracking must be genuinely off by default, because it is the one metric that costs the push path something -- on mpsc it makes the producer read the consumer's position, which is the shared line that shape exists to avoid. If the default silently tracked, every mpsc user would be paying for an answer they never asked for, and the only visible symptom would be a number appearing where None belongs.", + "why": "Tracking must be genuinely off by default, because it is the one metric that costs the push path something -- on slotwise_mpsc it makes the producer read the consumer's position, which is the shared line that shape exists to avoid. If the default silently tracked, every slotwise_mpsc user would be paying for an answer they never asked for, and the only visible symptom would be a number appearing where None belongs.", "find": [ " high_water: if track_high_water {" ], @@ -442,10 +442,10 @@ ] }, { - "name": "CONTROL: mpsc reads head unconditionally, skipping the tracking guard", - "file": "src/mpsc.rs", + "name": "CONTROL: slotwise_mpsc reads head unconditionally, skipping the tracking guard", + "file": "src/slotwise_mpsc.rs", "expect": "survives", - "why": "A control, and the replacement for the one M31.4 converted into a defect. The guard around mpsc's `head` load is an OPTIMISATION, not a correctness device -- `record_depth` already returns early when tracking is off, so reading head regardless changes no observable behaviour and the suite MUST stay green. What it changes is the cost, which is the whole reason the guard is there. If this is ever reported as caught, a test has started asserting the implementation rather than the contract.", + "why": "A control, and the replacement for the one M31.4 converted into a defect. The guard around slotwise_mpsc's `head` load is an OPTIMISATION, not a correctness device -- `record_depth` already returns early when tracking is off, so reading head regardless changes no observable behaviour and the suite MUST stay green. What it changes is the cost, which is the whole reason the guard is there. If this is ever reported as caught, a test has started asserting the implementation rather than the contract.", "find": [ " if self.shared.metrics.tracks_high_water() {" ], @@ -491,8 +491,8 @@ ] }, { - "name": "mpsc teardown destroys survivors instead of handing them over", - "file": "src/mpsc.rs", + "name": "slotwise_mpsc teardown destroys survivors instead of handing them over", + "file": "src/slotwise_mpsc.rs", "expect": "caught", "why": "As for spsc, and this walk differs: it consults each slot's sequence rather than assuming the whole resident range is published.", "find": [ @@ -520,7 +520,7 @@ "name": "spsc: a best-effort push may take a reserved slot", "file": "src/spsc.rs", "expect": "caught", - "why": "The same guarantee on the single-producer shape, where the mechanism is a plain counter rather than a packed word. Worth its own entry precisely because the two implementations share nothing: a test that only covered the mpsc path would leave this one unguarded.", + "why": "The same guarantee on the single-producer shape, where the mechanism is a plain counter rather than a packed word. Worth its own entry precisely because the two implementations share nothing: a test that only covered the slotwise_mpsc path would leave this one unguarded.", "find": [ " if tail.wrapping_sub(head) + reserved >= self.shared.capacity {", " // Report disconnection in preference to fullness" @@ -546,8 +546,8 @@ ] }, { - "name": "mpsc reports Full for a full queue whose consumer is gone", - "file": "src/mpsc.rs", + "name": "slotwise_mpsc reports Full for a full queue whose consumer is gone", + "file": "src/slotwise_mpsc.rs", "expect": "caught", "why": "Full invites a retry and Disconnected does not, and a full queue with no consumer will never drain -- so reporting Full here is telling the caller to spin forever. The preference has to be stated at the fullness branch specifically, because that branch returns before the general disconnection check below it is ever reached.", "find": [ diff --git a/crates/windows-waitable-queues/src/capacity.rs b/crates/windows-waitable-queues/src/capacity.rs index 0efe6f88..533b69e0 100644 --- a/crates/windows-waitable-queues/src/capacity.rs +++ b/crates/windows-waitable-queues/src/capacity.rs @@ -15,7 +15,7 @@ //! constants, because both follow from how a shape represents its positions -- //! and the shipped shapes disagree about both. //! -//! - **The minimum.** `spsc` accepts a capacity of one; `mpsc` cannot, because +//! - **The minimum.** `spsc` accepts a capacity of one; `slotwise_mpsc` cannot, because //! its slot state machine encodes "published" as one past the claim position //! and "free again" as one lap past it, and with a single slot those are the //! same number. @@ -38,7 +38,7 @@ use crate::error::CapacityError; /// - `spsc` computes the number of items held as `tail.wrapping_sub(head)`, /// which is the true difference only while that difference cannot exceed half /// the range. -/// - `mpsc` compares a slot's sequence number against a position by +/// - `slotwise_mpsc` compares a slot's sequence number against a position by /// interpreting `sequence.wrapping_sub(position)` as an [`isize`], which is /// the same requirement written a different way. /// diff --git a/crates/windows-waitable-queues/src/doorbell.rs b/crates/windows-waitable-queues/src/doorbell.rs index 2c5fcf52..b5b9e321 100644 --- a/crates/windows-waitable-queues/src/doorbell.rs +++ b/crates/windows-waitable-queues/src/doorbell.rs @@ -145,7 +145,7 @@ //! re-check sees the item and the caller does not wait. **That argument is //! sound only when the re-check is guaranteed to see anything that producer //! published**, and it silently assumed a queue whose emptiness is a single -//! position comparison. `mpsc` broke the assumption -- its re-check asks whether +//! position comparison. `slotwise_mpsc` broke the assumption -- its re-check asks whether //! the *head* slot is published, so a producer publishing at a later position //! is invisible to it, and the consumer parks in exactly the wedged state above. //! The failure was a rare permanent hang, reproduced once in a sabotage diff --git a/crates/windows-waitable-queues/src/error.rs b/crates/windows-waitable-queues/src/error.rs index 07896181..c663a2a0 100644 --- a/crates/windows-waitable-queues/src/error.rs +++ b/crates/windows-waitable-queues/src/error.rs @@ -23,7 +23,7 @@ pub struct CapacityError { /// The smallest capacity the rejecting shape accepts. /// /// Carried for the same reason as [`Self::max_valid`], and it is not always - /// one: `mpsc` cannot represent a capacity below two, because its slot + /// one: `slotwise_mpsc` cannot represent a capacity below two, because its slot /// state machine reuses a sequence number one lap later and a one-slot ring /// would make "published" and "free again" the same value. min_valid: usize, diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 6fc0223e..2d39c0a2 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -46,16 +46,75 @@ //! [`Reserving`] -- each naming one thing a queue can do, so a caller can be //! generic over exactly what it needs and nothing more. //! -//! # Choosing between `mpsc` and `reserving_mpsc` +//! # Where these algorithms come from +//! +//! **None of the queue algorithms here are novel, and that is deliberate.** A +//! concurrent queue is a bad place to be original: the failure mode is a +//! reordering that appears on one machine, under load, months later. Each shape +//! implements a published design, and the value this crate adds is the waiting, +//! not the queueing. +//! +//! - [`spsc`] is the classic single-producer single-consumer ring buffer, with +//! the producer's and consumer's positions on separate cache lines so the two +//! ends stop invalidating each other's line. The structure is old -- Lamport +//! gave the concurrent-reader/writer treatment in 1983 -- and the padding is +//! standard modern practice. +//! - [`slotwise_mpsc`] implements Dmitry Vyukov's bounded MPMC array queue, +//! specialised to one consumer. Each slot carries its own sequence number, so +//! a producer claims a position and then asks *that slot* whether it is ready, +//! which keeps producers off any single shared line. It is among the most +//! widely reimplemented concurrent queues in existence. +//! - [`reserving_mpsc`] uses the other classic approach: a producer counts free +//! slots against the consumer's position, so space can be *claimed in advance*. +//! Credit- or ticket-based admission of this kind is long-established in flow +//! control, and it is the only way to answer "will there be room later?". +//! +//! Where this crate departs from a reference implementation it says so, and why, +//! in `DESIGN-NOTES.md`. The measured behaviour of both MPSC shapes is below, +//! including one case where the published intuition turned out to be wrong on +//! our hardware. +//! +//! # Why not an existing queue crate +//! +//! Rust has excellent channel crates, and for most programs one of them is the +//! right answer. **They are not usable here for one structural reason: on +//! Windows, waiting is a kernel-object operation, and a queue whose readiness is +//! not a `HANDLE` cannot take part in one.** +//! +//! A thread that must wait for "an item arrived **or** an I/O completed **or** +//! this process exited **or** cancellation was requested" waits on all of them +//! at once, in a single `WaitForMultipleObjects`. Every participant in that wait +//! has to be a kernel object. A channel that signals readiness through an +//! internal condition variable, a futex, or a parked-thread list cannot be one +//! of them, however good its own blocking receive is -- and however rich its own +//! select mechanism, because that mechanism can only select over its own +//! channels. +//! +//! The alternatives to a waitable queue are all worse in the same way: +//! +//! - **Poll the queue on a timer.** Trades latency against wakeups, and the +//! thread is awake to discover nothing happened. +//! - **Dedicate a thread to blocking on the channel, which signals an event.** +//! Correct, and costs a thread and a hop per item to convert a condition +//! variable back into the kernel object you needed from the start. +//! - **Move everything to async.** A real answer for a program that is already +//! async; not one for a thread whose other obligations are `HANDLE`s. +//! +//! So the queue owns a manual-reset event and keeps it consistent with the +//! queue's state -- which is the hard part, and what this crate is actually +//! for. The event is created lazily, so a consumer that only ever polls never +//! allocates a kernel object at all. +//! +//! # Choosing between `slotwise_mpsc` and `reserving_mpsc` //! //! They are **two different claim protocols, not one queue with a switch**. -//! [`mpsc`] is Vyukov's bounded array queue, where a producer asks a slot's own +//! [`slotwise_mpsc`] is Vyukov's bounded array queue, where a producer asks a slot's own //! sequence number whether it is free. [`reserving_mpsc`] counts free slots //! against the consumer's position, which is the only way a reservation can be //! answered at all. Both are well-studied designs in production use elsewhere, //! which is why this crate ships both instead of picking one for you. //! -//! - Need [`Reserving`]? Only [`reserving_mpsc`] has it; [`mpsc`] structurally +//! - Need [`Reserving`]? Only [`reserving_mpsc`] has it; [`slotwise_mpsc`] structurally //! cannot. //! - Otherwise **start with [`reserving_mpsc`]**: it was the faster of the two //! at every producer count above one that we measured. @@ -64,7 +123,7 @@ //! Measured ns per push, isolated regime, median of three. An AMD EPYC 7763 //! slice (8 cores, 16 threads) and a Snapdragon X2 Elite (12 cores, no SMT): //! -//! | producers | `mpsc` x64 | `reserving` x64 | `mpsc` ARM64 | `reserving` ARM64 | +//! | producers | `slotwise_mpsc` x64 | `reserving` x64 | `slotwise_mpsc` ARM64 | `reserving` ARM64 | //! |---|---|---|---|---| //! | 1 | 9.0 | 8.6 | 6.5 | 6.1 | //! | 2 | 49.0 | 28.0 | 29.8 | 9.4 | @@ -75,16 +134,16 @@ //! //! **Read these as two data points, not as a law**, and measure your own //! workload before treating them as settled. This comparison has already -//! inverted once: the split was designed on the assumption that `mpsc` would be +//! inverted once: the split was designed on the assumption that `slotwise_mpsc` would be //! the cheaper shape, and measurement disagreed on both machines. Producer //! count, how hard the consumer drains, and where the threads are scheduled all //! move the answer -- placement alone moved an SPSC handoff by 5.6x on one of //! these hosts. //! //! Two things that look like reasons to choose and are not. **Capacity**: -//! `mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, but that counts slots +//! `slotwise_mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, but that counts slots //! allocated up front rather than items ever pushed, and 2^31 slots is tens of -//! gigabytes before the ring holds anything useful. **`mpsc` winning at one +//! gigabytes before the ring holds anything useful. **`slotwise_mpsc` winning at one //! producer**: true in one regime, and at one producer you want [`spsc`]. //! //! # Shutting down @@ -104,7 +163,7 @@ //! //! # Status //! -//! [`spsc`] and [`mpsc`] are implemented, both with their doorbell: either can +//! [`spsc`] and [`slotwise_mpsc`] are implemented, both with their doorbell: either can //! be polled with no kernel object at all, blocked on directly, or waited on //! alongside other handles. The remaining shapes land in the milestones tracked //! by `CHECKLIST-io-domains.md` at the workspace root; the decisions they are @@ -120,11 +179,11 @@ pub mod disposal; mod doorbell; mod error; mod metrics; -pub mod mpsc; mod options; #[cfg(test)] mod race_hooks; pub mod reserving_mpsc; +pub mod slotwise_mpsc; pub mod spsc; pub mod traits; diff --git a/crates/windows-waitable-queues/src/metrics/tests.rs b/crates/windows-waitable-queues/src/metrics/tests.rs index 9b4487b8..b5711356 100644 --- a/crates/windows-waitable-queues/src/metrics/tests.rs +++ b/crates/windows-waitable-queues/src/metrics/tests.rs @@ -3,7 +3,7 @@ //! Tests for the counters in isolation, with no queue attached. //! //! Their behaviour *through* a queue is asserted in each shape's own suite, -//! because each records depth from a different place -- and on `mpsc` records +//! because each records depth from a different place -- and on `slotwise_mpsc` records //! it only when asked. What is tested here is the arithmetic they share. use super::Metrics; diff --git a/crates/windows-waitable-queues/src/options.rs b/crates/windows-waitable-queues/src/options.rs index adde38e6..b59c4381 100644 --- a/crates/windows-waitable-queues/src/options.rs +++ b/crates/windows-waitable-queues/src/options.rs @@ -22,7 +22,7 @@ //! **High-water tracking** is off because it is the one metric that cannot be //! made free. Refusals and doorbell rings sit on paths that were already paying //! for themselves, but a peak has to observe every change, and on -//! [`mpsc`](crate::mpsc) observing the depth means the producer reading the +//! [`slotwise_mpsc`](crate::slotwise_mpsc) observing the depth means the producer reading the //! consumer's position -- the single shared line that shape's push is built to //! avoid touching. So it is a switch, and the cost lands only on queues that //! asked for the answer. @@ -80,7 +80,7 @@ impl Options { /// /// **This is the one option that costs the push path something**, which is /// why it is off by default. A peak has to observe every change, so on - /// `mpsc` it makes the producer read the consumer's position -- the shared + /// `slotwise_mpsc` it makes the producer read the consumer's position -- the shared /// line that shape's push exists to avoid. On `spsc` and `reserving_mpsc` /// the producer already knows the depth, so it costs those two almost /// nothing. diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index f11bdff1..cd4589e1 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -2,11 +2,11 @@ //! The multi-producer, single-consumer bounded array queue **that can reserve**. //! -//! Everything [`mpsc`](crate::mpsc) is, plus [`Producer::reserve`]: a slot +//! Everything [`slotwise_mpsc`](crate::slotwise_mpsc) is, plus [`Producer::reserve`]: a slot //! claimed in advance, so that a later delivery cannot be refused for want of //! room. *Reserved is guaranteed, unreserved is best-effort.* //! -//! # Why this is a separate shape rather than a method on `mpsc` +//! # Why this is a separate shape rather than a method on `slotwise_mpsc` //! //! Because the two ask different questions to claim a slot, and only this one's //! question can answer a reservation. They are two claim protocols, not one @@ -14,15 +14,15 @@ //! //! Honouring a reservation costs the producer a read of the consumer's position //! on **every** push, including the pushes that never reserve anything -- which -//! is what `mpsc` avoids and why it cannot offer reservation at all. +//! is what `slotwise_mpsc` avoids and why it cannot offer reservation at all. //! //! **That cost is not what makes either shape slower.** This one measured -//! *faster* than `mpsc` under contention on both architectures tried, by up to -//! 6.4x, because the slot sequence `mpsc` reads instead marches through memory +//! *faster* than `slotwise_mpsc` under contention on both architectures tried, by up to +//! 6.4x, because the slot sequence `slotwise_mpsc` reads instead marches through memory //! while other producers write it. See the crate documentation for the numbers //! and for how to choose. //! -//! `mpsc`'s producer never reads the consumer's position. It asks a different +//! `slotwise_mpsc`'s producer never reads the consumer's position. It asks a different //! question -- "is the slot I am about to claim free?" -- and reads that from //! the slot's own sequence number, which is spread across the slot array, so //! producers working at different positions touch different cache lines. @@ -34,11 +34,11 @@ //! requires exactly that count -- which requires the consumer's position, on one //! line every thread in the system touches. //! -//! So the two ship as peers ([D-16](../../DESIGN-NOTES.md#d-16)): `mpsc` for a +//! So the two ship as peers ([D-16](../../DESIGN-NOTES.md#d-16)): `slotwise_mpsc` for a //! caller who wants the cheapest possible push and can treat a refusal as //! backpressure, this shape for a caller with a message it must not lose. That //! is the narrow-trait argument from [D-2](../../DESIGN-NOTES.md#d-2) reaching -//! its sharpest case -- `mpsc` does not implement +//! its sharpest case -- `slotwise_mpsc` does not implement //! [`Reserving`](crate::Reserving) because it genuinely cannot, not because //! nobody got round to it. //! @@ -116,7 +116,7 @@ const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; /// What this shape accepts as a capacity. /// -/// The minimum is two for the same reason [`mpsc`](crate::mpsc)'s is: with a +/// The minimum is two for the same reason [`slotwise_mpsc`](crate::slotwise_mpsc)'s is: with a /// single slot, "published at `p`" and "free again on the next lap" would be the /// same sequence number. /// @@ -237,7 +237,7 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit /// quietly. Pairing a reservation with a disposal sink is what closes that. /// /// [`Options::tracking_high_water`] costs this shape almost nothing, unlike -/// [`mpsc`](crate::mpsc): the producer already reads the consumer's position +/// [`slotwise_mpsc`](crate::slotwise_mpsc): the producer already reads the consumer's position /// to decide whether there is room beyond the reservations, so the depth is a /// subtraction of two numbers it is already holding. /// @@ -300,12 +300,12 @@ struct Slot { /// writing, and anything else before that. /// /// **This shape uses the sequence for one direction only.** In - /// [`mpsc`](crate::mpsc) it answers both "has this been published?" for the + /// [`slotwise_mpsc`](crate::slotwise_mpsc) it answers both "has this been published?" for the /// consumer and "is this slot free?" for the producer. Here the producer /// answers the second from the consumer's position instead -- it has to read /// that position anyway, to count free slots for the reservations -- so /// nothing ever stores a "free again" value and the consumer's `pop` is one - /// store shorter than `mpsc`'s. + /// store shorter than `slotwise_mpsc`'s. sequence: AtomicU32, value: UnsafeCell>, } @@ -324,7 +324,7 @@ struct Shared { /// Where the consumer will next read. Written only by the consumer. /// /// Padded onto its own cache line, and here the padding earns its place - /// twice over: unlike `mpsc`, *every* producer reads this on *every* push, + /// twice over: unlike `slotwise_mpsc`, *every* producer reads this on *every* push, /// so letting the claim word share the line would put the consumer's writes /// directly in their path. head: CacheAligned, @@ -395,7 +395,7 @@ impl Shared { /// Items currently held, as a snapshot. /// /// Counts slots a producer has claimed but not yet finished writing, for the - /// reason `mpsc`'s does: counting only published items would need a walk of + /// reason `slotwise_mpsc`'s does: counting only published items would need a walk of /// the ring, and this number is a metric rather than a control-flow input. fn len(&self) -> usize { let position = position_of(self.claim.0.load(Ordering::Acquire)); @@ -450,7 +450,7 @@ impl Shared { /// must not have published it already. A position is claimed by exactly one /// producer, so this is the only writer of the slot. unsafe fn publish(&self, position: u32, item: T) { - // Near-free on this shape, unlike `mpsc`: the producer has already + // Near-free on this shape, unlike `slotwise_mpsc`: the producer has already // read `head` to decide there was room beyond the reservations, so the // depth is a subtraction of two numbers it is holding. Only the // counter's line is shared, and it is written rarely -- see @@ -891,7 +891,7 @@ impl Consumer { // the position and overwrite an item this thread had not finished // taking. // - // Note that nothing stores a "free again" sequence here, unlike `mpsc`. + // Note that nothing stores a "free again" sequence here, unlike `slotwise_mpsc`. // Advancing `head` *is* the release, because this shape's producers // decide freedom from `head` rather than from the sequence. self.shared diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index ec869938..91c89b83 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -2,7 +2,7 @@ //! Tests for the reserving MPSC bounded array queue. //! -//! The shape's queueing behaviour is `mpsc`'s and is covered there; what is +//! The shape's queueing behaviour is `slotwise_mpsc`'s and is covered there; what is //! tested here is the part that is different -- the reservation, the packed //! claim word, and the ways the two interact with everything else. //! @@ -108,7 +108,7 @@ fn the_two_halves_do_not_bleed_into_each_other() { #[test] fn a_capacity_above_this_shapes_ceiling_is_refused_even_though_others_accept_it() { // The bound is a property of the shape, which is exactly what D-12 argued - // and what this shape is the second instance of. `mpsc` takes this capacity + // and what this shape is the second instance of. `slotwise_mpsc` takes this capacity // happily; the packing means this one cannot. let error = bounded::(BOUNDS_MAX * 2).expect_err("beyond the packed position's range"); assert_eq!(error.max_valid(), BOUNDS_MAX); @@ -504,7 +504,7 @@ fn items_come_out_in_the_order_they_went_in() { #[test] fn the_ring_wraps_many_times_without_losing_order() { // The test that indicts the position arithmetic, and it matters more here - // than in `mpsc`: this shape decides a slot is free from the consumer's + // than in `slotwise_mpsc`: this shape decides a slot is free from the consumer's // position rather than from the slot's own sequence, so an error in the // wrapping subtraction is a use-after-free rather than a wrong answer. let (tx, rx) = bounded::(4).expect("a power-of-two capacity"); @@ -685,7 +685,7 @@ fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { } // --------------------------------------------------------------------------- -// Through the traits, which is where this shape and `mpsc` visibly differ. +// Through the traits, which is where this shape and `slotwise_mpsc` visibly differ. // --------------------------------------------------------------------------- #[test] diff --git a/crates/windows-waitable-queues/src/mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs similarity index 98% rename from crates/windows-waitable-queues/src/mpsc.rs rename to crates/windows-waitable-queues/src/slotwise_mpsc.rs index ab54d341..e3e6264a 100644 --- a/crates/windows-waitable-queues/src/mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -138,9 +138,9 @@ const BOUNDS: Bounds = Bounds { /// # Examples /// /// ``` -/// use windows_waitable_queues::mpsc; +/// use windows_waitable_queues::slotwise_mpsc; /// -/// let (tx, rx) = mpsc::bounded::(4)?; +/// let (tx, rx) = slotwise_mpsc::bounded::(4)?; /// let second = tx.clone(); /// /// tx.push(1).expect("a fresh queue has room"); @@ -369,7 +369,7 @@ impl Drop for Shared { } } -/// A writing half of an [`mpsc`](self) queue. +/// A writing half of an [`slotwise_mpsc`](self) queue. /// /// [`Clone`], and that is the only difference from `spsc`'s producer: cloning /// is how a second producer comes into existence, and the queue is disconnected @@ -561,7 +561,7 @@ impl Clone for Producer { // queue's state instead. impl fmt::Debug for Producer { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("mpsc::Producer") + f.debug_struct("slotwise_mpsc::Producer") .field("capacity", &self.capacity()) .field("len", &self.len()) .field("producers", &self.shared.producers.load(Ordering::Relaxed)) @@ -595,7 +595,7 @@ impl Drop for Producer { } } -/// The reading half of an [`mpsc`](self) queue. +/// The reading half of an [`slotwise_mpsc`](self) queue. /// /// Neither [`Clone`] nor [`Sync`], which is what makes "single consumer" a fact /// the compiler checks rather than a rule to remember. @@ -717,10 +717,10 @@ impl Consumer { /// whether waiting is safe, or the wait can miss an item and block forever: /// /// ```no_run - /// # use windows_waitable_queues::mpsc; + /// # use windows_waitable_queues::slotwise_mpsc; /// # use windows_sys::Win32::System::Threading::{WaitForSingleObject, INFINITE}; /// # use std::os::windows::io::AsRawHandle; - /// # fn demo(rx: &mpsc::Consumer) -> std::io::Result<()> { + /// # fn demo(rx: &slotwise_mpsc::Consumer) -> std::io::Result<()> { /// loop { /// while let Some(item) = rx.pop() { /// let _ = item; @@ -880,7 +880,7 @@ impl Parked for Consumer { /// See [`Producer`]'s impl for why this is hand-written. impl fmt::Debug for Consumer { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("mpsc::Consumer") + f.debug_struct("slotwise_mpsc::Consumer") .field("capacity", &self.capacity()) .field("len", &self.len()) .field("producers", &self.shared.producers.load(Ordering::Relaxed)) diff --git a/crates/windows-waitable-queues/src/mpsc/tests.rs b/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs similarity index 100% rename from crates/windows-waitable-queues/src/mpsc/tests.rs rename to crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 5fa84649..8dcb2e3b 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -36,7 +36,7 @@ //! ``` //! //! **They have since shipped, and they kept those signatures.** -//! [`mpsc`](crate::mpsc) was written against this sketch and matched it, which +//! [`slotwise_mpsc`](crate::slotwise_mpsc) was written against this sketch and matched it, which //! is the validation [D-3](../../DESIGN-NOTES.md#d-3) demanded before any trait //! was allowed to exist. The sketch is left here because it is the artefact //! that made the check possible: what [`crate::traits`] says now is what this @@ -86,7 +86,7 @@ use crate::options::Options; /// /// The minimum is one, and there is nothing to work around: a single slot is /// either inside `[head, tail)` or outside it, and those are the only two -/// states this shape's positions have to distinguish. [`mpsc`](crate::mpsc) +/// states this shape's positions have to distinguish. [`slotwise_mpsc`](crate::slotwise_mpsc) /// needs two, because its slots carry a third state, and that difference is why /// each shape names its own bounds rather than sharing one pair. /// @@ -761,9 +761,9 @@ impl Consumer { /// empty and will never be signalled again. /// /// The first of those two cases is stronger here than it is for - /// [`mpsc`](crate::mpsc): there is one producer and one position, so *any* + /// [`slotwise_mpsc`](crate::slotwise_mpsc): there is one producer and one position, so *any* /// push before the clear makes this check find something. That is why this - /// shape never exhibited the doorbell defect `mpsc` exposed, and why the + /// shape never exhibited the doorbell defect `slotwise_mpsc` exposed, and why the /// fix for it belongs to the doorbell rather than to either caller. /// /// This also creates the doorbell if it does not exist, which must happen diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index 8b6fc00d..b5256d41 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -951,7 +951,7 @@ fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { // two implementations share nothing, so the guarantee has to be asserted // separately on each -- a point made empirically rather than by argument: the // sabotage sweep found this whole section missing, because the reserving_mpsc -// tests covered the mpsc path and left this one unguarded. +// tests covered the slotwise_mpsc path and left this one unguarded. // --------------------------------------------------------------------------- /// Fills every slot the best-effort path is allowed to take, and reports how diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs index 534d30f9..b46b3429 100644 --- a/crates/windows-waitable-queues/src/traits.rs +++ b/crates/windows-waitable-queues/src/traits.rs @@ -25,16 +25,16 @@ //! tests whether the abstraction is the right one. So the trait *shape* was //! fixed in prose when `spsc` was written -- the signatures were spelled out in //! its module documentation before the type existed -- and the traits -//! themselves waited for `mpsc` to exist to be checked against. That is +//! themselves waited for `slotwise_mpsc` to exist to be checked against. That is //! [D-3](../../DESIGN-NOTES.md#d-3), and the check it demands is not rhetorical: -//! `mpsc` is a lock-free multi-producer array queue with no structural +//! `slotwise_mpsc` is a lock-free multi-producer array queue with no structural //! resemblance to `spsc` beyond its interface, so a signature that fitted only //! the first shape would have failed here rather than in a consumer's code. //! //! # The name a trait shares with a handle //! //! [`Producer`] and [`Consumer`] are also the names of the concrete handles in -//! [`spsc`](crate::spsc) and [`mpsc`](crate::mpsc). That is deliberate: the +//! [`spsc`](crate::spsc) and [`slotwise_mpsc`](crate::slotwise_mpsc). That is deliberate: the //! trait is named for the role, the handle is named for the role, and the //! handle plays the role. `std` does the same thing with `fmt::Write` and //! `io::Write`, and the module path disambiguates. Importing the traits @@ -172,11 +172,11 @@ pub trait Bounded { /// /// # Why this is a trait a shape may lack /// -/// [`mpsc`](crate::mpsc) deliberately does **not** implement this, and that is +/// [`slotwise_mpsc`](crate::slotwise_mpsc) deliberately does **not** implement this, and that is /// the clearest illustration of why the capability traits are narrow /// ([D-2](../../DESIGN-NOTES.md#d-2)). Honouring a reservation means knowing how /// many slots remain, which costs a producer a read of the consumer's position -/// on every push -- a single line every thread touches. `mpsc`'s push avoids +/// on every push -- a single line every thread touches. `slotwise_mpsc`'s push avoids /// that read by design, so it cannot answer the question, and /// [`reserving_mpsc`](crate::reserving_mpsc) exists beside it for callers who /// would rather pay than lose a message. diff --git a/crates/windows-waitable-queues/src/traits/tests.rs b/crates/windows-waitable-queues/src/traits/tests.rs index 9fcd9592..a2ad8ac8 100644 --- a/crates/windows-waitable-queues/src/traits/tests.rs +++ b/crates/windows-waitable-queues/src/traits/tests.rs @@ -16,7 +16,7 @@ //! the failure D-3 exists to prevent -- these would not compile against the //! other, which is a stronger check than any assertion in the bodies. -use crate::{Bounded, Consumer, Producer, PushError, Waitable, mpsc, spsc}; +use crate::{Bounded, Consumer, Producer, PushError, Waitable, slotwise_mpsc, spsc}; /// Fills a queue through nothing but the [`Producer`] and [`Bounded`] traits, /// and reports what the refusal said. @@ -85,7 +85,7 @@ where #[test] fn both_shapes_satisfy_the_producer_and_bounded_traits() { let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid for both shapes"); - let (mpsc_tx, mpsc_rx) = mpsc::bounded::(4).expect("4 is valid for both shapes"); + let (mpsc_tx, mpsc_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid for both shapes"); assert!( matches!(fill_to_capacity(&spsc_tx), PushError::Full(u32::MAX)), @@ -103,7 +103,7 @@ fn both_shapes_satisfy_the_producer_and_bounded_traits() { #[test] fn both_shapes_report_disconnection_through_the_traits() { let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid for both shapes"); - let (mpsc_tx, mpsc_rx) = mpsc::bounded::(4).expect("4 is valid for both shapes"); + let (mpsc_tx, mpsc_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid for both shapes"); fn producer_sees_it>(producer: &P) -> bool { producer.is_disconnected() @@ -129,7 +129,7 @@ fn both_shapes_report_disconnection_through_the_traits() { #[test] fn both_shapes_satisfy_the_waitable_trait() { let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid for both shapes"); - let (mpsc_tx, mpsc_rx) = mpsc::bounded::(4).expect("4 is valid for both shapes"); + let (mpsc_tx, mpsc_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid for both shapes"); assert!(arm_and_report(&spsc_rx), "an empty ring is safe to wait on"); assert!( @@ -152,7 +152,7 @@ fn the_multi_producer_shape_is_usable_through_the_producer_trait_from_a_clone() // that are, and `push` taking `&self` is what lets it span both. Had the // first shape shipped `push(&mut self)` -- which single-producer soundness // would have permitted -- this could not compile. - let (tx, rx) = mpsc::bounded::(4).expect("4 is a valid capacity"); + let (tx, rx) = slotwise_mpsc::bounded::(4).expect("4 is a valid capacity"); let second = tx.clone(); fn push_one>(producer: &P, value: u32) { @@ -170,7 +170,7 @@ fn drain_stops_at_the_current_end_rather_than_at_the_end_of_the_stream() { // not a way to consume a queue to its end. A caller that read it as the // latter would drop items pushed afterwards, so the distinction is asserted // rather than left to the documentation. - let (tx, rx) = mpsc::bounded::(4).expect("4 is a valid capacity"); + let (tx, rx) = slotwise_mpsc::bounded::(4).expect("4 is a valid capacity"); tx.push(1).expect("there is room"); assert_eq!(drain_all(&rx), vec![1]); From 686332a5ce82fd217689c30c893ec6ccf0b201d5 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 14:14:15 -0400 Subject: [PATCH 059/361] docs(waitable-queues): ship 0.1.0 without machine-checked orderings, and disclose it SH-1.2 decides only whether M31.6 blocks the release; it does not complete M31.6, and M31.6 remains open. The answer is written onto it. Model-checking does not gate 0.1.0. It gates 1.0. The case for gating was strong and is recorded before the case against: this is a lock-free primitive whose whole value is correctness, and the suite's blindness to ordering defects is measured rather than assumed -- weakening the producer's Acquire load of the consumer's position to Relaxed left all twenty tests green while every logic defect injected beside it was caught. Three things decided it the other way. A model checker would close the demonstrated gap but not the dangerous one. It models atomics and cannot model SetEvent/ResetEvent, so it covers the queue shapes' positions and sequence numbers -- where the weakened-acquire defect lives -- and cannot cover the doorbell, whose entire correctness argument is how an atomic mirror flag interleaves with those two syscalls. Stubbing them would verify a model of SetEvent rather than SetEvent, which is the trap D-28's probe was already caught by. The only ordering bug this crate has actually had was D-15's lost wakeup; it was found by sabotage, and a model checker would not have found it. So completing M31.6 must not be read as "the orderings are verified", and M31.6's scope is corrected to say so. The risk it addresses is mostly regression risk, and that is lowest now. The sabotage sweep introduced the weakening to prove blindness rather than discovering an existing defect. Regression risk grows with contributors, changes and consumers, all of which begin after publication. Gating has a cost this crate does not pay: it blocks 0.1.0 and through it the placement tool and the NUMA measurements from machines this workspace does not own. Every host available here has one NUMA node, which is not fixable locally at any price. The disclosure is the decision, not the deferral. The crate documentation and README now state what is verified, state that stress testing here is known not to catch ordering defects and cite the measurement showing it, and say a model checker is planned before 1.0 -- including the limit that it will still not cover the doorbell. An adopter decides with the information we have; 0.x carries the rest and is meant literally. Completed item: SH-1.2: Decide explicitly whether M31.6 gates 0.1.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 16 +++++++- CHECKLIST-ship-topology-and-queues.md | 30 +++++++++++++- .../windows-waitable-queues/DESIGN-NOTES.md | 40 +++++++++++++++++++ crates/windows-waitable-queues/README.md | 30 ++++++++++++++ crates/windows-waitable-queues/src/lib.rs | 30 ++++++++++++++ 5 files changed, 143 insertions(+), 3 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index b5387a7c..8e6a72c7 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -492,8 +492,20 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m - [ ] **M31.6** -- **GOVERNED BY [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) SH-1.2, which decides only whether this blocks the 0.1.0 release. SH-1.2 completing does NOT complete - this item**; it records an answer here. Once that answer exists, note it on this line so the reader - knows whether the crate shipped with this open deliberately. + this item**; it records an answer here. + **The answer, recorded 2026-08-31: this does NOT gate `windows-waitable-queues` 0.1.0. It gates + 1.0**, and the crate ships 0.1.0 disclosing the gap in its own documentation rather than leaving an + adopter to find it. See D-31. This item stays open, and the disclosure is a promise it now carries. + + **Its scope is corrected by the same decision, and this is the part worth reading before starting.** + Loom models atomics; it cannot model `SetEvent`/`ResetEvent`. So it covers the three queue shapes' + head/tail/sequence orderings -- which *is* where the demonstrated blind spot lives -- and it does + **not** cover the doorbell, whose correctness is precisely the interleaving of an `AtomicBool` mirror + with those syscalls. Stubbing them would verify a model of `SetEvent` rather than `SetEvent`, which + is the "measures the model, not the thing" trap this workspace has already been caught by once. + **D-15's lost wakeup, the only ordering bug this crate has actually had, was found by sabotage and + loom would not have found it.** Do not let completing this item be read as "the orderings are now + verified": the doorbell needs a separate answer, and this item does not supply it. Verify the memory orderings with a model checker, because stress testing demonstrably cannot. **Measured, not assumed:** during M30.3's sabotage sweep, weakening the producer's `Acquire` load of `head` to `Relaxed` left all twenty tests green, while every *logic* defect injected alongside diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index e8eac286..e02f7ec3 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -52,13 +52,41 @@ release-blocking rather than restating the decision itself. The measurement is already done and agrees across both architectures -- see M31.5 and M31.7 in [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) -- so this needs a decision, not more work. -- [ ] **SH-1.2** -- **GOVERNS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.6 -- this is not +- [x] **SH-1.2** -- **GOVERNS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.6 -- this is not that item and does not complete it.** It decides only whether M31.6 blocks SH-4.3. If the answer is "it gates", record that on M31.6 and SH-4.3 cannot proceed until M31.6 is done; if "it does not", record that too, so a later reader does not mistake a considered choice for an oversight. **Checking this off never checks off M31.6.** **Decide explicitly whether M31.6 (loom verification) gates 0.1.0**, and record the answer either way rather than letting it drift into "not yet". + + **Decided: it does not gate 0.1.0. It gates 1.0, and the gap is disclosed in the crate's own + documentation rather than left for an adopter to discover.** Recorded as D-31. + + Three findings drove it, and the second was not expected: + + - **Loom would close the demonstrated gap.** The sabotage sweep showed a weakened `Acquire` on the + producer's load of `head` survives the whole suite. That defect lives in queue code, which is + exactly what loom models well. + - **Loom would *not* close the gap where a real bug actually occurred.** The doorbell's correctness + is the interleaving of an `AtomicBool` mirror with real `SetEvent`/`ResetEvent` syscalls. Loom + models the atomics and cannot model the syscalls; stubbing them tests a *model* of `SetEvent` + rather than `SetEvent`. D-15's lost wakeup -- the only ordering bug this crate has actually had -- + was found by sabotage, and loom would not have found it. So loom is valuable and is **not** the + thing standing between this crate and confidence about its hardest part. + - **The risk loom addresses is mostly regression risk**, and that risk is lowest now. The orderings + are believed correct and were reasoned about at the time; sabotage *introduced* the weakening to + prove the suite was blind to it. Regression risk rises with contributors, changes, and consumers + -- all of which start after publication, not before. + + Against that, gating would block 0.1.0, and through it the placement tool and the NUMA measurements + from other people's machines that this whole sequence exists to obtain. Loom is invasive work: every + atomic in the crate goes behind a `cfg` shim across four modules. + + **The disclosure is what makes this a decision rather than a punt**, and it is not optional: the + crate documentation states what is verified, states that stress testing here is *known* not to catch + ordering defects and cites the measurement showing it, and says loom is planned before 1.0. An + adopter then makes their own call with the same information we have. `0.x` carries the rest. The reason it deserves a deliberate answer rather than a default: the sabotage sweep demonstrated that weakening the producer's `Acquire` load of `head` to `Relaxed` left **all twenty tests green**, while every logic defect injected beside it was caught. So this is not an untested-by-omission gap, diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 5fbd2497..34f49f73 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -54,6 +54,7 @@ preferred. | D-28 | **Amended -- the blanket rejection is withdrawn; the verdict depends on thread placement, and the open question is queued as [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M-inf.4.** Caching the peer's index was measured, and it engaged as designed. It cost ~1.8x on x64 with the threads across cores, and *won* 17x on ARM64 and 1.8x on x64 SMT siblings. Batch depth decides the sign, and batch depth is set by where the two threads are scheduled -- not by the architecture and not by our code. A prefetch-only "warming" control changed nothing on any host. | | D-29 | **Both multi-producer shapes ship. The crate publishes what it measured and declines to choose for the caller.** [D-26](#d-26) falsified [D-16](#d-16)'s cost premise, which reopened merge-or-delete; the answer is neither. Vyukov's sequence protocol and the head-based one are independently researched designs, both in production use, and our own workload having settled which *we* want is not evidence about anyone else's. Deleting a shape because no visible consumer wants it is what PLATFORM INTEGRITY forbids. What the crate owes instead is the data and, through `probe-core-affinity`, the means to gather it on the caller's own hardware. | | D-30 | **Both MPSC shapes are qualified by name; neither is `mpsc`.** A bare `mpsc` beside `reserving_mpsc` makes one canonical by implication, which contradicts this crate's own "no shape is the canonical one" and, after [D-29](#d-29), is simply false. `slotwise_mpsc` names its claim protocol -- it claims slot by slot, with no shared counter -- and avoids the reading `sequence_mpsc` invites, that it alone preserves FIFO order when both shapes do. Renamed before first publish, where it is free. | +| D-31 | **0.1.0 ships without machine-checked memory orderings, and says so in its own documentation.** Model-checking gates 1.0, not 0.1.0. It would close the *demonstrated* gap -- a weakened `Acquire` survives the whole suite -- but not the dangerous one: it cannot model `SetEvent`/`ResetEvent`, so it cannot cover the doorbell, and [D-15](#d-15)'s lost wakeup, the only ordering bug this crate has had, was found by sabotage instead. The risk it addresses is mostly regression risk, which is lowest before there are consumers. The disclosure, not the deferral, is the decision. | ## D-2: capabilities are sliced, not gathered @@ -1016,6 +1017,45 @@ noted above). The fix also makes the "near vs far" summary fall back to the sibl where `same cache, same class` is not expressible, which would otherwise have printed nothing here. +## D-31: 0.1.0 ships without machine-checked orderings, and says so + +Model-checker verification of the memory orderings ([M31.6](../../CHECKLIST-io-domains.md)) does **not** +gate `windows-waitable-queues` 0.1.0. It gates 1.0. The gap is disclosed in the crate documentation and +the README rather than left for an adopter to discover. + +The case for gating was strong and is worth stating before the case against. This is a lock-free +concurrency primitive whose whole value is correctness, and the suite's blindness to ordering defects is +**measured, not assumed**: weakening the producer's `Acquire` load of the consumer's position to +`Relaxed` left all twenty tests of the day green, while every logic defect injected beside it was +caught. Publishing with a known blind spot is a real decision. + +Three things decided it the other way. + +**A model checker would close the demonstrated gap but not the dangerous one.** It models atomics; it +cannot model `SetEvent` and `ResetEvent`. So it covers the queue shapes' positions and sequence numbers +-- which is where the weakened-acquire defect lives -- and it cannot cover the doorbell, whose entire +correctness argument ([D-9](#d-9), [D-15](#d-15)) is how an atomic mirror flag interleaves with those +two syscalls. Stubbing them verifies a model of `SetEvent` rather than `SetEvent`, which is the +"measures the model, not the thing" trap this workspace was already caught by once, in +[D-28](#d-28)'s probe. **The only ordering bug this crate has actually had was D-15's lost wakeup, it +was found by sabotage, and a model checker would not have found it.** Treating that work as "the +orderings are now verified" would therefore overstate it in exactly the direction that matters. + +**The risk it addresses is mostly regression risk, and that is lowest now.** The orderings are believed +correct and were argued at the time; the sabotage sweep *introduced* the weakening to prove the suite +was blind to it, rather than discovering one. Regression risk grows with contributors, changes and +consumers, all of which begin after publication. + +**Gating has a cost that is not paid by this crate.** It blocks 0.1.0, and through it the placement +tool and the measurements from other people's machines that the whole release sequence exists to +obtain -- see [CHECKLIST-placement-tool.md](../../CHECKLIST-placement-tool.md). Every host available +here has one NUMA node; that is not fixable locally at any price. + +**The disclosure is what makes this a decision rather than a deferral.** The crate says what is +verified, says that stress testing here is known not to catch ordering defects, cites the measurement +that shows it, and says a model checker is planned before 1.0. An adopter then decides with the +information we have. A `0.x` version number carries the rest, and is meant literally. + ## D-29: both multi-producer shapes ship, and the caller is given the data instead of a verdict [D-26](#d-26) falsified [D-16](#d-16)'s premise -- reading the consumer's position was supposed to make diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 242bceb7..363b6063 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -67,6 +67,36 @@ says so rather than the documentation: `spsc` accepts one slot, and `slotwise_mp two, because its per-slot sequence cannot distinguish "just published" from "free again next lap" in a one-slot ring. +## How far the memory orderings are verified, and how far they are not + +Stated plainly, because a lock-free queue that is vague about this is asking to +be trusted rather than evaluated. + +**What is verified.** Every ordering was reasoned about when written, and the +reasoning is recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) beside the code it +justifies. The shapes are covered by an extensive unit suite and by a sabotage +suite that injects deliberate defects and requires each to be caught -- which is +how the one real ordering bug this crate has had was found: a lost wakeup where +the doorbell cleared its mirror flag before resetting the event. + +**What is not.** Stress testing cannot catch a *weakened memory ordering* here, +and that is measured rather than assumed: changing the producer's `Acquire` load +of the consumer's position to `Relaxed` left the entire suite green, while every +logic defect injected beside it was caught. A test can only observe the +interleavings the hardware and scheduler happen to produce, and neither x86-64 +nor ARM64 obliged. + +**So the orderings are not machine-checked.** Verification with a model checker +is planned before 1.0. Until then `0.x` is meant literally, and an adopter for +whom that matters has the same information we do rather than an assurance we +cannot support. + +One limit worth knowing even after that work lands: a model checker covers the +queue shapes' positions and sequence numbers, and **cannot** cover the doorbell, +whose correctness is the interleaving of an atomic flag with real `SetEvent` and +`ResetEvent` calls. Modelling those would verify a model of them rather than the +calls themselves. + ## Where these algorithms come from **None of the queue algorithms here are novel, and that is deliberate.** A diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 2d39c0a2..0312eeab 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -46,6 +46,36 @@ //! [`Reserving`] -- each naming one thing a queue can do, so a caller can be //! generic over exactly what it needs and nothing more. //! +//! # How far the memory orderings are verified, and how far they are not +//! +//! Stated plainly because a lock-free queue that is vague about this is asking +//! to be trusted rather than evaluated. +//! +//! **What is verified.** Every ordering was reasoned about when written and the +//! reasoning is recorded in `DESIGN-NOTES.md` beside the code it justifies. The +//! shapes are covered by an extensive unit suite and by a sabotage suite that +//! injects deliberate defects and requires each to be caught -- which is how the +//! one real ordering bug this crate has had was found: a lost wakeup where the +//! doorbell cleared its mirror flag before resetting the event. +//! +//! **What is not.** Stress testing cannot catch a *weakened memory ordering* +//! here, and that is measured rather than assumed: changing the producer's +//! `Acquire` load of the consumer's position to `Relaxed` left the entire suite +//! green, while every logic defect injected beside it was caught. A test can +//! only observe the interleavings the hardware and scheduler happen to produce, +//! and neither x86-64 nor ARM64 obliged. +//! +//! **So the orderings are not machine-checked.** Verification with a model +//! checker is planned before 1.0. Until then the `0.x` version is meant +//! literally, and an adopter for whom that matters now has the same information +//! we have rather than an assurance we cannot support. +//! +//! One limit worth knowing even after that work lands: a model checker covers +//! the queue shapes' positions and sequence numbers, and **cannot** cover the +//! doorbell, whose correctness is the interleaving of an atomic flag with real +//! `SetEvent` and `ResetEvent` calls. Modelling those would verify a model of +//! them rather than the calls themselves. +//! //! # Where these algorithms come from //! //! **None of the queue algorithms here are novel, and that is deliberate.** A From 5a3769c45a24834436afbd8d69873488aad5e461 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 14:32:55 -0400 Subject: [PATCH 060/361] docs: multi-processor-group support, before a large NUMA machine is offered A two-socket Sapphire Rapids host is expected to become available. It fills nearly the whole placement table at once -- a genuine node crossing, a real cross-cache same-class row at L3, SMT siblings, and same-cache same-class within a socket -- and with Sub-NUMA Clustering it may present four or eight nodes, which would be the first host on which the node-pair matrix shows variation rather than a single hop. That matrix was built for this case and has never met a machine that can populate it. The tooling would produce silently wrong results on it. ProcessorPlace keys on a bare processor number and places_from_topology discards the group, so on a host with more than 64 logical processors every group's processor 5 collides on one map key. It does not crash: numbers stay under 64 within a group, so the existing assert never fires. The tool runs, pins to whichever processor won the collision, and prints a confident placement table describing a topology it collapsed -- the same defect class as the omitted SMT row, on hardware we would get one attempt at. SetThreadAffinityMask is single-group by definition, so this needs SetThreadGroupAffinity rather than a wider mask. Queued as M1B with a synthetic two-group fixture to verify against, and a requirement that the tool refuse loudly rather than collapse if anything remains unimplemented when the machine is offered. Records two cautions for that run: the host is homogeneous so same-cache cross-class stays unmeasurable, and it is x86-64 and therefore TSO, so a clean stress or queue run there is not ordering validation. Ask for probe-topology output before any long run, since SNC, group count and partitioning cache level are unknowable from the part number. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 22 +++++++++++ CHECKLIST-placement-tool.md | 29 ++++++++++++++ CHECKLIST-ship-topology-and-queues.md | 54 +++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 8e6a72c7..2a3560cd 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -793,3 +793,25 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio **What would actually add rows**, if either becomes available: bare metal for `same cache, same class` and `same cache, cross class`, or a deliberately large multi-NUMA VM SKU (not a dev box) for a genuine node crossing. See the NUMA gap recorded below before spending time on the latter. + + **A two-socket Sapphire Rapids host is expected to become available, and it fills nearly the whole + table at once.** Sockets give a genuine node crossing and a real `cross cache, same class` row at the + L3 level; SMT gives the sibling row; two cores within a socket give `same cache, same class`. If + **Sub-NUMA Clustering** is enabled it subdivides each socket, so the machine may present four or + eight nodes -- and that would be the first host on which the node-pair matrix shows *variation* + rather than one hop, because an intra-socket SNC hop and a cross-socket hop are not the same + distance. That matrix was built for exactly this case and has never met a machine that can populate + it. + Three things to carry into that run: + - **It still cannot produce `same cache, cross class`.** The cores are homogeneous, so that row + stays unmeasurable on every host we have access to. + - **It is x86-64, so it is TSO.** It will expose weakened memory orderings no better than the EPYC + slice did, and a clean run there must not be read as ordering validation. ARM64 remains the more + revealing host for that, and per D-31 neither substitutes for a model checker. + - **It will present multiple processor groups**, which the tooling does not yet handle and would + silently collapse rather than refuse. That is + [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) M1B, and it must land before the machine + is used. + **Ask for `probe-topology` output first**, before any long run. Whether SNC is on, how many groups + the host presents, and where its partitioning cache sits decide what everything else means, and none + of the three is knowable from the part number. diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index c2d17223..3a92ba30 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -108,6 +108,35 @@ waiting on numbers only other people's machines can produce. becomes an entry point only; measurement *and* rendering live in the library and are called, never reimplemented. A binary that formats its own output is the defect, not a binary that exists. +## M1B: processor groups, before a large machine is ever offered + +**This is the blocker that would waste the opportunity.** A large multi-socket host -- the kind that is +the entire point of this tool -- has more than 64 logical processors, so Windows presents it as +**multiple processor groups**, each numbering from zero. + +- [ ] **PT-1B.1** -- **Carry `(group, number)` as a processor's identity.** `ProcessorPlace` keys on a + bare `u8` number and `places_from_topology` discards the group outright (`for (_group, number)`), so + every group's processor 5 collides on one map key. **The result is not a crash.** Numbers stay below + 64 within a group, so `assert!(cpu < 64)` never fires: the tool runs, pins to whichever processor + won the collision, and prints a confident placement table describing a topology it silently + collapsed. That is the same defect class as the omitted SMT row, on the machine we would get one + attempt at. + +- [ ] **PT-1B.2** -- **Pin with `SetThreadGroupAffinity`.** `SetThreadAffinityMask` takes a mask + within the caller's current group and cannot express a processor in another one, so it is not a + matter of widening the mask. Keep the existing failure discipline: pinning that does not land must + abort the run rather than fall back to an unpinned measurement. + +- [ ] **PT-1B.3** -- **Verify against a synthetic multi-group topology**, since no host here has more + than one group. `places_from_topology` is a pure conversion and already testable; a fixture with two + groups whose numbers overlap must produce distinct processors, and the sabotage is to key on the + number alone and watch the count halve. + +- [ ] **PT-1B.4** -- **Refuse loudly if groups are present and unsupported.** Whatever remains + unimplemented when a large machine is offered, the tool must say so and stop. A refusal costs one + message; a collapsed topology costs a wrong answer nobody can detect from the output, on hardware + that is not coming back. + ## M2: the move - [ ] **PT-2.1** -- Move `fingerprint`, `core_affinity` and `peer_index_cache` into the new crate, and diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index e02f7ec3..f3dbf3b5 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -194,3 +194,57 @@ release-blocking rather than restating the decision itself. - [ ] **SH-5.2** -- Confirm the published `windows-topology-sys` still reports `Provenance::Measured` from `discover()` when consumed as a dependency, and that a `Topology::default()` is `Synthetic`. The provenance rules are the newest thing in the crate and the least exercised outside it. + +## M6: long-running validation + +**Placed last so the numbering does not churn, and it gates SH-4.3 all the same.** `windows-waitable-queues` +0.1.0 does not publish until this milestone is done. The reasoning is in SH-1.2 / D-31: the crate ships +without machine-checked orderings, and long-running validation is part of what it owes instead. + +**What a pass here does not mean, stated once and repeated in the tool's own output.** Hours of green +stress says nothing about memory orderings. That is measured, not cautious: weakening the producer's +`Acquire` to `Relaxed` left the whole suite green. A stress tool that omits this becomes false comfort +-- someone points at a long clean run and concludes the orderings are fine, which is exactly the claim +D-31 says cannot be supported. + +- [ ] **SH-6.1** -- **The wraparound scenario, which is the one reachable correctness gap.** + `reserving_mpsc` packs its position into 32 bits, so it wraps after 2^32 pushes -- about two minutes + at measured rates, and reachable in production within hours. `spsc` and `slotwise_mpsc` use `usize` + positions and cannot be driven there at all, so this gap belongs to the shape whose position is + narrow by design. + What exists today is *ring* wraparound (positions cycling through slots) and the packing arithmetic + checked at the boundary; what does not is the queue actually crossing 2^32 end to end. **Tracking + every item is impossible at that count**, so the invariants are the cheap ones: per-producer sequence + numbers strictly increasing in consumption order, and an exact total count. O(producers) memory + rather than O(items). + +- [ ] **SH-6.2** -- **Diagnostic history, merged by position rather than by a clock.** Unseeded is + correct here: the scheduler is the source of variation, not the PRNG, so a seed would make the inputs + reproducible while the interleaving that caused the failure stays unreproducible -- the appearance of + determinism with none of the substance. What is needed is **reconstructability**. + Each thread keeps a small lock-free ring of recent records: thread, operation, position, value, + outcome. **The merge needs no clock and no global counter**, because the queue under test already + carries a total order -- its positions -- so records sort by position after the fact. A global + sequence number would give a true order and perturb the hot path it is trying to observe; a + timestamp costs a clock read per operation. Both were considered and neither is needed. + The one case positions do not order is a *refused* push, which has no position; record the position + it attempted and mark it refused. + +- [ ] **SH-6.3** -- **Detect the failures worth detecting**, and dump the history on any of them: item + loss or duplication, per-producer order violation, a panic in any thread, and **no progress**. The + last needs a watchdog thread against a progress counter, and is the case that most needs history and + is least served by a seed -- a hang leaves no assertion behind, only a stuck process. + +- [ ] **SH-6.4** -- **Cover all three shapes and the doorbell.** The doorbell is the point: SH-1.2 + established that a model checker *cannot* cover it, because its correctness is an atomic mirror flag + interleaving with real `SetEvent`/`ResetEvent` calls. Stress is one of the few instruments that + exercises that at all. D-15's lost wakeup surfaced because a baseline run hung **once**; more hours + of running is the only lever we have on that class. + Include the parking path, not just the polling one -- a consumer that never parks never exercises + the doorbell protocol that D-9 and D-15 are about. + +- [ ] **SH-6.5** -- **Ship it as a tool, not only as a test.** A binary with duration and concurrency + knobs, so a user can stress *their* hardware. That matters concretely: x64 and ARM64 have already + disagreed once about this crate's behaviour, and no test we run here covers a machine we do not own. + Keep a short in-suite smoke run over the same engine so the code cannot rot, and keep it out of the + fast unit suite, which must stay under a second. From 4dbdf93941e2f9b4ebbfca47efaa2d80902b31e8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 14:35:21 -0400 Subject: [PATCH 061/361] docs: the GitHub binaries were never gated on the crate releases An earlier revision gated the whole placement tool on topology 0.2.0 and queues 0.1.0, which was wrong. CI builds the tool from this repository, so its dependencies resolve through path and nothing needs to exist on crates.io. Only PT-5.3 -- publishing the tool to crates.io, where a path dependency needs a real published version behind it -- actually waits on those releases. The mistake would have delayed the binaries by the length of the entire release sequence including M6's stress work, for no reason, and the binaries are the distribution that matters: the download is the provenance. Both reciprocal markers in the ship checklist corrected to say the same thing, so the two files cannot drift on it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 19 ++++++++++++++----- CHECKLIST-ship-topology-and-queues.md | 15 +++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 3a92ba30..66fcc7ec 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -6,11 +6,20 @@ from hardware it does not own. **The motivating gap is concrete: every host avai one NUMA node**, so the entire `cross NUMA node` row and the whole inter-node hop matrix are unmeasured, and no amount of local work will change that. -**GATED BY [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) SH-4.1 -(topology 0.2.0) and SH-4.3 (queues 0.1.0). This paragraph is the gate of record: when those land, -edit it to say the gate is lifted and name the two published versions.** Leaving it as-is after the -releases is the failure mode -- a reader arriving here should never have to reconstruct whether the -gate still applies. M6 below is deliberately *outside* this gate and says so. +**The gate applies to crates.io publication only, and not to the GitHub binaries.** An earlier revision +of this paragraph gated the whole file on SH-4.1 and SH-4.3, which was wrong and would have delayed the +tool by the length of the entire release sequence -- including M6's stress work -- for no reason. + +- **CI-built binaries are compiled from this repository**, so the tool's dependencies resolve through + `path` and nothing has to exist on crates.io. **PT-5.1 is therefore not gated at all**, and it is the + distribution that matters: the download is the provenance, per PT-3.2. +- **GATED BY [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) SH-4.1 + (topology 0.2.0) and SH-4.3 (queues 0.1.0): PT-5.3 only**, publishing the tool to crates.io, where a + path dependency needs a real published version behind it. **This bullet is the gate of record; when + those land, edit it to say so and name the two versions.** A gate that has silently lifted is as + harmful as one that has not. + +M1B and M6 are outside all of this and say so where they are defined. **Gated on shipping [crates/windows-topology-sys](crates/windows-topology-sys) and [crates/windows-waitable-queues](crates/windows-waitable-queues) first.** Not a preference: the tool diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index f3dbf3b5..8cc04d07 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -169,19 +169,22 @@ release-blocking rather than restating the decision itself. - [ ] **SH-4.1** -- Release `windows-topology-sys` 0.2.0 and confirm it appears on crates.io and builds on docs.rs. Docs.rs builds under its own configuration, so a crate that documents locally can still fail there. - **UNBLOCKS half of [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md), which is gated on both - releases. On completing this, update that file's gate note to record that topology has shipped** -- - the gate lifts only when SH-4.3 lands too, and a half-lifted gate that reads as lifted is how work - starts against a dependency that is not there yet. + **UNBLOCKS half of [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) PT-5.3 -- publishing + the tool to crates.io -- which needs both releases. On completing this, update that file's gate + bullet to record that topology has shipped**; the gate lifts only when SH-4.3 lands too, and a + half-lifted gate that reads as lifted is how work starts against a dependency that is not there yet. + **It does not gate the GitHub binaries**, which CI builds from this repository through `path` + dependencies. - [ ] **SH-4.2** -- Update `windows-ioring-sys` to depend on the published 0.2.0 and release it, per the order settled in SH-2.2. - [ ] **SH-4.3** -- Release `windows-waitable-queues` 0.1.0, with SH-2.1's fix in place. Confirm the tag triggered a publish rather than assuming it did. - **LIFTS THE GATE ON [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md). On completing this, - edit that file's opening gate paragraph to say the gate is lifted and name the two published + **LIFTS THE GATE ON [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) PT-5.3 only. On + completing this, edit that file's gate bullet to say the gate is lifted and name the two published versions**, so a reader arriving there later does not have to reconstruct whether it still applies. + The tool's GitHub binaries never waited on this. Blocked by SH-1.1, and by M31.6 as well if SH-1.2 decided that it gates. ## M5: verify from outside the workspace From 8f166c67785379501a45668a8f8330772b089252 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 14:39:00 -0400 Subject: [PATCH 062/361] feat(placement-probe): create the tool crate as windows-placement-probe Named for what it is and what it measures: a probe, not a library to build on, and placement rather than queues. windows-handoff-cost was rejected as too narrow, since the tool already reports topology and NUMA hops which are not handoffs; windows-locality-report as understating that it measures rather than summarises. All candidates confirmed free on crates.io first. publish = false, because PT-5.3 has not decided crates.io and false is the setting that cannot publish by accident. The distribution that matters is the CI-built binary anyway, where the download is the provenance. The crate documentation is written for a stranger rather than for this workspace: what the tool answers, why their machine is interesting given every host here has one NUMA node and the two measured hosts express disjoint placement sets, what is collected and what is not as a commitment rather than a description, and the honest limit that suppressing the CPU model does not make confidential hardware safe to submit because the topology is the measurement. M1B is noted to execute after M2 despite its number, so the module move stays a pure relocation with its provenance trail intact. Completed item: PT-1.1: Name the crate, and record the reasoning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 14 +++++- Cargo.lock | 9 ++++ Cargo.toml | 1 + crates/windows-placement-probe/Cargo.toml | 40 +++++++++++++++++ crates/windows-placement-probe/src/lib.rs | 52 +++++++++++++++++++++++ 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 crates/windows-placement-probe/Cargo.toml create mode 100644 crates/windows-placement-probe/src/lib.rs diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 66fcc7ec..88017744 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -38,10 +38,17 @@ waiting on numbers only other people's machines can produce. ## M1: decisions that shape everything after -- [ ] **PT-1.1** -- **Name the crate**, and record the reasoning. It measures what a producer/consumer +- [x] **PT-1.1** -- **Name the crate**, and record the reasoning. It measures what a producer/consumer handoff costs as a function of where the two threads run, which is broader than queues and narrower than "topology". Candidates to weigh rather than a foregone answer: `windows-placement-probe`, `windows-handoff-cost`, `windows-locality-report`. Check availability on crates.io before settling. + **Named `windows-placement-probe`.** It says what the thing is (a probe, not a library to build on) + and what it measures (placement), and it matches the `probe-` binary naming already in this + workspace. `windows-handoff-cost` was rejected as too narrow -- the tool already reports topology and + NUMA hops, which are not handoffs -- and `windows-locality-report` as understating that it *measures* + rather than summarises. All five candidates were confirmed free on crates.io before choosing. + Created as a workspace member with `publish = false`, because PT-5.3 has not decided crates.io yet + and `false` is the setting that cannot publish something by accident. - [x] **PT-1.2** -- **Decide what the submission record carries about the machine beyond the fingerprint**, specifically the CPU model name. The fingerprint deliberately omits model names @@ -119,6 +126,11 @@ waiting on numbers only other people's machines can produce. ## M1B: processor groups, before a large machine is ever offered +**Executes after M2, despite the number.** The code it changes lives in +[crates/windows-platform-probes](crates/windows-platform-probes) until the move, and doing this work in +its final home keeps the move a pure relocation with its provenance trail intact. The "before" in the +heading is about the *machine*, not about M2. + **This is the blocker that would waste the opportunity.** A large multi-socket host -- the kind that is the entire point of this tool -- has more than 64 logical processors, so Windows presents it as **multiple processor groups**, each numbering from zero. diff --git a/Cargo.lock b/Cargo.lock index 8c4be92e..bc9e5cd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -224,6 +224,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "windows-placement-probe" +version = "0.1.0" +dependencies = [ + "windows-sys", + "windows-topology-sys", + "windows-waitable-queues", +] + [[package]] name = "windows-platform-probes" version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml index f255412d..d0007510 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/windows-ioring-sys", "crates/windows-overlapped-io-sys", "crates/windows-namespace-request-sys", + "crates/windows-placement-probe", "crates/windows-platform-probes", "crates/windows-thread-ambient-sys", "crates/windows-threadpool-sys", diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml new file mode 100644 index 00000000..0749d248 --- /dev/null +++ b/crates/windows-placement-probe/Cargo.toml @@ -0,0 +1,40 @@ +# Copyright (c) 2026 Mike Grier + +[package] +name = "windows-placement-probe" +version = "0.1.0" +# Publication to crates.io is an open decision -- see PT-5.3 in +# CHECKLIST-placement-tool.md. The distribution that matters is the CI-built +# binary attached to a GitHub release, because the download is the provenance: +# a binary built here is traceable to the commit that produced it in a way a +# local build of identical source is not. `false` until that decision is made, +# so the crate cannot be published by accident before it is meant to be. +publish = false +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Measures what thread placement costs on a Windows machine -- SMT siblings, cache domains, efficiency classes and NUMA hops -- and produces one structured record to send back." + +[lib] +path = "src/lib.rs" + +[dependencies] +windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys" } +windows-waitable-queues = { version = "0.1.0", path = "../windows-waitable-queues" } + +[dependencies.windows-sys] +version = "0.61.2" +default-features = false +features = [ + "Win32_Foundation", + # Thread affinity: this crate pins both ends of a handoff to chosen + # processors, and a run whose pinning silently failed would measure the + # scheduler's preferences instead of the placement it claims to. + "Win32_System_Threading", + # The machine-description fields that travel with a submission, so a + # result can be read months later without asking its sender anything. + "Win32_System_SystemInformation", +] diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs new file mode 100644 index 00000000..ca87eef8 --- /dev/null +++ b/crates/windows-placement-probe/src/lib.rs @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Mike Grier +//! Measures what thread placement costs on this machine. +//! +//! # What this answers +//! +//! Where two communicating threads run changes how fast they can hand work to +//! each other, and by more than most optimisations are worth: on one machine +//! measured here, moving a producer/consumer pair from one locality domain to +//! another cost **5.6x** on the same code. This tool measures that on *your* +//! machine and writes one record you can send back. +//! +//! # Why your machine is interesting +//! +//! The designs this measurement informs are shared, and the hosts available to +//! the author are not. Every machine this has run on so far presents a **single +//! NUMA node**, so the cost of crossing between nodes -- the thing a +//! multi-socket server does constantly -- is entirely unmeasured, and no amount +//! of work on a one-node machine will change that. +//! +//! The two hosts measured to date also turn out to express **disjoint** sets of +//! placements: neither can produce a single row the other can. So this is not a +//! matter of collecting more of the same, and a result from a machine unlike +//! either is worth more than a hundred repetitions here. +//! +//! # What it collects, and what it does not +//! +//! **Collected:** the shape of the machine (logical processors, cores, cache +//! domains, efficiency classes, NUMA nodes), the CPU model, the OS build, a +//! hint about whether virtualisation was detected, and the timings this tool +//! measures. +//! +//! **Not collected:** host name, user name, file paths, environment variables, +//! serial numbers, or anything about installed software. That list is a +//! commitment rather than a description of the current implementation. +//! +//! **The tool makes no network connections.** It writes a file; sending it is +//! your decision and your action. The record is text, so you can read it before +//! deciding -- and if you would rather not share the CPU model, there is a +//! switch for that. +//! +//! **If the hardware is confidential, do not send the record.** The model name +//! can be suppressed, but the topology *is* the measurement, and an unreleased +//! part is identified by its shape at least as well as by its name. No switch +//! fixes that, and pretending otherwise would be worse than saying so. +//! +//! # An instrument, not a library +//! +//! This crate exists to produce measurements, not to be built on. It is not a +//! placement policy, it does not choose where your threads should run, and +//! nothing here is tuned for use in a running system. + +#![cfg(windows)] From e1904c244e8a9a6df14c862d643ffd243a1da7e7 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 14:45:57 -0400 Subject: [PATCH 063/361] refactor(placement-probe): move the placement measurement into its own crate fingerprint, core_affinity and peer_index_cache move to windows-placement-probe, and windows-platform-probes depends on it instead. The direction is deliberately inverted from where the code started: the crate meant to be shared owns the measurement, and the internal grab-bag borrows it, so there is one implementation and one rendering rather than two that can drift. The probe binaries stay in windows-platform-probes and stay thin, per PT-1.3. They are entry points now and nothing else -- the development loop wants to run one measurement in isolation, while a stranger doing a favour wants one binary and one record, and those are different needs served by the same library. A pure relocation: git recorded all five files as 100% renames, so --follow and blame carry through and no provenance headers are needed. Those are required for a split, where the source file survives the copy; here it does not. Verified beyond compiling, because compiling only proves the names resolved. Test counts add up exactly (58 new + 25 old = the 83 that existed before), and probe-core-affinity reproduces its pre-move numbers: siblings 1.8x WINS at batch depth ~85, cross-cache 0.54x LOSES at ~1.9. Fixes one pre-existing defect the move exposed. queue_contention still imported windows_waitable_queues::mpsc, stale since the slotwise_mpsc rename; nothing had rebuilt that crate since, so it had gone unnoticed, and adding the dependency is what forced the rebuild. Completed item: PT-2.1: Move fingerprint, core_affinity and peer_index_cache into the new crate, and make windows-platform-probes depend on it. Completed item: PT-2.2: Keep queue_contention and every unrelated probe where they are. Completed item: PT-2.3: Verify the move changed no behaviour. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 17 ++++++++++++++--- Cargo.lock | 1 + .../src/core_affinity.rs | 0 .../src/core_affinity/tests.rs | 0 .../src/fingerprint.rs | 0 .../src/fingerprint/tests.rs | 0 crates/windows-placement-probe/src/lib.rs | 7 +++++++ .../src/peer_index_cache.rs | 0 crates/windows-platform-probes/Cargo.toml | 5 +++++ .../src/bin/core_affinity.rs | 10 +++++----- .../src/bin/peer_index_cache.rs | 4 ++-- .../src/bin/queue_contention.rs | 2 +- crates/windows-platform-probes/src/lib.rs | 3 --- .../src/queue_contention.rs | 13 +++++++------ 14 files changed, 42 insertions(+), 20 deletions(-) rename crates/{windows-platform-probes => windows-placement-probe}/src/core_affinity.rs (100%) rename crates/{windows-platform-probes => windows-placement-probe}/src/core_affinity/tests.rs (100%) rename crates/{windows-platform-probes => windows-placement-probe}/src/fingerprint.rs (100%) rename crates/{windows-platform-probes => windows-placement-probe}/src/fingerprint/tests.rs (100%) rename crates/{windows-platform-probes => windows-placement-probe}/src/peer_index_cache.rs (100%) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 88017744..6cc2ff43 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -160,21 +160,32 @@ the entire point of this tool -- has more than 64 logical processors, so Windows ## M2: the move -- [ ] **PT-2.1** -- Move `fingerprint`, `core_affinity` and `peer_index_cache` into the new crate, and +- [x] **PT-2.1** -- Move `fingerprint`, `core_affinity` and `peer_index_cache` into the new crate, and make `windows-platform-probes` depend on it. This inverts today's direction deliberately: the published crate owns the measurement, the internal grab-bag borrows it. A **pure relocation** with the provenance trail the repository requires for a split -- commit trailers and per-file headers -- because these modules carry a session's worth of hard-won reasoning in their comments and blame must survive. -- [ ] **PT-2.2** -- Keep `queue_contention` and every unrelated probe where they are. The new crate is +- [x] **PT-2.2** -- Keep `queue_contention` and every unrelated probe where they are. The new crate is not a home for "measurement code in general"; it is one tool with one question, and admitting a second unrelated probe is how it becomes the grab-bag it was extracted from. -- [ ] **PT-2.3** -- Verify the move changed no behaviour: the three probe binaries (or their +- [x] **PT-2.3** -- Verify the move changed no behaviour: the three probe binaries (or their replacements per PT-1.3) produce the same numbers on this host as recorded in [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, and the full sabotage set still fails where it should. + **Verified three ways.** Git recorded all five files as **100% renames**, so this was a pure + relocation and `--follow` and blame both carry through without the provenance headers a *split* + would need -- a split copies and leaves the source behind, a move does not. Test counts add up + exactly: 58 in the new crate plus 25 in the old is the 83 that existed before. And + `probe-core-affinity` reproduces its pre-move results (siblings 1.8x WINS at batch depth ~85, + cross-cache 0.54x LOSES at ~1.9), which is the check that matters, because compiling proves the + names resolved and nothing more. + **One defect surfaced, unrelated to the move and pre-existing:** `queue_contention` still imported + `windows_waitable_queues::mpsc`, stale since the `slotwise_mpsc` rename. It went unnoticed because + nothing had rebuilt that crate since, and the move is what forced the rebuild. Fixed here rather + than left for the release. ## M3: the submission record diff --git a/Cargo.lock b/Cargo.lock index bc9e5cd8..38a2063d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,6 +238,7 @@ name = "windows-platform-probes" version = "0.0.0" dependencies = [ "windows-namespace-request-sys", + "windows-placement-probe", "windows-sys", "windows-threadpool-sys", "windows-topology-sys", diff --git a/crates/windows-platform-probes/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs similarity index 100% rename from crates/windows-platform-probes/src/core_affinity.rs rename to crates/windows-placement-probe/src/core_affinity.rs diff --git a/crates/windows-platform-probes/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs similarity index 100% rename from crates/windows-platform-probes/src/core_affinity/tests.rs rename to crates/windows-placement-probe/src/core_affinity/tests.rs diff --git a/crates/windows-platform-probes/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs similarity index 100% rename from crates/windows-platform-probes/src/fingerprint.rs rename to crates/windows-placement-probe/src/fingerprint.rs diff --git a/crates/windows-platform-probes/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs similarity index 100% rename from crates/windows-platform-probes/src/fingerprint/tests.rs rename to crates/windows-placement-probe/src/fingerprint/tests.rs diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index ca87eef8..0551e846 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -50,3 +50,10 @@ //! nothing here is tuned for use in a running system. #![cfg(windows)] + +/// What a handoff costs, by where the two threads run. +pub mod core_affinity; +/// What shape the machine is, and which slice a measurement ran on. +pub mod fingerprint; +/// The handoff itself, and the strategies the placement experiment compares. +pub mod peer_index_cache; diff --git a/crates/windows-platform-probes/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs similarity index 100% rename from crates/windows-platform-probes/src/peer_index_cache.rs rename to crates/windows-placement-probe/src/peer_index_cache.rs diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index f4d2b21d..db17613e 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -80,6 +80,11 @@ windows-threadpool-sys = { version = "0.1.3", path = "../windows-threadpool-sys" # a second parse written here, which would only measure itself. The raw Win32 # counters it cross-checks against are read independently through windows-sys. windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys" } +# The placement measurement moved out to its own crate so it could be shared +# with people running it on hardware this workspace does not own. The probes +# here call into it rather than keeping a second copy: two renderings of one +# measurement disagreeing is a defect this investigation has already hit. +windows-placement-probe = { version = "0.1.0", path = "../windows-placement-probe" } # The request-cost probe measures the real request types the design would put on # a queue, not a stand-in, for the same reason. windows-namespace-request-sys = { version = "0.2.0", path = "../windows-namespace-request-sys" } diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index 10483e15..5ef3e767 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -2,11 +2,11 @@ //! Prints whether it matters where the two ends of a queue run. -use windows_platform_probes::core_affinity::{Observation, Placement, measure}; -use windows_platform_probes::peer_index_cache::Strategy; +use windows_placement_probe::core_affinity::{Observation, Placement, measure}; +use windows_placement_probe::peer_index_cache::Strategy; fn main() -> std::io::Result<()> { - windows_platform_probes::fingerprint::print_banner(); + windows_placement_probe::fingerprint::print_banner(); println!("== does it matter where the two ends of a queue run? ==\n"); let observation = measure()?; @@ -220,11 +220,11 @@ interpretation: if let (Some(same), Some(cross)) = ( mean( &same_class, - |m: &windows_platform_probes::core_affinity::Measurement| m.consumer_batch, + |m: &windows_placement_probe::core_affinity::Measurement| m.consumer_batch, ), mean( &cross_class, - |m: &windows_platform_probes::core_affinity::Measurement| m.consumer_batch, + |m: &windows_placement_probe::core_affinity::Measurement| m.consumer_batch, ), ) { let within = if confounded { diff --git a/crates/windows-platform-probes/src/bin/peer_index_cache.rs b/crates/windows-platform-probes/src/bin/peer_index_cache.rs index 43c161e8..eb27b6b7 100644 --- a/crates/windows-platform-probes/src/bin/peer_index_cache.rs +++ b/crates/windows-platform-probes/src/bin/peer_index_cache.rs @@ -6,10 +6,10 @@ //! and are not for production use. Do not call them from production code, and //! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. -use windows_platform_probes::peer_index_cache::{CAPACITY, ITEMS, Strategy, measure}; +use windows_placement_probe::peer_index_cache::{CAPACITY, ITEMS, Strategy, measure}; fn main() { - windows_platform_probes::fingerprint::print_banner(); + windows_placement_probe::fingerprint::print_banner(); println!("== what does caching the peer's index buy an SPSC ring? ==\n"); let observation = measure(); diff --git a/crates/windows-platform-probes/src/bin/queue_contention.rs b/crates/windows-platform-probes/src/bin/queue_contention.rs index 272a4782..d770d3a0 100644 --- a/crates/windows-platform-probes/src/bin/queue_contention.rs +++ b/crates/windows-platform-probes/src/bin/queue_contention.rs @@ -13,7 +13,7 @@ use windows_platform_probes::queue_contention::{PRODUCER_COUNTS, Run, measure}; fn main() { - windows_platform_probes::fingerprint::print_banner(); + windows_placement_probe::fingerprint::print_banner(); println!("== does the array queue's tail claim contend? ==\n"); let observation = measure(); diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index 309bd90c..1470d76e 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -111,14 +111,11 @@ pub mod cancel_io; pub mod completion_port; -pub mod core_affinity; pub mod device_map; pub mod doorbell_cost; pub mod error_mode; -pub mod fingerprint; pub mod handle_state; pub mod ioring; -pub mod peer_index_cache; pub mod pool_growth; pub mod queue_contention; pub mod request_cost; diff --git a/crates/windows-platform-probes/src/queue_contention.rs b/crates/windows-platform-probes/src/queue_contention.rs index 25468c51..1420d865 100644 --- a/crates/windows-platform-probes/src/queue_contention.rs +++ b/crates/windows-platform-probes/src/queue_contention.rs @@ -15,7 +15,7 @@ //! only MPSC the queue crate ever needs, and two speculative shapes never get //! written. //! -//! **2. Should `mpsc` and `reserving_mpsc` merge?** They ship as peers because +//! **2. Should `slotwise_mpsc` and `reserving_mpsc` merge?** They ship as peers because //! honouring a reservation costs the producer a read of the consumer's //! position -- one line every thread touches -- and *how much* that costs was a //! judgement rather than a measurement. If it is cheap, the two shapes merge and @@ -51,7 +51,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::thread; use std::time::Instant; -use windows_waitable_queues::{Options, mpsc, reserving_mpsc}; +use windows_waitable_queues::{Options, reserving_mpsc, slotwise_mpsc}; /// How many pushes each producer thread performs in one timed run. const PUSHES_PER_PRODUCER: usize = 50_000; @@ -133,14 +133,14 @@ pub fn measure() -> Observation { isolated.push(median_run("baseline_fetch_add", producers, |count| { time_contended_atomic(count) })); - isolated.push(median_run("mpsc", producers, |count| { + isolated.push(median_run("slotwise_mpsc", producers, |count| { time_isolated_mpsc(count) })); isolated.push(median_run("reserving_mpsc", producers, |count| { time_isolated_reserving(count) })); - drained.push(median_run("mpsc", producers, |count| { + drained.push(median_run("slotwise_mpsc", producers, |count| { time_drained_mpsc(count) })); drained.push(median_run("reserving_mpsc", producers, |count| { @@ -214,7 +214,8 @@ fn capacity_for(producers: usize) -> usize { } fn time_isolated_mpsc(producers: usize) -> Repetition { - let (tx, rx) = mpsc::bounded::(capacity_for(producers)).expect("a valid capacity"); + let (tx, rx) = + slotwise_mpsc::bounded::(capacity_for(producers)).expect("a valid capacity"); let started = Instant::now(); thread::scope(|scope| { for producer in 0..producers { @@ -261,7 +262,7 @@ fn time_isolated_reserving(producers: usize) -> Repetition { const DRAINED_CAPACITY: usize = 1024; fn time_drained_mpsc(producers: usize) -> Repetition { - let (tx, rx) = mpsc::bounded::(DRAINED_CAPACITY).expect("a valid capacity"); + let (tx, rx) = slotwise_mpsc::bounded::(DRAINED_CAPACITY).expect("a valid capacity"); let done = Arc::new(AtomicBool::new(false)); let consumer_done = Arc::clone(&done); From b1c6c6d1f6fd3cc6f9fb9674c87552560e1ebb62 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 15:02:04 -0400 Subject: [PATCH 064/361] feat(placement-probe)!: identify a processor by (group, number), and pin by group A processor was keyed by a bare number, and the topology conversion discarded the group outright. On a machine with more than 64 logical processors Windows presents multiple groups, each numbering from zero, so every group has a processor 5 and the maps collapsed them. The failure was silent. Numbers stay below 64 within a group, so no bound check fired: the tool would run, pin to whichever processor won the collision, and print a confident table describing a topology that does not exist. That is the same defect class as the omitted SMT row, waiting on the large multi-socket hosts this tool is written for. ProcessorPlace now carries the group, every map in places_from_topology is keyed on the full identity, and the fallback core id no longer derives from the number alone. Display renders the group always, including group 0 on a single-group machine: printing it only when non-zero would keep old output unchanged and still distinguish groups, but a bare cpu5 cannot tell a reader whether the group was considered and was zero, or never consulted at all. Pinning moves to SetThreadGroupAffinity. SetThreadAffinityMask interprets its mask within the caller's current group, so it cannot name a processor in another one at all -- this is not a matter of widening the mask. The tests found two defects rather than confirming the change. classify compared core without comparing group, so processors in different groups with colliding core ids were reported as SMT siblings, attributing a shared L1 that cannot exist because a core cannot span a group. The planned sabotage could not be performed, which is the strongest available result: keying the conversion on the number alone no longer compiles, because the maps are keyed (u16, u8). The collapse is unrepresentable rather than merely tested against. The sabotage that does compile -- dropping the group comparison in classify -- was performed and is caught. One test asserted a promise the code never made, and was rewritten rather than the code bent to fit it: representative_pairs returns one pair per placement category, and "in a different group" is not a category. Its fixture also gave two groups the same cache-domain ids, describing a cache shared across a group boundary, which no machine does. Adds assert_group_support as the declared place a future limitation stops the run. A tool that quietly measures the subset it understands is worse than one that stops, because its output is indistinguishable from a complete run, and these machines are borrowed and measured once. Verified on this single-group host: probe-core-affinity reproduces its previous numbers (siblings 1.81x WINS, cross-cache 0.49x LOSES) with slices now reading g0/cpu0/..., so the new pinning path works where it can be observed. Completed item: PT-1B.1: Carry (group, number) as a processor's identity. Completed item: PT-1B.2: Pin with SetThreadGroupAffinity. Completed item: PT-1B.3: Verify against a synthetic multi-group topology. Completed item: PT-1B.4: Refuse loudly if groups are present and unsupported. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 23 ++- .../src/core_affinity.rs | 50 ++++++- .../src/core_affinity/tests.rs | 141 ++++++++++++++++++ .../src/fingerprint.rs | 82 +++++++--- .../src/fingerprint/tests.rs | 127 ++++++++++++++++ .../src/peer_index_cache.rs | 46 ++++-- 6 files changed, 430 insertions(+), 39 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 6cc2ff43..85733688 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -135,7 +135,7 @@ heading is about the *machine*, not about M2. the entire point of this tool -- has more than 64 logical processors, so Windows presents it as **multiple processor groups**, each numbering from zero. -- [ ] **PT-1B.1** -- **Carry `(group, number)` as a processor's identity.** `ProcessorPlace` keys on a +- [x] **PT-1B.1** -- **Carry `(group, number)` as a processor's identity.** `ProcessorPlace` keys on a bare `u8` number and `places_from_topology` discards the group outright (`for (_group, number)`), so every group's processor 5 collides on one map key. **The result is not a crash.** Numbers stay below 64 within a group, so `assert!(cpu < 64)` never fires: the tool runs, pins to whichever processor @@ -143,17 +143,30 @@ the entire point of this tool -- has more than 64 logical processors, so Windows collapsed. That is the same defect class as the omitted SMT row, on the machine we would get one attempt at. -- [ ] **PT-1B.2** -- **Pin with `SetThreadGroupAffinity`.** `SetThreadAffinityMask` takes a mask +- [x] **PT-1B.2** -- **Pin with `SetThreadGroupAffinity`.** `SetThreadAffinityMask` takes a mask within the caller's current group and cannot express a processor in another one, so it is not a matter of widening the mask. Keep the existing failure discipline: pinning that does not land must abort the run rather than fall back to an unpinned measurement. -- [ ] **PT-1B.3** -- **Verify against a synthetic multi-group topology**, since no host here has more +- [x] **PT-1B.3** -- **Verify against a synthetic multi-group topology**, since no host here has more than one group. `places_from_topology` is a pure conversion and already testable; a fixture with two groups whose numbers overlap must produce distinct processors, and the sabotage is to key on the number alone and watch the count halve. - -- [ ] **PT-1B.4** -- **Refuse loudly if groups are present and unsupported.** Whatever remains + **Nine tests added, and they found two real defects rather than confirming the change.** `classify` + compared `core` without comparing `group`, so two processors in different groups whose core ids + collided were reported as **SMT siblings** -- attributing a shared L1 that cannot exist, since a core + cannot span a group. And the fallback core id was derived from the number alone, which is what made + those collisions possible. + **The planned sabotage could not be performed, which is the strongest available result.** Keying the + conversion on the number alone no longer *compiles*: the maps are keyed `(u16, u8)`, so the collapse + is unrepresentable rather than merely tested against. The sabotage that does compile -- dropping the + group comparison from `classify` -- was performed and is caught. + **One test asserted a promise the code never made** and was rewritten rather than the code bent to + fit: `representative_pairs` returns one pair per placement *category*, and "in a different group" is + not a category, so requiring a pair from every group was wrong. Its fixture also gave both groups the + same cache-domain ids, describing a cache shared across groups, which no machine does. + +- [x] **PT-1B.4** -- **Refuse loudly if groups are present and unsupported.** Whatever remains unimplemented when a large machine is offered, the tool must say so and stop. A refusal costs one message; a collapsed topology costs a wrong answer nobody can detect from the output, on hardware that is not coming back. diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index b5a8bbf7..0f9f216c 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -199,7 +199,12 @@ pub struct Observation { pub fn classify(producer: ProcessorPlace, consumer: ProcessorPlace) -> Placement { // Tested first: two processors on one core share L1, which dominates any // statement about the cache domain or the class they also share. - if producer.core == consumer.core { + // + // The group must match as well. A physical core cannot span a processor + // group, so two processors reporting the same core id in different groups + // are different cores whose ids collide -- and calling them SMT siblings + // would attribute a shared L1 that does not exist. + if producer.group == consumer.group && producer.core == consumer.core { return Placement::SameCoreSiblings; } // Tested before cache and class for the mirror-image reason: crossing a @@ -247,6 +252,42 @@ pub fn representative_pairs( chosen } +/// Stop the run if this machine presents processor groups the tool cannot +/// honestly handle. +/// +/// Currently a no-op beyond the check itself, because groups *are* handled -- +/// every identity is a `(group, number)` pair and pinning goes through +/// `SetThreadGroupAffinity`. It exists as the place a future limitation is +/// declared, and it is deliberately loud rather than silent. +/// +/// # Why a refusal rather than a best effort +/// +/// A tool that quietly measures whatever subset it understands is worse than +/// one that stops, because its output is indistinguishable from a complete run. +/// The large multi-socket machines this exists for are borrowed, measured once, +/// and not available again: a wrong answer there is not a wrong answer we get to +/// correct. A refusal costs one message. +/// +/// # Panics +/// +/// If the discovered processors cannot be measured as they are. +fn assert_group_support(processors: &[ProcessorPlace]) { + assert!( + !processors.is_empty(), + "no processors were discovered, so there is nothing to measure" + ); + // Every discovered processor must be pinnable. A number at or above the + // width of an affinity mask cannot be expressed in one, and measuring the + // rest while dropping it would report a machine smaller than the real one. + for place in processors { + assert!( + u32::from(place.number) < usize::BITS, + "processor {place} has a number no affinity mask can express; \ + this machine cannot be measured honestly and the run is stopping" + ); + } +} + /// Choose one representative processor pair for each *distinct pair of NUMA /// nodes*. /// @@ -312,13 +353,14 @@ pub fn node_pairs( /// Returns whatever [`discover_places`] failed with. pub fn measure() -> std::io::Result { let processors = discover_places()?; + assert_group_support(&processors); let pairs = representative_pairs(&processors); let mut measurements = Vec::new(); for (placement, (producer, consumer)) in pairs { for strategy in [Strategy::Baseline, Strategy::Cached] { let mut samples: Vec<_> = (0..REPETITIONS) - .map(|_| time_model_on(strategy, Some(producer.number), Some(consumer.number))) + .map(|_| time_model_on(strategy, Some(producer.id()), Some(consumer.id()))) .collect(); samples.sort_by(|a, b| a.nanos.total_cmp(&b.nanos)); let median = samples[samples.len() / 2]; @@ -361,7 +403,7 @@ pub fn measure() -> std::io::Result { }; for strategy in [Strategy::Baseline, Strategy::Cached] { let mut samples: Vec<_> = (0..REPETITIONS) - .map(|_| time_model_on(strategy, Some(producer.number), Some(consumer.number))) + .map(|_| time_model_on(strategy, Some(producer.id()), Some(consumer.id()))) .collect(); samples.sort_by(|a, b| a.nanos.total_cmp(&b.nanos)); let median = samples[samples.len() / 2]; @@ -383,7 +425,7 @@ pub fn measure() -> std::io::Result { debug_assert_eq!((producer.numa_node, consumer.numa_node), (left, right)); for strategy in [Strategy::Baseline, Strategy::Cached] { let mut samples: Vec<_> = (0..REPETITIONS) - .map(|_| time_model_on(strategy, Some(producer.number), Some(consumer.number))) + .map(|_| time_model_on(strategy, Some(producer.id()), Some(consumer.id()))) .collect(); samples.sort_by(|a, b| a.nanos.total_cmp(&b.nanos)); let median = samples[samples.len() / 2]; diff --git a/crates/windows-placement-probe/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs index 4c1571e9..151239f3 100644 --- a/crates/windows-placement-probe/src/core_affinity/tests.rs +++ b/crates/windows-placement-probe/src/core_affinity/tests.rs @@ -16,6 +16,7 @@ use crate::fingerprint::ProcessorPlace; /// one onto another NUMA node. fn place(number: u8, efficiency_class: u8, cache_domain: Option) -> ProcessorPlace { ProcessorPlace { + group: 0, number, core: u32::from(number), efficiency_class, @@ -29,6 +30,15 @@ fn on_node(place: ProcessorPlace, numa_node: u32) -> ProcessorPlace { ProcessorPlace { numa_node, ..place } } +/// The same processor, relocated to another processor group. +/// +/// Its `number` is deliberately unchanged, because that is the configuration +/// that breaks a number-keyed implementation: two distinct processors sharing +/// one number across groups. +fn in_group(place: ProcessorPlace, group: u16) -> ProcessorPlace { + ProcessorPlace { group, ..place } +} + /// Two processors sharing one physical core: SMT siblings. fn sibling( number: u8, @@ -37,6 +47,7 @@ fn sibling( cache_domain: Option, ) -> ProcessorPlace { ProcessorPlace { + group: 0, number, core, efficiency_class, @@ -372,6 +383,7 @@ fn synthesize(spec: &HostSpec) -> Vec { for _ in 0..spec.cores_per_cache_domain { for _ in 0..spec.threads_per_core { places.push(ProcessorPlace { + group: 0, number, core, efficiency_class: 0, @@ -717,3 +729,132 @@ fn a_node_pair_is_still_selected_when_the_nodes_are_not_numbered_from_zero() { assert!(pairs.contains_key(&(2, 5))); assert_node_pairs_are_faithful(&places); } + +// --------------------------------------------------------------------------- +// Processor groups. +// +// Windows splits a machine with more than 64 logical processors into groups, +// each numbering from zero, so every group has a processor 5. No host available +// to this workspace has more than one group, and the machines this tool is +// written for -- large multi-socket servers -- all do. These fixtures are the +// only way that path executes before it meets such a machine. +// +// The failure being guarded against is silent: numbers stay below 64 within a +// group, so no bound check fires. A number-keyed implementation simply reports +// fewer processors than the machine has and prints a confident table describing +// a topology that does not exist. +// --------------------------------------------------------------------------- + +mod processor_groups { + use super::{classify, in_group, node_pairs, place, representative_pairs, sibling}; + use crate::core_affinity::Placement; + use crate::fingerprint::ProcessorPlace; + + /// Two groups of four processors whose numbers deliberately overlap. + /// + /// Group 0 and group 1 both contain numbers 0..4. A map keyed on the number + /// alone keeps four of the eight. + /// + /// Cache domain ids are distinct per group, matching the real conversion: + /// they come from a machine-wide enumeration, so two groups never share + /// one. An earlier version of this fixture reused them across groups and + /// thereby described a cache shared between processor groups, which no + /// machine does. + fn two_groups() -> Vec { + let mut places = Vec::new(); + for group in 0..2_u16 { + for number in 0..4_u8 { + let domain = u32::from(group) * 2 + u32::from(number) / 2; + let base = place(number, 0, Some(domain)); + places.push(in_group(base, group)); + } + } + places + } + + #[test] + fn processors_sharing_a_number_across_groups_are_distinct() { + let places = two_groups(); + + let ids: std::collections::BTreeSet<(u16, u8)> = places.iter().map(|p| p.id()).collect(); + + assert_eq!( + ids.len(), + 8, + "two groups of four collapsed to {} distinct processors", + ids.len() + ); + } + + #[test] + fn a_group_is_part_of_the_rendered_identity() { + // The slice string is how a measurement's provenance travels into a + // checklist or a submitted record. If it omits the group, two different + // processors render identically and the record cannot be read back. + let zero = place(5, 0, Some(0)); + let one = in_group(zero, 1); + + assert_ne!(zero.to_string(), one.to_string()); + assert!(zero.to_string().starts_with("g0/cpu5/"), "{zero}"); + assert!(one.to_string().starts_with("g1/cpu5/"), "{one}"); + } + + #[test] + fn same_number_in_different_groups_is_not_the_same_core() { + // `core` is what `SameCoreSiblings` is decided on, so if the fallback + // core id were derived from the number alone, two processors in + // different groups would be classified as SMT siblings -- physically + // impossible, since a core cannot span a group. + let zero = sibling(5, 5, 0, Some(0)); + let one = in_group(zero, 1); + + assert_ne!( + classify(zero, one), + Placement::SameCoreSiblings, + "processors in different groups were classified as siblings of one core" + ); + } + + #[test] + fn selection_across_groups_files_every_pair_under_a_placement_it_satisfies() { + // Note what is *not* asserted: that a pair is drawn from every group. + // `representative_pairs` returns one pair per placement *category*, and + // "in a different group" is not one -- a cross-group pair on one node + // is an ordinary cross-cache pair. Requiring group coverage would be + // asserting a promise the function does not make, and an earlier + // revision of this test did exactly that. + // + // What must hold is that groups do not corrupt the classification: each + // chosen pair genuinely satisfies the row it is filed under. + let places = two_groups(); + + for (placement, (producer, consumer)) in representative_pairs(&places) { + assert_eq!( + classify(producer, consumer), + placement, + "pair {producer} / {consumer} filed under {}", + placement.label() + ); + if placement == Placement::SameCoreSiblings { + assert_eq!( + producer.group, consumer.group, + "siblings were selected across a group boundary" + ); + } + } + } + + #[test] + fn groups_do_not_by_themselves_imply_a_numa_crossing() { + // A group boundary and a node boundary are different things, and + // Windows may split a single node across groups. Classifying by group + // would invent node crossings the machine does not have. + let places = two_groups(); + let hops = node_pairs(&places); + + assert!( + hops.is_empty(), + "a single-node machine with two groups reported a node crossing: {hops:?}" + ); + } +} diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index e1798300..2856d903 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -70,7 +70,19 @@ use windows_topology_sys::{DomainKind, Provenance, Topology}; /// and the placement classifier interprets it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct ProcessorPlace { - /// Its number within the (single) processor group. + /// Which processor group it belongs to. + /// + /// **A processor is identified by `(group, number)`, never by `number` + /// alone.** Windows splits a machine with more than 64 logical processors + /// into groups, each numbering from zero, so every group has a processor 5. + /// Keying on the number alone silently collapses them -- and it fails + /// quietly rather than loudly, because numbers stay below 64 within a group, + /// so no bound check fires. The run then pins to whichever processor won the + /// collision and prints a confident table describing a topology that does + /// not exist. That is the exact hazard on the large multi-socket hosts this + /// tool is written for. + pub group: u16, + /// Its number within [`Self::group`]. pub number: u8, /// Which physical core it belongs to. /// @@ -96,12 +108,33 @@ pub struct ProcessorPlace { pub numa_node: u32, } +impl ProcessorPlace { + /// This processor's full identity, as the pinning call needs it. + /// + /// Exists so no call site is tempted to pass a bare `number`, which is the + /// whole defect: a number without its group names a different processor in + /// every group, and names the wrong one in all but the first. + #[must_use] + pub fn id(self) -> (u16, u8) { + (self.group, self.number) + } +} + impl fmt::Display for ProcessorPlace { + /// Renders the group **always**, including group 0 on a machine that has + /// only one. + /// + /// Printing it only when non-zero would keep single-group output unchanged + /// and still distinguish the groups on a large host, so it is tempting. It + /// is refused because the failure this guards against is precisely a tool + /// that *silently collapsed* the groups: a bare `cpu5` cannot tell a reader + /// whether the group was considered and was zero, or never consulted at all. + /// Group-awareness is cheap to show and expensive to assume. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "cpu{}/core{}/ec{}", - self.number, self.core, self.efficiency_class + "g{}/cpu{}/core{}/ec{}", + self.group, self.number, self.core, self.efficiency_class )?; match self.cache_domain { Some(id) => write!(f, "/cd{id}")?, @@ -414,11 +447,16 @@ pub fn discover_places() -> std::io::Result> { /// broken lookup and a correct one both yield node 0. #[must_use] pub fn places_from_topology(topology: &Topology) -> Vec { + // Every map here is keyed by the full `(group, number)` pair. Keying on the + // number alone is the defect this function is written against: on a machine + // with more than 64 logical processors each group numbers from zero, so + // group 1's processor 5 would overwrite group 0's and the machine would + // silently shrink to one group's worth of processors. let mut class_of = std::collections::BTreeMap::new(); let mut core_of = std::collections::BTreeMap::new(); for core in topology.cores() { - for (_group, number) in core.processors.iter() { - core_of.insert(number, core.id); + for id in core.processors.iter() { + core_of.insert(id, core.id); } let DomainKind::Core { efficiency_class, .. @@ -426,8 +464,8 @@ pub fn places_from_topology(topology: &Topology) -> Vec { else { continue; }; - for (_group, number) in core.processors.iter() { - class_of.insert(number, efficiency_class); + for id in core.processors.iter() { + class_of.insert(id, efficiency_class); } } @@ -439,8 +477,8 @@ pub fn places_from_topology(topology: &Topology) -> Vec { if domains.len() > 1 { cache_of.clear(); for domain in domains { - for (_group, number) in domain.processors.iter() { - cache_of.insert(number, domain.id); + for id in domain.processors.iter() { + cache_of.insert(id, domain.id); } } } @@ -450,19 +488,29 @@ pub fn places_from_topology(topology: &Topology) -> Vec { // NUMA partitioning has exactly one node, and every processor is in it. let mut numa_of = std::collections::BTreeMap::new(); for domain in topology.memory_domains() { - for (_group, number) in domain.processors.iter() { - numa_of.insert(number, domain.id); + for id in domain.processors.iter() { + numa_of.insert(id, domain.id); } } class_of .into_iter() - .map(|(number, efficiency_class)| ProcessorPlace { - number, - core: core_of.get(&number).copied().unwrap_or(u32::from(number)), - efficiency_class, - cache_domain: cache_of.get(&number).copied(), - numa_node: numa_of.get(&number).copied().unwrap_or(0), + .map(|(id, efficiency_class)| { + let (group, number) = id; + ProcessorPlace { + group, + number, + // The fallback keeps distinct processors distinct across groups: + // a topology that reports no core for this processor must not + // collapse group 1's cpu5 onto group 0's. + core: core_of + .get(&id) + .copied() + .unwrap_or_else(|| u32::from(group) << 8 | u32::from(number)), + efficiency_class, + cache_domain: cache_of.get(&id).copied(), + numa_node: numa_of.get(&id).copied().unwrap_or(0), + } }) .collect() } diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index 7cf68eb9..b029b249 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -573,3 +573,130 @@ mod from_topology { assert!(hops.contains_key(&(0, 1))); } } + +// --------------------------------------------------------------------------- +// The conversion, on a machine with more than one processor group. +// +// The tests above cover classification and selection once places exist. This +// covers the step that builds them, which is where the group was previously +// discarded outright. +// --------------------------------------------------------------------------- + +mod multi_group_conversion { + use windows_topology_sys::{ + Domain, DomainKind, Processor, ProcessorId, ProcessorSet, Topology, + }; + + use crate::fingerprint::places_from_topology; + + /// One processor per core, four cores per group, two groups -- with the + /// numbers overlapping, which is how Windows really presents it. + fn two_group_topology() -> Topology { + let mut processors = Vec::new(); + let mut domains = Vec::new(); + let mut core_id = 0_u32; + + for group in 0..2_u16 { + let mut members = Vec::new(); + for number in 0..4_u8 { + processors.push(Processor { + id: ProcessorId { group, number }, + online: true, + capacity: 0, + }); + members.push(number); + + domains.push(Domain { + kind: DomainKind::Core { + simultaneous_multithreading: false, + efficiency_class: 0, + }, + id: core_id, + processors: ProcessorSet::from_group_mask(group, 1_usize << number), + }); + core_id += 1; + } + + let mask = members.iter().fold(0_usize, |mask, n| mask | (1 << n)); + domains.push(Domain { + kind: DomainKind::Group, + id: u32::from(group), + processors: ProcessorSet::from_group_mask(group, mask), + }); + // A cache domain per group, because a cache is never shared across + // one, and a memory domain per group so this stays a two-node + // machine rather than accidentally testing NUMA as well. + domains.push(Domain { + kind: DomainKind::Cache { + level: 3, + associativity: 16, + line_size: 64, + size_bytes: 32 * 1024 * 1024, + cache_type: windows_topology_sys::CacheKind::Unified, + }, + id: 100 + u32::from(group), + processors: ProcessorSet::from_group_mask(group, mask), + }); + domains.push(Domain { + kind: DomainKind::Memory { memory_bytes: None }, + id: u32::from(group), + processors: ProcessorSet::from_group_mask(group, mask), + }); + } + + Topology { + processors, + domains, + distances: None, + ..Default::default() + } + } + + #[test] + fn every_processor_of_every_group_survives_the_conversion() { + // The regression that matters. Keying the conversion's maps on the + // processor number alone silently produced four places for an + // eight-processor machine, and nothing in the output said so. + let places = places_from_topology(&two_group_topology()); + + assert_eq!( + places.len(), + 8, + "an eight-processor two-group machine converted to {} places", + places.len() + ); + + let ids: std::collections::BTreeSet<(u16, u8)> = places.iter().map(|p| p.id()).collect(); + assert_eq!(ids.len(), 8, "two places collided on one identity"); + assert_eq!(places.iter().filter(|p| p.group == 0).count(), 4); + assert_eq!(places.iter().filter(|p| p.group == 1).count(), 4); + } + + #[test] + fn a_cores_identity_does_not_collide_across_groups() { + let places = places_from_topology(&two_group_topology()); + + let cores: std::collections::BTreeSet<(u16, u32)> = + places.iter().map(|p| (p.group, p.core)).collect(); + + assert_eq!(cores.len(), 8, "eight single-threaded cores collapsed"); + } + + #[test] + fn per_group_cache_and_node_membership_is_read_correctly() { + let places = places_from_topology(&two_group_topology()); + + for place in &places { + assert_eq!( + place.numa_node, + u32::from(place.group), + "{place} was read onto the wrong node" + ); + assert_eq!( + place.cache_domain, + Some(100 + u32::from(place.group)), + "{place} was read into the wrong cache domain" + ); + } + } +} diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index 5c9cc6d9..e4e10c07 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -55,7 +55,10 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use std::time::Instant; -use windows_sys::Win32::System::Threading::{GetCurrentThread, SetThreadAffinityMask}; +use core::ptr; + +use windows_sys::Win32::System::SystemInformation::GROUP_AFFINITY; +use windows_sys::Win32::System::Threading::{GetCurrentThread, SetThreadGroupAffinity}; use windows_waitable_queues::spsc; /// Items handed across the ring in one timed run. @@ -267,8 +270,8 @@ fn time_model(strategy: Strategy) -> Sample { /// processor: an unconstrained thread can migrate mid-run. pub fn time_model_on( strategy: Strategy, - producer_cpu: Option, - consumer_cpu: Option, + producer_cpu: Option<(u16, u8)>, + consumer_cpu: Option<(u16, u8)>, ) -> Sample { let ring = Ring::new(CAPACITY); let started = Instant::now(); @@ -290,24 +293,41 @@ pub fn time_model_on( } } -/// Confine the calling thread to one logical processor. +/// Confine the calling thread to one logical processor, named by group. /// /// Panics rather than warns on failure. A silently unpinned thread would turn /// a placement experiment into a measurement of the scheduler's preferences, /// and the run would still print a confident number -- the same failure mode as /// a probe that asserts its conclusion. -fn pin_current_thread(cpu: Option) { - let Some(cpu) = cpu else { +/// +/// # Why not `SetThreadAffinityMask` +/// +/// Its mask is interpreted **within the caller's current group**, so it cannot +/// name a processor in another one. On a machine with more than 64 logical +/// processors that is not a matter of widening the mask; the call has no way to +/// express the target at all. `SetThreadGroupAffinity` takes the group +/// explicitly, and is the only way to pin across the whole machine. +fn pin_current_thread(cpu: Option<(u16, u8)>) { + let Some((group, number)) = cpu else { return; }; - assert!(cpu < 64, "this probe assumes a single processor group"); - let mask: usize = 1 << cpu; - // SAFETY: sets this thread's affinity to a mask with one bit set, for a - // processor the caller took from the discovered topology. - let previous = unsafe { SetThreadAffinityMask(GetCurrentThread(), mask) }; assert!( - previous != 0, - "SetThreadAffinityMask failed for processor {cpu}: {}", + u32::from(number) < usize::BITS, + "processor number {number} does not fit a group affinity mask" + ); + + let affinity = GROUP_AFFINITY { + Mask: 1_usize << number, + Group: group, + Reserved: [0; 3], + }; + // SAFETY: `affinity` is a fully initialised `GROUP_AFFINITY` naming one + // processor the caller took from the discovered topology, and the previous + // affinity is not wanted, so a null pointer is passed for it. + let ok = unsafe { SetThreadGroupAffinity(GetCurrentThread(), &affinity, ptr::null_mut()) }; + assert!( + ok != 0, + "SetThreadGroupAffinity failed for group {group} processor {number}: {}", std::io::Error::last_os_error() ); } From ab0798e64c330f039771625bbf77dafdc5316d4e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 15:06:41 -0400 Subject: [PATCH 065/361] feat(placement-probe): stamp the build's identity into the binary A measurement that cannot say which build produced it is an unlabelled number, and submissions will arrive over months from machines nobody here owns. The record carries the commit, whether the tree was dirty, the crate version, and whether the build came from CI. The same shape as topology Provenance one layer down, deliberately: BuildSource is ordered by trust with Unknown as Default, and is_official requires all three of CI, a known commit, and a clean tree, with every unknown counting against. A cargo install from a crates.io tarball has no repository and honestly reports unknown rather than guessing. dirty is Option rather than bool because 'we could not ask' and 'we asked and it was clean' are different facts, and only the second may support an official build. A boolean would have silently merged them in the safe-looking direction. Verified both directions rather than only the local one. A working-copy build stamps a real 12-character sha, reports Local, and refuses to claim official -- there is a test that fails if the build script silently emits nothing, which every shape assertion would otherwise pass through. Setting the CI environment variables flips it to official and drops the UNOFFICIAL marker, which is the path that matters and which no local run would otherwise exercise. Completed item: PT-3.2: Stamp the exact build, and say loudly when it is not an official one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 2 +- Cargo.lock | 2 + crates/windows-placement-probe/Cargo.toml | 14 ++ crates/windows-placement-probe/build.rs | 97 ++++++++++ .../src/build_identity.rs | 135 +++++++++++++ .../src/build_identity/tests.rs | 178 ++++++++++++++++++ crates/windows-placement-probe/src/lib.rs | 2 + 7 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 crates/windows-placement-probe/build.rs create mode 100644 crates/windows-placement-probe/src/build_identity.rs create mode 100644 crates/windows-placement-probe/src/build_identity/tests.rs diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 85733688..e7f29131 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -228,7 +228,7 @@ that carries them is written. N's meaning is fixed, because the record cannot be regenerated. Verify by sabotage: add a field, confirm the test fails and names the difference; bump, add the golden, confirm it passes. -- [ ] **PT-3.2** -- **Stamp the exact build, and say loudly when it is not an official one.** The +- [x] **PT-3.2** -- **Stamp the exact build, and say loudly when it is not an official one.** The record carries the git commit, whether the working tree was dirty when it was built, the crate version, and whether it came from CI or a local build. **This is the same problem as `Provenance` one layer up, and takes the same shape**: an official diff --git a/Cargo.lock b/Cargo.lock index 38a2063d..c3459dbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -228,6 +228,8 @@ dependencies = [ name = "windows-placement-probe" version = "0.1.0" dependencies = [ + "serde", + "serde_json", "windows-sys", "windows-topology-sys", "windows-waitable-queues", diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index 0749d248..e373cfac 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -21,9 +21,23 @@ description = "Measures what thread placement costs on a Windows machine -- SMT [lib] path = "src/lib.rs" +[features] +# The submission record is the product, so serialization is not optional the way +# it is in the layers below. The feature exists so the measurement code can be +# used without it, not so the record can. +default = ["serde"] +serde = ["dep:serde", "windows-topology-sys/serde"] + [dependencies] windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys" } windows-waitable-queues = { version = "0.1.0", path = "../windows-waitable-queues" } +serde = { version = "1.0", features = ["derive"], optional = true } + +[dev-dependencies] +# Only the tests need a serializer: the schema golden is derived by serializing +# a record and walking the result, rather than by restating its key paths in a +# list somebody has to remember to update. +serde_json = "1.0" [dependencies.windows-sys] version = "0.61.2" diff --git a/crates/windows-placement-probe/build.rs b/crates/windows-placement-probe/build.rs new file mode 100644 index 00000000..d2989ac6 --- /dev/null +++ b/crates/windows-placement-probe/build.rs @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Mike Grier +//! Stamps the build's identity into the binary. +//! +//! A measurement that cannot say which build produced it is an unlabelled +//! number. Results from this tool arrive over months, from machines nobody here +//! owns, built from whatever commit was current -- so the record carries the +//! commit, whether the tree was dirty, and whether the build came from CI. +//! +//! # The default is untrusted +//! +//! Every value here can fail to be determined: a `cargo install` from a +//! crates.io tarball has no repository, a downloaded source zip has no `.git`, +//! and `git` may not be on `PATH`. **In every one of those cases the answer is +//! "unknown", never a guess**, and unknown is not official. That mirrors +//! `Provenance::Synthetic` being `Default` one layer down: forgetting, or being +//! unable to tell, must be the safe direction. + +use std::process::Command; + +/// What CI sets so the build does not have to shell out to `git`. +const COMMIT_ENV: &str = "PLACEMENT_PROBE_COMMIT"; + +/// What CI sets to declare the build official. +const SOURCE_ENV: &str = "PLACEMENT_PROBE_SOURCE"; + +fn main() { + // Without these, a rebuild after a commit would keep the stale stamp -- + // which is the failure this file exists to prevent, arriving by a different + // route. + println!("cargo::rerun-if-env-changed={COMMIT_ENV}"); + println!("cargo::rerun-if-env-changed={SOURCE_ENV}"); + println!("cargo::rerun-if-changed=../../.git/HEAD"); + + let (commit, dirty) = match std::env::var(COMMIT_ENV) { + // CI knows the commit it checked out, and a CI checkout is clean by + // construction, so no `git` call is needed or wanted there. + Ok(sha) if !sha.trim().is_empty() => (Some(shorten(sha.trim())), Some(false)), + _ => (git_commit(), git_dirty()), + }; + + let source = match std::env::var(SOURCE_ENV) { + Ok(value) if value.trim().eq_ignore_ascii_case("ci") => "ci", + _ if commit.is_some() => "local", + _ => "unknown", + }; + + println!( + "cargo::rustc-env=PLACEMENT_PROBE_COMMIT_OUT={}", + commit.as_deref().unwrap_or("") + ); + println!( + "cargo::rustc-env=PLACEMENT_PROBE_DIRTY_OUT={}", + match dirty { + Some(true) => "1", + Some(false) => "0", + None => "", + } + ); + println!("cargo::rustc-env=PLACEMENT_PROBE_SOURCE_OUT={source}"); +} + +/// The first twelve characters, which is unambiguous in practice and short +/// enough to sit in a printed line. +fn shorten(sha: &str) -> String { + sha.chars().take(12).collect() +} + +fn git_commit() -> Option { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let sha = String::from_utf8(output.stdout).ok()?; + let sha = sha.trim(); + if sha.is_empty() { + return None; + } + Some(shorten(sha)) +} + +/// Whether the working tree had uncommitted changes. +/// +/// `None` when the question could not be asked at all, which is a different +/// fact from "clean" and is reported as such rather than assumed either way. +fn git_dirty() -> Option { + let output = Command::new("git") + .args(["status", "--porcelain"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + Some(!output.stdout.is_empty()) +} diff --git a/crates/windows-placement-probe/src/build_identity.rs b/crates/windows-placement-probe/src/build_identity.rs new file mode 100644 index 00000000..2318f7ad --- /dev/null +++ b/crates/windows-placement-probe/src/build_identity.rs @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Mike Grier +//! Which build produced a measurement. + +use std::fmt; + +/// Where a binary came from. +/// +/// Ordered by trust, `Unknown < Local < Ci`, so the derived `Ord` is the trust +/// order -- the same shape as `windows_topology_sys::Provenance` one layer down, +/// and for the same reason. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum BuildSource { + /// Could not be established. A `cargo install` from a crates.io tarball + /// takes this path, and so does a source archive with no repository in it. + #[default] + Unknown, + /// Built from a working copy on someone's machine. + Local, + /// Built by this repository's CI, which is the only path that produces an + /// artifact traceable to the commit that made it. + Ci, +} + +impl BuildSource { + /// A short word for a rendered line. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Unknown => "UNKNOWN", + Self::Local => "LOCAL", + Self::Ci => "ci", + } + } +} + +impl fmt::Display for BuildSource { + /// Renders the untrusted variants in capitals and the trusted one in lower + /// case, so a build that cannot vouch for itself is visibly louder than one + /// that can. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} + +/// The identity of the binary that produced a measurement. +/// +/// # Why a measurement carries this +/// +/// Submissions arrive over months from builds nobody here has a copy of. A +/// number that cannot name the code that produced it cannot be compared against +/// one taken later, and cannot be re-examined when a defect is found -- the +/// exact failure `windows_topology_sys::Provenance` fixes for *topology*, one +/// layer down and for the same reason. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct BuildIdentity { + /// The crate version this binary was built from. + pub crate_version: &'static str, + /// The commit, shortened, or `None` when it could not be determined. + pub commit: Option<&'static str>, + /// Whether the working tree had uncommitted changes at build time. + /// + /// `None` means the question could not be asked -- no repository, or no + /// `git` -- which is a different fact from "clean" and is kept distinct + /// from it. + pub dirty: Option, + /// Where the binary came from. + pub source: BuildSource, +} + +impl BuildIdentity { + /// This binary's identity, as its build script stamped it. + #[must_use] + pub fn current() -> Self { + Self { + crate_version: env!("CARGO_PKG_VERSION"), + commit: non_empty(env!("PLACEMENT_PROBE_COMMIT_OUT")), + dirty: match env!("PLACEMENT_PROBE_DIRTY_OUT") { + "1" => Some(true), + "0" => Some(false), + _ => None, + }, + source: match env!("PLACEMENT_PROBE_SOURCE_OUT") { + "ci" => BuildSource::Ci, + "local" => BuildSource::Local, + _ => BuildSource::Unknown, + }, + } + } + + /// Whether this is an official build: from CI, at a known commit, with a + /// clean tree. + /// + /// **All three, and every unknown counts against.** A result from an + /// unofficial build is still worth having; it is not worth *pooling* with + /// official ones without being able to tell them apart, because a defect + /// found later can only be traced through a build that can name its source. + #[must_use] + pub fn is_official(self) -> bool { + self.source == BuildSource::Ci && self.commit.is_some() && self.dirty == Some(false) + } +} + +impl fmt::Display for BuildIdentity { + /// Renders a taint marker unless the build is official, in the same shape + /// the fingerprint uses, so the two read alike wherever they appear + /// together. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if !self.is_official() { + write!(f, "!!UNOFFICIAL!! ")?; + } + write!(f, "v{}", self.crate_version)?; + match self.commit { + Some(commit) => write!(f, " {commit}")?, + None => write!(f, " commit-unknown")?, + } + match self.dirty { + Some(true) => write!(f, " DIRTY")?, + Some(false) => {} + None => write!(f, " dirty-unknown")?, + } + write!(f, " [{}]", self.source) + } +} + +/// An environment stamp that was not set renders as empty, which means "not +/// determined" rather than "the empty string". +fn non_empty(value: &'static str) -> Option<&'static str> { + if value.is_empty() { None } else { Some(value) } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-placement-probe/src/build_identity/tests.rs b/crates/windows-placement-probe/src/build_identity/tests.rs new file mode 100644 index 00000000..5b8a910c --- /dev/null +++ b/crates/windows-placement-probe/src/build_identity/tests.rs @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`BuildIdentity`](super::BuildIdentity). + +use super::{BuildIdentity, BuildSource}; + +/// An official build: CI, known commit, clean tree. +fn official() -> BuildIdentity { + BuildIdentity { + crate_version: "0.1.0", + commit: Some("abcdef123456"), + dirty: Some(false), + source: BuildSource::Ci, + } +} + +#[test] +fn the_default_source_is_the_untrusted_one() { + // The load-bearing property, matching Provenance one layer down: a value + // that was never established must not read as trustworthy. + assert_eq!(BuildSource::default(), BuildSource::Unknown); +} + +#[test] +fn the_source_ordering_is_the_trust_order() { + assert!(BuildSource::Unknown < BuildSource::Local); + assert!(BuildSource::Local < BuildSource::Ci); +} + +#[test] +fn an_official_build_is_ci_with_a_known_commit_and_a_clean_tree() { + assert!(official().is_official()); +} + +#[test] +fn every_single_missing_condition_makes_a_build_unofficial() { + // Exhaustive rather than illustrative: each condition is dropped on its own + // so no one of them can quietly stop mattering. + let dirty = BuildIdentity { + dirty: Some(true), + ..official() + }; + let unknown_dirt = BuildIdentity { + dirty: None, + ..official() + }; + let no_commit = BuildIdentity { + commit: None, + ..official() + }; + let local = BuildIdentity { + source: BuildSource::Local, + ..official() + }; + let unknown = BuildIdentity { + source: BuildSource::Unknown, + ..official() + }; + + for build in [dirty, unknown_dirt, no_commit, local, unknown] { + assert!( + !build.is_official(), + "{build:?} was accepted as an official build" + ); + } +} + +#[test] +fn an_unknown_tree_state_is_not_treated_as_clean() { + // The distinction that a boolean would have lost. "We could not ask" and + // "we asked and it was clean" are different facts, and only the second may + // support an official build. + let unknown_dirt = BuildIdentity { + dirty: None, + ..official() + }; + + assert_ne!(unknown_dirt.dirty, Some(false)); + assert!(!unknown_dirt.is_official()); +} + +#[test] +fn an_official_build_renders_without_a_marker() { + let rendered = official().to_string(); + + assert!(!rendered.contains("!!"), "got {rendered}"); + assert!(rendered.contains("v0.1.0"), "got {rendered}"); + assert!(rendered.contains("abcdef123456"), "got {rendered}"); +} + +#[test] +fn an_unofficial_build_is_marked_at_the_front() { + let local = BuildIdentity { + source: BuildSource::Local, + ..official() + }; + + assert!( + local.to_string().starts_with("!!UNOFFICIAL!! "), + "got {local}" + ); +} + +#[test] +fn the_rendering_names_what_is_wrong_rather_than_only_that_something_is() { + // A reader triaging a surprising submission needs to know *which* property + // failed: a dirty tree and an unknown commit are different problems. + let dirty = BuildIdentity { + dirty: Some(true), + ..official() + }; + let no_commit = BuildIdentity { + commit: None, + ..official() + }; + + assert!(dirty.to_string().contains("DIRTY"), "got {dirty}"); + assert!( + no_commit.to_string().contains("commit-unknown"), + "got {no_commit}" + ); +} + +#[test] +fn this_binarys_identity_is_readable_and_names_its_version() { + // Exercises the build script's stamps rather than a fixture. What the + // values *are* depends on how this test was built, so only their shape is + // asserted -- and the crate version is knowable either way. + let current = BuildIdentity::current(); + + assert_eq!(current.crate_version, env!("CARGO_PKG_VERSION")); + if let Some(commit) = current.commit { + assert!(!commit.is_empty(), "an empty commit must read as None"); + assert!(commit.len() <= 12, "the commit is meant to be shortened"); + } +} + +#[test] +fn the_build_script_stamped_a_real_commit_here() { + // Guards against the build script silently emitting nothing: every stamp + // would then be empty, `commit` would be `None` everywhere, and the shape + // assertions above would all still pass while the record carried no + // identity at all. + // + // This suite is built from a git working copy, so the commit *is* + // determinable and must have been determined. On a machine where it is + // genuinely unavailable -- a crates.io tarball, a source zip -- this test + // is not the one that runs, because that is not where the suite runs. + let current = BuildIdentity::current(); + + let commit = current + .commit + .expect("the build script must find a commit when built from a repository"); + assert!( + commit.len() == 12 && commit.chars().all(|c| c.is_ascii_hexdigit()), + "the stamped commit is not a shortened hex sha: {commit:?}" + ); + assert!( + current.dirty.is_some(), + "the tree state must be determinable from a repository" + ); + assert_eq!( + current.source, + BuildSource::Local, + "a working-copy build must report itself as local" + ); +} + +#[test] +fn a_local_development_build_does_not_claim_to_be_official() { + // This suite runs from a working copy, never from CI, so the binary under + // test must not pass as official. If this ever fails, the build script is + // claiming something it cannot know. + assert!( + !BuildIdentity::current().is_official(), + "a build from a working copy claimed to be official: {}", + BuildIdentity::current() + ); +} diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index 0551e846..610343fa 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -51,6 +51,8 @@ #![cfg(windows)] +/// Which build produced a measurement. +pub mod build_identity; /// What a handoff costs, by where the two threads run. pub mod core_affinity; /// What shape the machine is, and which slice a measurement ran on. From 63761ac75cecd0b88cc0f30c1a4af84a92804993 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 15:11:09 -0400 Subject: [PATCH 066/361] feat(placement-probe): read the machine-description fields a submission carries CPU model, OS build and a virtualisation hint, all read from the registry: it answers on ARM64 where CPUID's brand string does not exist, and it is not subject to the version shims that lie to an unmanifested process. Found a real defect by looking at the output rather than at the test result. CurrentMajorVersionNumber is a REG_DWORD, so a string read is rejected for type and falls through to the legacy CurrentVersion string -- this Windows 11 host reported its build as 6.3.0.26200. Plausible, dotted, numeric, and wrong. The shape-only test passed it happily, which is the lesson: a test that asserts the form of an answer cannot catch a well-formed wrong one. Added read_registry_u32 and a test that checks the assembled build against the registry's own major version rather than against a written-down constant, so the check cannot go stale. Sabotage-verified: forcing the DWORD read to fail reproduces 6.3.26200.9168 and the test names exactly what is wrong. Suppression is recorded rather than inferred from absence, because a field withheld by the runner and a field the host would not answer are different facts. Suppression covers only the model, so a privacy-conscious submission stays useful. The virtualisation hint names what it matched. A bare boolean would ask a reader to trust a heuristic whose markers include manufacturers that also ship real hardware; naming the string lets them judge it. This host reports 'Microsoft Corporation', correctly identifying it as a VM. Completed item: PT-3.6: Read the three machine-description fields PT-1.2 settled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 2 +- crates/windows-placement-probe/Cargo.toml | 5 + crates/windows-placement-probe/src/lib.rs | 2 + crates/windows-placement-probe/src/machine.rs | 316 ++++++++++++++++++ .../src/machine/tests.rs | 161 +++++++++ 5 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 crates/windows-placement-probe/src/machine.rs create mode 100644 crates/windows-placement-probe/src/machine/tests.rs diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index e7f29131..35995f14 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -256,7 +256,7 @@ that carries them is written. exactly where it is and what to do with it. Asking someone to copy terminal output invites truncated and reflowed submissions. -- [ ] **PT-3.6** -- Read the three machine-description fields PT-1.2 settled, each of which needs a +- [x] **PT-3.6** -- Read the three machine-description fields PT-1.2 settled, each of which needs a source this crate does not currently use. **Every one of them is optional in the record**, so a host that will not answer produces a record missing a field rather than a failed run or a fabricated value. diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index e373cfac..cd00c2ba 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -51,4 +51,9 @@ features = [ # The machine-description fields that travel with a submission, so a # result can be read months later without asking its sender anything. "Win32_System_SystemInformation", + # Those fields are read from the registry rather than from the version and + # CPUID APIs: `GetVersionEx` is shimmed and lies to an unmanifested process + # about the major version, and CPUID's brand string does not exist on + # ARM64. The registry is truthful on both counts and on both architectures. + "Win32_System_Registry", ] diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index 610343fa..f38de2ea 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -57,5 +57,7 @@ pub mod build_identity; pub mod core_affinity; /// What shape the machine is, and which slice a measurement ran on. pub mod fingerprint; +/// What machine this was, beyond its measurable shape. +pub mod machine; /// The handoff itself, and the strategies the placement experiment compares. pub mod peer_index_cache; diff --git a/crates/windows-placement-probe/src/machine.rs b/crates/windows-placement-probe/src/machine.rs new file mode 100644 index 00000000..c94db8a5 --- /dev/null +++ b/crates/windows-placement-probe/src/machine.rs @@ -0,0 +1,316 @@ +// Copyright (c) 2026 Mike Grier +//! What machine this was, beyond the shape of it. +//! +//! The [`Fingerprint`](crate::fingerprint::Fingerprint) says which experiments a +//! machine can express, and deliberately omits anything that varies without +//! changing that -- model names, clock speeds, cache sizes -- because a +//! fingerprint that changes when the answer does not is one nobody can compare. +//! +//! That is right for comparison and insufficient for a *submission*. A result +//! arriving from a machine nobody here owns cannot be asked follow-up questions +//! later, so the context has to travel with it or be lost. These are the fields +//! that answer "what was this, really?". +//! +//! # What is collected, and why each is not sensitive +//! +//! A CPU model is a hardware characteristic shared by millions of machines. An +//! OS build likewise. Neither identifies a person, a company, or a deployment. +//! **Host name, user name, file paths, environment variables, serial numbers +//! and installed software are not read here and must not be** -- that is a +//! commitment about this module, not a description of what it happens to do +//! today. +//! +//! # Every field is optional, and absence is honest +//! +//! A host that will not answer produces a record missing a field rather than a +//! failed run or a fabricated value. A registry key can be absent, a policy can +//! deny a read, and a future Windows can rename something. None of those is a +//! reason to stop measuring, and none is a reason to invent an answer. + +use std::fmt; + +use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, ERROR_SUCCESS}; +use windows_sys::Win32::System::Registry::{ + HKEY_LOCAL_MACHINE, RRF_RT_REG_DWORD, RRF_RT_REG_SZ, RegGetValueW, +}; + +/// Whether the machine looks virtualised. +/// +/// **A hint, and named one on purpose.** There is no user-mode call that +/// decides this: a hypervisor that wishes to be invisible can be, and a bare +/// machine can carry firmware strings that look virtual. A field that overstated +/// its confidence would be worse than an absent one, because a reader would +/// trust it. +/// +/// This matters more than it might seem for this dataset. A VM slice *flattens +/// topology* -- one measured here reports a single L3 domain and a single NUMA +/// node for silicon that has eight and two -- so whether a submission came from +/// bare metal decides whether it could ever have shown the rows that are +/// currently unmeasured. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +pub enum VirtualisationHint { + /// Nothing suggested virtualisation. **Not the same as "bare metal"**, and + /// the default precisely because failing to detect must never be reported + /// as having ruled out. + #[default] + NotDetected, + /// A firmware string names a known hypervisor. [`Self::name`] says which. + Detected, + /// The question could not be asked -- the firmware strings were unreadable. + Unknown, +} + +impl fmt::Display for VirtualisationHint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::NotDetected => "not detected", + Self::Detected => "detected", + Self::Unknown => "unknown", + }) + } +} + +/// The machine behind a submission, beyond its measurable shape. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct MachineDescription { + /// The processor's marketing name, or `None` when unreadable or suppressed. + /// + /// Suppression is recorded in [`Self::model_suppressed`] rather than left + /// to be inferred from absence: a field withheld by the runner and a field + /// the host would not answer are different facts, and a collector that + /// cannot tell them apart will eventually read one as the other. + pub cpu_model: Option, + /// Whether the runner asked for the model to be withheld. + pub model_suppressed: bool, + /// The OS build, as `10.0.22631.4460` or similar. + pub os_build: Option, + /// Whether the machine looks virtualised. + pub virtualisation: VirtualisationHint, + /// The firmware's system manufacturer, when it names a known hypervisor. + /// + /// Only populated when [`Self::virtualisation`] is + /// [`VirtualisationHint::Detected`], so the reader can see *what* was + /// detected rather than trusting a bare boolean. + pub virtualisation_name: Option, +} + +impl MachineDescription { + /// Read what this machine will say about itself. + /// + /// `suppress_model` withholds the CPU model at the runner's request. It + /// does not make confidential hardware safe to submit -- the topology + /// identifies an unreleased part at least as well as its name does, and the + /// topology is the measurement -- so the switch reduces incidental leakage + /// and nothing more. + #[must_use] + pub fn read(suppress_model: bool) -> Self { + let (virtualisation, virtualisation_name) = detect_virtualisation(); + Self { + cpu_model: if suppress_model { + None + } else { + read_cpu_model() + }, + model_suppressed: suppress_model, + os_build: read_os_build(), + virtualisation, + virtualisation_name, + } + } +} + +/// The processor's marketing name. +/// +/// Read from the registry rather than from CPUID's brand string, because the +/// registry answers on ARM64 as well and CPUID does not exist there. +fn read_cpu_model() -> Option { + read_registry_string( + r"HARDWARE\DESCRIPTION\System\CentralProcessor\0", + "ProcessorNameString", + ) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +/// The OS build, assembled from the registry. +/// +/// **Deliberately not `GetVersionEx` or `GetVersion`.** Those are shimmed: they +/// report a capped version to a process without a compatibility manifest, so a +/// tool that trusted them would file results from Windows 11 under Windows 8. +/// The registry is not shimmed and reports the real build. +fn read_os_build() -> Option { + const KEY: &str = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion"; + + let build = read_registry_string(KEY, "CurrentBuildNumber")?; + + // **These are `REG_DWORD`, not `REG_SZ`, and reading them as strings is a + // trap that fails quietly.** A string read of `CurrentMajorVersionNumber` + // is rejected for type, falls through to the legacy `CurrentVersion` + // string, and yields `6.3` -- so a Windows 11 machine reports build + // `6.3.0.26200`, which looks entirely plausible and is wrong. That was + // observed here, not theorised. + let major = read_registry_u32(KEY, "CurrentMajorVersionNumber"); + let minor = read_registry_u32(KEY, "CurrentMinorVersionNumber"); + // The update-build revision is genuinely optional: it is absent on some + // builds, and a version without it is still a usable answer. + let revision = read_registry_u32(KEY, "UBR"); + + let head = match (major, minor) { + (Some(major), Some(minor)) => format!("{major}.{minor}"), + (Some(major), None) => format!("{major}.0"), + // Only a pre-Windows-10 machine lacks the numeric values, and there the + // legacy string genuinely is the answer rather than a stale stand-in. + (None, _) => read_registry_string(KEY, "CurrentVersion")?, + }; + + match revision { + Some(revision) => Some(format!("{head}.{build}.{revision}")), + None => Some(format!("{head}.{build}")), + } +} + +/// Firmware strings that name a hypervisor. +/// +/// Matched case-insensitively on a substring, because the exact strings vary by +/// version and by how the host was configured. +const HYPERVISOR_MARKERS: &[&str] = &[ + "vmware", + "virtualbox", + "innotek", + "qemu", + "xen", + "kvm", + "parallels", + "bhyve", + "amazon ec2", + "google", + "microsoft corporation", + "hyper-v", +]; + +fn detect_virtualisation() -> (VirtualisationHint, Option) { + const KEY: &str = r"HARDWARE\DESCRIPTION\System\BIOS"; + + let manufacturer = read_registry_string(KEY, "SystemManufacturer"); + let product = read_registry_string(KEY, "SystemProductName"); + + if manufacturer.is_none() && product.is_none() { + return (VirtualisationHint::Unknown, None); + } + + for candidate in [manufacturer, product].into_iter().flatten() { + let lowered = candidate.to_lowercase(); + if HYPERVISOR_MARKERS + .iter() + .any(|marker| lowered.contains(marker)) + { + return (VirtualisationHint::Detected, Some(candidate)); + } + } + + (VirtualisationHint::NotDetected, None) +} + +/// Read one string value from `HKEY_LOCAL_MACHINE`. +/// +/// Returns `None` for every failure, which is the right shape here: an absent +/// key, a denied read and a value of the wrong type are all "this host will not +/// tell us", and none of them is worth failing a measurement over. +fn read_registry_string(subkey: &str, value: &str) -> Option { + let subkey = wide(subkey); + let value = wide(value); + + // One generous attempt, then one sized retry. Every value read here is a + // short string, so the first attempt almost always succeeds; the retry + // exists so a longer one is not silently truncated. + let mut buffer = vec![0_u16; 256]; + let mut bytes = (buffer.len() * size_of::()) as u32; + + // SAFETY: `subkey` and `value` are NUL-terminated wide strings that outlive + // the call; `buffer` is writable for `bytes`, which is its true size; and + // the type filter restricts the call to string values, so nothing else can + // be written into it. + let mut status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + RRF_RT_REG_SZ, + std::ptr::null_mut(), + buffer.as_mut_ptr().cast(), + &mut bytes, + ) + }; + + if status == ERROR_MORE_DATA { + buffer = vec![0_u16; (bytes as usize).div_ceil(size_of::()) + 1]; + bytes = (buffer.len() * size_of::()) as u32; + // SAFETY: as above, with a buffer sized from what the first call asked + // for. + status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + RRF_RT_REG_SZ, + std::ptr::null_mut(), + buffer.as_mut_ptr().cast(), + &mut bytes, + ) + }; + } + + if status != ERROR_SUCCESS { + return None; + } + + let len = (bytes as usize) / size_of::(); + let text = &buffer[..len.min(buffer.len())]; + let text = match text.iter().position(|&c| c == 0) { + Some(nul) => &text[..nul], + None => text, + }; + Some(String::from_utf16_lossy(text)) +} + +/// Read one `REG_DWORD` value from `HKEY_LOCAL_MACHINE`. +/// +/// Separate from [`read_registry_string`] because the type filter must match +/// the stored type: asking for a string and getting a DWORD is not a coercion, +/// it is a rejected read, and the caller then silently falls back to whatever +/// is next. That is how a Windows 11 host came to report itself as `6.3`. +fn read_registry_u32(subkey: &str, value: &str) -> Option { + let subkey = wide(subkey); + let value = wide(value); + + let mut data = 0_u32; + let mut bytes = size_of::() as u32; + + // SAFETY: `subkey` and `value` are NUL-terminated wide strings that outlive + // the call; `data` is a writable `u32` and `bytes` is exactly its size; and + // the type filter restricts the call to DWORD values, so nothing wider can + // be written into it. + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + RRF_RT_REG_DWORD, + std::ptr::null_mut(), + std::ptr::from_mut(&mut data).cast(), + &mut bytes, + ) + }; + + (status == ERROR_SUCCESS).then_some(data) +} + +fn wide(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-placement-probe/src/machine/tests.rs b/crates/windows-placement-probe/src/machine/tests.rs new file mode 100644 index 00000000..7d1dcc55 --- /dev/null +++ b/crates/windows-placement-probe/src/machine/tests.rs @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`MachineDescription`](super::MachineDescription). +//! +//! These run against the real machine, because the whole point of the module is +//! what a real machine will say. They therefore assert *shape and policy* -- +//! that suppression is honoured, that absence is distinguishable from +//! suppression, that nothing forbidden is read -- rather than any particular +//! value, which would be an assertion about whatever host ran the suite. + +use super::{MachineDescription, VirtualisationHint}; + +#[test] +fn the_default_hint_is_not_a_claim_of_bare_metal() { + // `NotDetected` must be the default rather than anything stronger: failing + // to detect a hypervisor is not the same as establishing there is none, and + // a reader deciding whether a submission could show NUMA rows depends on + // that distinction. + assert_eq!( + VirtualisationHint::default(), + VirtualisationHint::NotDetected + ); + assert_eq!( + VirtualisationHint::NotDetected.to_string(), + "not detected", + "the rendered form must not read as a positive claim" + ); +} + +#[test] +fn reading_this_machine_answers_something() { + // A smoke test with teeth: if the registry reads were wrong -- bad key + // path, wrong value type, mishandled buffer -- every field would come back + // empty at once, and that is what this catches. + let described = MachineDescription::read(false); + + assert!( + described.cpu_model.is_some() || described.os_build.is_some(), + "no field could be read at all, which suggests the reads are broken \ + rather than that this host is unusually quiet: {described:?}" + ); +} + +#[test] +fn the_cpu_model_when_present_looks_like_a_processor_name() { + let described = MachineDescription::read(false); + + if let Some(model) = &described.cpu_model { + assert!(!model.is_empty(), "an empty model must be reported as None"); + assert_eq!(model.trim(), model, "the model must arrive trimmed"); + assert!( + model.chars().any(|c| c.is_ascii_alphabetic()), + "a processor name with no letters is not a name: {model:?}" + ); + } +} + +#[test] +fn the_os_build_reports_the_real_major_version_and_not_the_legacy_string() { + // **This test exists because the shape-only test below passed a wrong + // answer.** `CurrentMajorVersionNumber` is a `REG_DWORD`; reading it as a + // string is rejected for type, falls back to the legacy `CurrentVersion` + // string, and reports a Windows 11 host as build `6.3.0.26200` -- dotted + // numbers, entirely plausible, and false. + // + // The expected value is read here rather than written down, so this checks + // the assembly against the registry rather than against a constant that + // would itself go stale. + let Some(major) = super::read_registry_u32( + r"SOFTWARE\Microsoft\Windows NT\CurrentVersion", + "CurrentMajorVersionNumber", + ) else { + // Pre-Windows-10, where the legacy string genuinely is the answer. + return; + }; + + let build = MachineDescription::read(false) + .os_build + .expect("a host that reports a major version must yield a build"); + + assert!( + build.starts_with(&format!("{major}.")), + "os_build {build:?} does not begin with the registry's major version {major}" + ); +} + +#[test] +fn the_os_build_when_present_is_dotted_numbers() { + // Guards the assembly in `read_os_build`, which stitches several registry + // values together and could silently produce something like "..". + let described = MachineDescription::read(false); + + if let Some(build) = &described.os_build { + let parts: Vec<&str> = build.split('.').collect(); + assert!( + parts.len() >= 3, + "an OS build should have at least three components: {build:?}" + ); + assert!( + parts.iter().all(|part| !part.is_empty()), + "an OS build with an empty component means a missing registry \ + value was stitched in silently: {build:?}" + ); + assert!( + parts + .iter() + .all(|part| part.chars().all(|c| c.is_ascii_digit())), + "an OS build component that is not a number: {build:?}" + ); + } +} + +#[test] +fn suppressing_the_model_withholds_it_and_records_that_it_was_withheld() { + // The distinction that a bare `Option` would have lost. A collector must be + // able to tell "the runner withheld this" from "the host would not say". + let suppressed = MachineDescription::read(true); + + assert!(suppressed.cpu_model.is_none()); + assert!(suppressed.model_suppressed); +} + +#[test] +fn not_suppressing_records_that_nothing_was_withheld() { + let described = MachineDescription::read(false); + + assert!( + !described.model_suppressed, + "an unsuppressed read must not claim the model was withheld" + ); +} + +#[test] +fn suppression_withholds_only_the_model() { + // Suppression is a privacy switch, not a mute button: the fields it does + // not cover must still be collected, or a suppressed submission would be + // far less useful than the runner intended. + let open = MachineDescription::read(false); + let suppressed = MachineDescription::read(true); + + assert_eq!(open.os_build, suppressed.os_build); + assert_eq!(open.virtualisation, suppressed.virtualisation); +} + +#[test] +fn a_detected_hypervisor_names_itself() { + // A bare "detected" would ask the reader to trust the heuristic. Naming the + // string that matched lets them judge it -- which matters, because the + // markers include manufacturer names that also ship real hardware. + let described = MachineDescription::read(false); + + match described.virtualisation { + VirtualisationHint::Detected => assert!( + described.virtualisation_name.is_some(), + "a detection with nothing named cannot be judged by a reader" + ), + _ => assert!( + described.virtualisation_name.is_none(), + "a name was recorded without a detection: {described:?}" + ), + } +} From 8ab9ccf5c866d9d039b658f2057c8389e7cb88dc Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 15:15:55 -0400 Subject: [PATCH 067/361] feat(placement-probe): add the submission record and its archived schema guard PT-3.1 and PT-3.3 land together because they are one change and the checklist had them as two. A schema guard archives the shape of a record, so it cannot be written before the record exists; and shipping the record without the guard would publish a shape with nothing holding it still. There is no intermediate state worth committing, so this cites both. The record carries everything needed to interpret its numbers months later from a machine nobody here owns: schema version, build identity, machine description, topology fingerprint and provenance, timestamp, and the measurements. Field names mean something without a schema in hand and there are no opaque blobs, because "read it before you send it" is only an honest instruction if the file can be read. The schema is archived, not hashed. schema/v1.txt lists the record's key paths, derived by serializing a fully populated record and walking the result -- never written by hand, so it cannot drift from the type it describes. A digest was rejected because only the current version's hash can ever be recomputed, which silently makes the hash function an unversioned contract, and because a digest reports that the shape moved but never what moved. Sabotage-verified, and this is where the choice pays: adding a field fails the guard with `added: ["sabotage_field"]`. A hash would have said only that something differed. Goldens are append-only and a published version is never redefined, because records in the wild claim their number and cannot be regenerated. Two smaller decisions worth recording. Strategy::name is added beside Strategy::label rather than reusing it: the label is prose for a terminal table and may be reworded freely, while the name is a token stored records are keyed on, and rewording that would break every collector that grouped by it. And node_hops serializes as an empty array rather than being omitted, so a large machine's submission can distinguish "measured, none exist" from "this version did not report them". The timestamp is hand-rolled rather than pulling a date crate into a tool people are asked to download and run. The civil-from-days conversion is pinned against known instants including two leap days and a century boundary, plus a round-trip property over eighty years. Completed item: PT-3.1: A linearly increasing integer schema version that cannot silently drift, guarded by an archived schema rather than a hash. Completed item: PT-3.3: Emit one machine-readable record per run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 4 +- crates/windows-placement-probe/schema/v1.txt | 84 ++++++ .../src/fingerprint.rs | 1 + crates/windows-placement-probe/src/lib.rs | 2 + .../src/peer_index_cache.rs | 16 ++ crates/windows-placement-probe/src/record.rs | 239 +++++++++++++++++ .../src/record/tests.rs | 244 ++++++++++++++++++ 7 files changed, 588 insertions(+), 2 deletions(-) create mode 100644 crates/windows-placement-probe/schema/v1.txt create mode 100644 crates/windows-placement-probe/src/record.rs create mode 100644 crates/windows-placement-probe/src/record/tests.rs diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 35995f14..9579ecc1 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -205,7 +205,7 @@ the entire point of this tool -- has more than 64 logical processors, so Windows Ordered so each item's prerequisites land first: the two identity fields are decided before the record that carries them is written. -- [ ] **PT-3.1** -- **A linearly increasing integer schema version that cannot silently drift, guarded +- [x] **PT-3.1** -- **A linearly increasing integer schema version that cannot silently drift, guarded by an archived schema rather than a hash.** The counter itself is easy for a consumer to compare (`schema >= 2`); the hazard is forgetting to bump it when the record's shape changes, which no amount of care reliably prevents. So derive rather than restate, per this repository's own rule -- but @@ -240,7 +240,7 @@ that carries them is written. when there is a repository, and records *unknown* otherwise -- which is exactly what a `cargo install` from a crates.io tarball will produce, and is the honest answer there. -- [ ] **PT-3.3** -- Emit **one** machine-readable record per run, carrying: the schema version +- [x] **PT-3.3** -- Emit **one** machine-readable record per run, carrying: the schema version (PT-3.1), the build identity (PT-3.2), the topology **provenance**, a UTC timestamp, the host fingerprint, every placement measurement, and every node-hop measurement. **Build identity is the load-bearing field.** Results will arrive over months from different builds, diff --git a/crates/windows-placement-probe/schema/v1.txt b/crates/windows-placement-probe/schema/v1.txt new file mode 100644 index 00000000..23ccb230 --- /dev/null +++ b/crates/windows-placement-probe/schema/v1.txt @@ -0,0 +1,84 @@ +# Schema v1 for windows-placement-probe submission records. +# +# Every key path the record serializes to, sorted. Derived by serializing a +# fully populated record and walking the result -- never written by hand, so it +# cannot drift from the type it describes. +# +# APPEND-ONLY. Never edit a published version: records already in the wild +# claim this number, and they cannot be regenerated. To change the shape, raise +# SCHEMA_VERSION and add the next file beside this one. +# +# An array contributes "field[]" rather than one entry per element, so this +# describes the shape and not the size of any one sample. + +build +build.commit +build.crate_version +build.dirty +build.source +by_class +by_class[] +by_class[].consumer_batch +by_class[].consumer_group +by_class[].consumer_numa_node +by_class[].consumer_number +by_class[].nanos_per_item +by_class[].placement +by_class[].producer_batch +by_class[].producer_group +by_class[].producer_numa_node +by_class[].producer_number +by_class[].slice +by_class[].strategy +host +host.arch +host.cache_domain_sizes +host.cache_domain_sizes[] +host.cores +host.efficiency_classes +host.efficiency_classes[] +host.efficiency_classes[][] +host.numa_node_sizes +host.numa_node_sizes[] +host.partitioning_cache_level +host.processors +host.provenance +host.smt +machine +machine.cpu_model +machine.model_suppressed +machine.os_build +machine.virtualisation +machine.virtualisation_name +node_hops +node_hops[] +node_hops[].consumer_batch +node_hops[].consumer_group +node_hops[].consumer_numa_node +node_hops[].consumer_number +node_hops[].nanos_per_item +node_hops[].placement +node_hops[].producer_batch +node_hops[].producer_group +node_hops[].producer_numa_node +node_hops[].producer_number +node_hops[].slice +node_hops[].strategy +placements +placements[] +placements[].consumer_batch +placements[].consumer_group +placements[].consumer_numa_node +placements[].consumer_number +placements[].nanos_per_item +placements[].placement +placements[].producer_batch +placements[].producer_group +placements[].producer_numa_node +placements[].producer_number +placements[].slice +placements[].strategy +recorded_at +recorded_at_epoch_seconds +schema_version +topology_provenance diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index 2856d903..8c1522fa 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -245,6 +245,7 @@ impl fmt::Display for Slice { /// A machine's shape, in the terms that decide which placements exist. #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] pub struct Fingerprint { /// Target architecture, as `std::env::consts::ARCH` reports it. pub arch: &'static str, diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index f38de2ea..87624afa 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -61,3 +61,5 @@ pub mod fingerprint; pub mod machine; /// The handoff itself, and the strategies the placement experiment compares. pub mod peer_index_cache; +/// The record a run produces and a runner sends back. +pub mod record; diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index e4e10c07..8f2092bd 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -94,6 +94,22 @@ impl Strategy { Self::Warmed => "model: warming load", } } + + /// A stable identifier for a record. + /// + /// Separate from [`Self::label`] on purpose, and not a duplicate of it. + /// The label is prose for a terminal table and may be reworded whenever the + /// table reads better a different way; this is a token a stored record is + /// keyed on, so rewording it would silently break every collector that ever + /// grouped by it. Keeping them apart is what lets the prose stay free. + #[must_use] + pub fn name(self) -> &'static str { + match self { + Self::Baseline => "baseline", + Self::Cached => "cached", + Self::Warmed => "warmed", + } + } } /// One configuration's result. diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs new file mode 100644 index 00000000..718b9f95 --- /dev/null +++ b/crates/windows-placement-probe/src/record.rs @@ -0,0 +1,239 @@ +// Copyright (c) 2026 Mike Grier +//! The record a run produces and a runner sends back. +//! +//! # One record, one run +//! +//! Everything a reader needs to interpret the numbers travels with them: which +//! build measured, what the machine was, where the topology came from, and when. +//! A number that arrives without those cannot be compared against one taken on +//! another machine six months later, and comparing across machines is the entire +//! purpose of collecting these. +//! +//! # The record is meant to be read before it is sent +//! +//! It is text, its field names mean something without a schema in hand, and it +//! contains no opaque blobs. That is a deliberate constraint rather than a +//! convenience: "open it and read it, and if you are unhappy with anything in +//! there, do not send it" is only an honest instruction if the file can actually +//! be read. + +use std::fmt; +use std::time::{SystemTime, UNIX_EPOCH}; + +use windows_topology_sys::Provenance; + +use crate::build_identity::BuildIdentity; +use crate::core_affinity::{Measurement, Observation}; +use crate::fingerprint::Fingerprint; +use crate::machine::MachineDescription; + +/// The version of the record's shape. +/// +/// # How this is kept honest +/// +/// A linearly increasing integer, so a collector can compare it (`schema >= 2`) +/// without understanding anything else. The hazard is not the counter, it is +/// **forgetting to raise it** when the shape changes -- which no amount of care +/// reliably prevents. +/// +/// So the shape is *archived* rather than restated: `schema/vN.txt` lists this +/// record's key paths, and a test regenerates them and compares. Changing the +/// shape without bumping fails that test, and the diff shows exactly what +/// changed. +/// +/// A digest was considered and rejected. With a table of `version -> hash` only +/// the current version's hash can ever be recomputed, so every earlier row is a +/// frozen constant nobody can verify, and the hash function silently becomes an +/// unversioned contract. A digest also reports only *that* the shape moved, +/// never *what* moved, so a review cannot tell an added field from a removed +/// one. +/// +/// **The golden files are append-only and a published version is never +/// redefined.** Once a record exists in the wild claiming schema N, N's meaning +/// is fixed, because that record cannot be regenerated. +pub const SCHEMA_VERSION: u32 = 1; + +/// One run's complete output. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct SubmissionRecord { + /// Which shape this record has. See [`SCHEMA_VERSION`]. + pub schema_version: u32, + /// When the run finished, as an ISO-8601 UTC timestamp. + pub recorded_at: String, + /// The same instant in seconds since the Unix epoch. + /// + /// Carried beside the formatted form because a collector should never have + /// to parse prose to sort records, and because it survives any later change + /// to how the string is rendered. + pub recorded_at_epoch_seconds: u64, + /// Which build measured. + pub build: BuildIdentity, + /// What the machine was, beyond its measurable shape. + pub machine: MachineDescription, + /// The machine's shape, in the terms that decide which placements exist. + pub host: Fingerprint, + /// Where the topology came from. + /// + /// Repeated from [`Fingerprint::provenance`] deliberately: a collector + /// filtering out synthetic submissions should not have to know that the + /// fingerprint carries it, and this is the field they will look for. + pub topology_provenance: Provenance, + /// One entry per placement this machine could express, per strategy. + pub placements: Vec, + /// One entry per distinct pair of NUMA nodes, per strategy. + /// + /// Empty on a single-node machine. **That emptiness is the finding this + /// tool most wants from a large host**, so it is an empty list rather than + /// an omitted field: a collector can then tell "measured, none exist" from + /// "this version did not report them". + pub node_hops: Vec, + /// One entry per efficiency class, comparing like with like. + pub by_class: Vec, +} + +/// One measurement, flattened into the shape a record carries. +/// +/// Flattened rather than nested because the nesting in +/// [`Measurement`](crate::core_affinity::Measurement) serves the code, and a +/// record is read by someone who does not have the code. +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +pub struct MeasurementRecord { + /// How the two threads were placed relative to each other. + pub placement: String, + /// Which peer-index strategy ran. + pub strategy: String, + /// Exactly which processors this number came from. + pub slice: String, + /// The producer's processor group. + pub producer_group: u16, + /// The producer's processor number within its group. + pub producer_number: u8, + /// The producer's NUMA node. + pub producer_numa_node: u32, + /// The consumer's processor group. + pub consumer_group: u16, + /// The consumer's processor number within its group. + pub consumer_number: u8, + /// The consumer's NUMA node. + pub consumer_numa_node: u32, + /// Median nanoseconds per item handed across the ring. + pub nanos_per_item: f64, + /// How many items each consumer-side shared read was amortised over. + pub consumer_batch: f64, + /// The same for the producer side. + pub producer_batch: f64, +} + +impl From<&Measurement> for MeasurementRecord { + fn from(measurement: &Measurement) -> Self { + Self { + placement: measurement.placement.label().to_owned(), + strategy: measurement.strategy.name().to_owned(), + slice: measurement.slice.to_string(), + producer_group: measurement.producer.group, + producer_number: measurement.producer.number, + producer_numa_node: measurement.producer.numa_node, + consumer_group: measurement.consumer.group, + consumer_number: measurement.consumer.number, + consumer_numa_node: measurement.consumer.numa_node, + nanos_per_item: measurement.nanos_per_item, + consumer_batch: measurement.consumer_batch, + producer_batch: measurement.producer_batch, + } + } +} + +impl SubmissionRecord { + /// Assemble a record from a completed run. + #[must_use] + pub fn new(observation: &Observation, host: Fingerprint, machine: MachineDescription) -> Self { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + + Self { + schema_version: SCHEMA_VERSION, + recorded_at: iso8601_utc(now), + recorded_at_epoch_seconds: now, + build: BuildIdentity::current(), + machine, + topology_provenance: host.provenance, + host, + placements: observation.measurements.iter().map(Into::into).collect(), + node_hops: observation.by_node_pair.iter().map(Into::into).collect(), + by_class: observation.by_class.iter().map(Into::into).collect(), + } + } + + /// Whether every part of this record is trustworthy. + /// + /// A record that fails this is still worth sending -- it is not worth + /// silently pooling with the rest, because a defect found later can only be + /// traced through a build and a topology that can name themselves. + #[must_use] + pub fn is_fully_trusted(&self) -> bool { + self.build.is_official() && self.topology_provenance.is_measured() + } +} + +impl fmt::Display for SubmissionRecord { + /// A one-line summary, for a banner rather than for a collector. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "schema {} | {} | {} | {} placements, {} node hops", + self.schema_version, + self.build, + self.host, + self.placements.len(), + self.node_hops.len() + ) + } +} + +/// Format seconds since the Unix epoch as `YYYY-MM-DDTHH:MM:SSZ`. +/// +/// Hand-rolled rather than pulled from a date crate: one timestamp in one +/// format does not justify a dependency in a tool people are asked to download +/// and run, and the civil-from-days conversion is a settled algorithm that a +/// test can pin against known instants. +#[must_use] +fn iso8601_utc(epoch_seconds: u64) -> String { + let days = (epoch_seconds / 86_400) as i64; + let seconds_of_day = epoch_seconds % 86_400; + let (year, month, day) = civil_from_days(days); + let (hour, minute, second) = ( + seconds_of_day / 3_600, + (seconds_of_day % 3_600) / 60, + seconds_of_day % 60, + ); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +/// Days since 1970-01-01 to a civil `(year, month, day)`. +/// +/// Howard Hinnant's `civil_from_days`, which shifts the epoch to 0000-03-01 so +/// the leap day lands at the end of the era and the month arithmetic becomes +/// branch-free. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let day_of_era = z.rem_euclid(146_097); + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let shifted_month = (5 * day_of_year + 2) / 153; + let day = (day_of_year - (153 * shifted_month + 2) / 5 + 1) as u32; + let month = if shifted_month < 10 { + shifted_month + 3 + } else { + shifted_month - 9 + } as u32; + (year + i64::from(month <= 2), month, day) +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs new file mode 100644 index 00000000..9750da9c --- /dev/null +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -0,0 +1,244 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`SubmissionRecord`](super::SubmissionRecord), including the +//! schema guard. + +use std::collections::BTreeSet; + +use windows_topology_sys::Provenance; + +use super::{MeasurementRecord, SCHEMA_VERSION, SubmissionRecord, civil_from_days, iso8601_utc}; +use crate::build_identity::{BuildIdentity, BuildSource}; +use crate::fingerprint::Fingerprint; +use crate::machine::{MachineDescription, VirtualisationHint}; + +/// A record with **every** optional field populated. +/// +/// Fully populated on purpose: the schema golden is derived from whatever this +/// serializes to, so a field left `None` here would be omitted from the JSON +/// and would silently vanish from the archived shape. A schema that describes +/// less than the record can emit is worse than no schema, because it would +/// pass. +fn fully_populated() -> SubmissionRecord { + let measurement = MeasurementRecord { + placement: "SMT siblings (one core)".to_owned(), + strategy: "baseline".to_owned(), + slice: "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0".to_owned(), + producer_group: 0, + producer_number: 0, + producer_numa_node: 0, + consumer_group: 0, + consumer_number: 1, + consumer_numa_node: 0, + nanos_per_item: 10.5, + consumer_batch: 84.9, + producer_batch: 1.0, + }; + + SubmissionRecord { + schema_version: SCHEMA_VERSION, + recorded_at: "2026-08-31T12:00:00Z".to_owned(), + recorded_at_epoch_seconds: 1_788_177_600, + build: BuildIdentity { + crate_version: "0.1.0", + commit: Some("abcdef123456"), + dirty: Some(false), + source: BuildSource::Ci, + }, + machine: MachineDescription { + cpu_model: Some("Example CPU".to_owned()), + model_suppressed: false, + os_build: Some("10.0.26200.9168".to_owned()), + virtualisation: VirtualisationHint::Detected, + virtualisation_name: Some("Example Hypervisor".to_owned()), + }, + host: Fingerprint { + arch: "x86_64", + processors: 16, + cores: 8, + smt: true, + partitioning_cache_level: Some(2), + cache_domain_sizes: vec![2, 2], + efficiency_classes: vec![(0, 16)], + numa_node_sizes: vec![16], + provenance: Provenance::Measured, + }, + topology_provenance: Provenance::Measured, + placements: vec![measurement.clone()], + node_hops: vec![measurement.clone()], + by_class: vec![measurement], + } +} + +/// Every key path the record serializes to, sorted. +/// +/// **Derived from the record rather than written down.** A hand-maintained list +/// would be a second statement of the shape, and the two would drift -- which +/// is the whole failure this guard exists to prevent, arriving by a different +/// route. +/// +/// An array contributes `field[]`, not one entry per element, so the golden +/// describes the shape and not the size of one sample. +fn key_paths(value: &serde_json::Value) -> BTreeSet { + fn walk(value: &serde_json::Value, prefix: &str, into: &mut BTreeSet) { + match value { + serde_json::Value::Object(fields) => { + for (name, child) in fields { + let path = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}.{name}") + }; + into.insert(path.clone()); + walk(child, &path, into); + } + } + serde_json::Value::Array(items) => { + let path = format!("{prefix}[]"); + into.insert(path.clone()); + for item in items { + walk(item, &path, into); + } + } + _ => {} + } + } + + let mut paths = BTreeSet::new(); + walk(value, "", &mut paths); + paths +} + +#[test] +fn the_records_shape_matches_the_archived_schema_for_its_version() { + // The guard. Change the record's shape without raising SCHEMA_VERSION and + // adding the next golden, and this fails -- with a diff that names exactly + // which paths appeared or disappeared, which a digest could never do. + let record = fully_populated(); + let value = serde_json::to_value(&record).expect("the record must serialize"); + let actual: Vec = key_paths(&value).into_iter().collect(); + + let golden_path = format!( + "{}/schema/v{SCHEMA_VERSION}.txt", + env!("CARGO_MANIFEST_DIR") + ); + let golden = std::fs::read_to_string(&golden_path).unwrap_or_else(|error| { + panic!( + "schema golden {golden_path} is missing ({error}). If SCHEMA_VERSION \ + was just raised, create it with the current shape:\n{}\n", + actual.join("\n") + ) + }); + let expected: Vec = golden + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .map(str::to_owned) + .collect(); + + let missing: Vec<&String> = expected.iter().filter(|p| !actual.contains(p)).collect(); + let added: Vec<&String> = actual.iter().filter(|p| !expected.contains(p)).collect(); + + assert!( + missing.is_empty() && added.is_empty(), + "the record's shape no longer matches schema v{SCHEMA_VERSION}.\n\ + removed: {missing:?}\n\ + added: {added:?}\n\ + Raise SCHEMA_VERSION and add the next golden; never edit a published one, \ + because records already in the wild claim the old number and cannot be \ + regenerated." + ); +} + +#[test] +fn the_schema_version_in_a_record_is_the_constant() { + // A record that declared a version it was not built to would be worse than + // one with no version at all. + assert_eq!(fully_populated().schema_version, SCHEMA_VERSION); +} + +#[test] +fn every_field_of_a_fully_populated_record_is_present_in_the_json() { + // Guards the fixture rather than the code: if a future field is added and + // left `None` here, it would be omitted from the JSON and would never enter + // the golden, so the guard above would pass while describing less than the + // record can emit. + let value = serde_json::to_value(fully_populated()).expect("must serialize"); + let paths = key_paths(&value); + + for required in [ + "machine.cpu_model", + "machine.os_build", + "machine.virtualisation_name", + "build.commit", + "build.dirty", + "host.partitioning_cache_level", + ] { + assert!( + paths.contains(required), + "{required} is absent, so the fixture leaves an optional field unset" + ); + } +} + +#[test] +fn node_hops_is_an_empty_list_rather_than_an_absent_field() { + // The distinction a large-machine submission depends on: "measured, and + // there are none" must be tellable from "this version did not report them". + let mut record = fully_populated(); + record.node_hops.clear(); + + let value = serde_json::to_value(&record).expect("must serialize"); + assert!( + value + .get("node_hops") + .is_some_and(serde_json::Value::is_array), + "node_hops must serialize as an array even when empty" + ); +} + +#[test] +fn a_record_is_fully_trusted_only_when_the_build_and_the_topology_both_are() { + assert!(fully_populated().is_fully_trusted()); + + let mut synthetic = fully_populated(); + synthetic.topology_provenance = Provenance::Synthetic; + assert!(!synthetic.is_fully_trusted()); + + let mut unofficial = fully_populated(); + unofficial.build.source = BuildSource::Local; + assert!(!unofficial.is_fully_trusted()); +} + +#[test] +fn the_timestamp_renders_known_instants_correctly() { + // Pins the hand-rolled civil-from-days conversion against instants whose + // answers are independently known, including a leap day and a century + // boundary that is a leap year. + assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z"); + assert_eq!(iso8601_utc(1), "1970-01-01T00:00:01Z"); + assert_eq!(iso8601_utc(86_399), "1970-01-01T23:59:59Z"); + assert_eq!(iso8601_utc(86_400), "1970-01-02T00:00:00Z"); + // 2000-02-29, a leap day in a year divisible by 100 and by 400. + assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00Z"); + // 2024-02-29, an ordinary leap day. + assert_eq!(iso8601_utc(1_709_164_800), "2024-02-29T00:00:00Z"); + assert_eq!(iso8601_utc(1_788_177_600), "2026-08-31T12:00:00Z"); +} + +#[test] +fn the_civil_conversion_round_trips_across_a_long_span() { + // A property rather than a fixture: every day for eighty years must convert + // to a real date, and consecutive days must differ by exactly one day. + let mut previous: Option<(i64, u32, u32)> = None; + for day in 0..(80 * 365 + 20) { + let (year, month, dom) = civil_from_days(day); + assert!((1..=12).contains(&month), "day {day} gave month {month}"); + assert!((1..=31).contains(&dom), "day {day} gave day-of-month {dom}"); + assert!((1970..2060).contains(&year), "day {day} gave year {year}"); + + if let Some(prev) = previous { + assert_ne!(prev, (year, month, dom), "day {day} repeated a date"); + } + previous = Some((year, month, dom)); + } +} From b6f23ec9f3bc6314250038d94113893dd5fceddf Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 15:18:27 -0400 Subject: [PATCH 068/361] feat(placement-probe): render the human-readable report from the record The report is a function of the SubmissionRecord rather than of the Observation the record was also built from. Two renderings of one run can disagree, and this investigation has been bitten three times by exactly that: a probe printing a fixed conclusion that contradicted its own table, a table omitting the row its interpretation quoted, and a classification silently merging two placements. Deriving the text from the file is what makes 'read it before you send it' meaningful. The report keeps distinctions the record keeps. A withheld CPU model reads differently from an unreadable one; a virtualisation negative reads as 'not detected' rather than 'bare metal'; an untrusted run names each reason separately rather than saying something is wrong; and a single-node machine explains that it is a fact about the host rather than a failed measurement, because a runner who thinks the tool broke will not send the file. The memory-ordering caveat is printed on every run including trusted ones, since a long clean run is exactly when someone is most tempted to read it as validation of something it never touched. One test asserted absence of the substring 'failed measurement' while the report legitimately says 'not a failed measurement' -- a match inside a denial. Fixed the assertion rather than the wording, and noted why. Completed item: PT-3.4: Keep the human-readable report as well, and derive both from the same measured values so they cannot disagree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 2 +- crates/windows-placement-probe/src/lib.rs | 2 + crates/windows-placement-probe/src/record.rs | 2 +- .../src/record/tests.rs | 2 +- crates/windows-placement-probe/src/report.rs | 192 ++++++++++++++++++ .../src/report/tests.rs | 172 ++++++++++++++++ 6 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 crates/windows-placement-probe/src/report.rs create mode 100644 crates/windows-placement-probe/src/report/tests.rs diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 9579ecc1..d6dfd4a1 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -248,7 +248,7 @@ that carries them is written. failure this workspace spent [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) `D-12` fixing one layer down. There is currently **no** version stamped in any probe output. -- [ ] **PT-3.4** -- Keep the human-readable report as well, and derive both from the same measured +- [x] **PT-3.4** -- Keep the human-readable report as well, and derive both from the same measured values so they cannot disagree. The reader running the tool should be able to see, in prose, the same conclusion the record encodes -- otherwise nobody notices when a run is nonsense. diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index 87624afa..a6b46d7a 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -63,3 +63,5 @@ pub mod machine; pub mod peer_index_cache; /// The record a run produces and a runner sends back. pub mod record; +/// The human-readable report, rendered from the record. +pub mod report; diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index 718b9f95..ab4c48f1 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -236,4 +236,4 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) { } #[cfg(test)] -mod tests; +pub(crate) mod tests; diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index 9750da9c..c50ca6cc 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -18,7 +18,7 @@ use crate::machine::{MachineDescription, VirtualisationHint}; /// and would silently vanish from the archived shape. A schema that describes /// less than the record can emit is worse than no schema, because it would /// pass. -fn fully_populated() -> SubmissionRecord { +pub(crate) fn fully_populated() -> SubmissionRecord { let measurement = MeasurementRecord { placement: "SMT siblings (one core)".to_owned(), strategy: "baseline".to_owned(), diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs new file mode 100644 index 00000000..c290f99b --- /dev/null +++ b/crates/windows-placement-probe/src/report.rs @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Mike Grier +//! The human-readable report, rendered from the record. +//! +//! # Why this renders the record rather than the measurement +//! +//! The obvious implementation walks the [`Observation`](crate::core_affinity::Observation) +//! that the record was also built from. That gives two renderings of one run +//! which can disagree -- and this investigation has already been bitten three +//! times by exactly that: a probe that printed a fixed conclusion contradicting +//! its own table, a table that omitted the row its interpretation quoted, and a +//! classification that silently merged two placements. +//! +//! So the report is a function of the [`SubmissionRecord`], full stop. If the +//! record is wrong the report is wrong in the same way, which is what makes the +//! printed text worth reading before deciding whether to send the file. + +use std::fmt::Write as _; + +use crate::record::SubmissionRecord; + +/// Render the report a runner sees. +#[must_use] +pub fn render(record: &SubmissionRecord) -> String { + let mut out = String::new(); + render_header(&mut out, record); + render_machine(&mut out, record); + render_placements(&mut out, record); + render_node_hops(&mut out, record); + render_trust(&mut out, record); + out +} + +fn render_header(out: &mut String, record: &SubmissionRecord) { + let _ = writeln!( + out, + "== what does thread placement cost on this machine? ==" + ); + let _ = writeln!(out); + let _ = writeln!(out, "host: {}", record.host); + let _ = writeln!(out, "build: {}", record.build); + let _ = writeln!(out, "recorded: {}", record.recorded_at); + let _ = writeln!(out, "schema: {}", record.schema_version); +} + +fn render_machine(out: &mut String, record: &SubmissionRecord) { + let machine = &record.machine; + let _ = writeln!(out); + let _ = writeln!(out, "-- the machine --"); + let _ = writeln!( + out, + "cpu: {}", + match (&machine.cpu_model, machine.model_suppressed) { + (Some(model), _) => model.clone(), + // Withheld and unreadable are different facts and are shown as + // such: a reader of a submission must not have to guess which. + (None, true) => "(withheld by the runner)".to_owned(), + (None, false) => "(this host would not say)".to_owned(), + } + ); + let _ = writeln!( + out, + "os build: {}", + machine.os_build.as_deref().unwrap_or("(unknown)") + ); + let _ = write!(out, "virtualisation: {}", machine.virtualisation); + match &machine.virtualisation_name { + Some(name) => { + let _ = writeln!(out, " ({name})"); + } + None => { + let _ = writeln!(out); + } + } +} + +fn render_placements(out: &mut String, record: &SubmissionRecord) { + let _ = writeln!(out); + let _ = writeln!(out, "-- the handoff, by placement --"); + + if record.placements.is_empty() { + let _ = writeln!( + out, + " Nothing was measured, which is a fault rather than a finding." + ); + return; + } + + let _ = writeln!( + out, + "{:<26} {:<10} {:>12} {:>12}", + "placement", "strategy", "ns/item", "batch depth" + ); + for entry in &record.placements { + let _ = writeln!( + out, + "{:<26} {:<10} {:>12.1} {:>12.1}", + entry.placement, entry.strategy, entry.nanos_per_item, entry.consumer_batch + ); + } + let _ = writeln!(out); + let _ = writeln!(out, "the slice each row was measured on:"); + for entry in &record.placements { + let _ = writeln!(out, " {:<26} {}", entry.placement, entry.slice); + } +} + +fn render_node_hops(out: &mut String, record: &SubmissionRecord) { + let _ = writeln!(out); + let _ = writeln!(out, "-- the handoff, by NUMA node pair --"); + + if record.node_hops.is_empty() { + // Not an apology. Every machine measured by the author so far reports + // one node, which is exactly why a submission from a multi-node host is + // worth asking for -- so the empty case says what it means. + let _ = writeln!( + out, + " This machine has one NUMA node, so there is no node crossing to" + ); + let _ = writeln!( + out, + " measure. That is a fact about the host, not a failed measurement." + ); + return; + } + + let _ = writeln!( + out, + "{:<14} {:<10} {:>12} {:>12}", + "node pair", "strategy", "ns/item", "batch depth" + ); + for entry in &record.node_hops { + let _ = writeln!( + out, + "{:<14} {:<10} {:>12.1} {:>12.1}", + format!( + "{} <-> {}", + entry.producer_numa_node, entry.consumer_numa_node + ), + entry.strategy, + entry.nanos_per_item, + entry.consumer_batch + ); + } +} + +fn render_trust(out: &mut String, record: &SubmissionRecord) { + let _ = writeln!(out); + let _ = writeln!(out, "-- how far to trust this --"); + + if record.is_fully_trusted() { + let _ = writeln!( + out, + " An official build, reading this machine's real topology." + ); + } else { + let _ = writeln!(out, " This run is marked, and here is why:"); + if !record.build.is_official() { + let _ = writeln!( + out, + " - the binary is not an official CI build ({})", + record.build + ); + } + if !record.topology_provenance.is_measured() { + let _ = writeln!( + out, + " - the topology is {}, not read from this machine", + record.topology_provenance + ); + } + let _ = writeln!( + out, + " The numbers are still real; they simply cannot be traced the way" + ); + let _ = writeln!(out, " an official run can, so say so when you send them."); + } + + // Stated on every run, not only untrusted ones. A long clean run is exactly + // when someone is most tempted to read more into it than it says. + let _ = writeln!(out); + let _ = writeln!( + out, + " What this does NOT establish: anything about memory ordering. These" + ); + let _ = writeln!( + out, + " are timing measurements, and timing cannot catch a weakened ordering." + ); +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-placement-probe/src/report/tests.rs b/crates/windows-placement-probe/src/report/tests.rs new file mode 100644 index 00000000..ab73cca8 --- /dev/null +++ b/crates/windows-placement-probe/src/report/tests.rs @@ -0,0 +1,172 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`render`](super::render). +//! +//! These assert that the report *reflects the record*, not that it contains any +//! particular wording. Asserting exact prose would make the text unchangeable +//! and would not check the property that matters, which is that the two cannot +//! disagree. + +use windows_topology_sys::Provenance; + +use super::render; +use crate::build_identity::BuildSource; +use crate::machine::VirtualisationHint; +use crate::record::tests::fully_populated; + +#[test] +fn the_report_shows_the_values_the_record_carries() { + // The core property: a reader comparing the printed text against the file + // must see the same numbers, because the text is a function of the file. + let record = fully_populated(); + let text = render(&record); + + assert!(text.contains(&record.recorded_at), "timestamp missing"); + assert!( + text.contains(&record.schema_version.to_string()), + "schema version missing" + ); + assert!( + text.contains("Example CPU"), + "the cpu model in the record is absent from the report" + ); + assert!( + text.contains("10.0.26200.9168"), + "the os build in the record is absent from the report" + ); + for entry in &record.placements { + assert!( + text.contains(&entry.placement), + "placement {} is absent from the report", + entry.placement + ); + assert!( + text.contains(&entry.slice), + "the slice for {} is absent, so a number cannot be traced to its processors", + entry.placement + ); + } +} + +#[test] +fn a_withheld_model_reads_differently_from_an_unreadable_one() { + // The distinction the record keeps must survive into the text, or the + // report would flatten two different facts into one blank. + let mut withheld = fully_populated(); + withheld.machine.cpu_model = None; + withheld.machine.model_suppressed = true; + + let mut unreadable = fully_populated(); + unreadable.machine.cpu_model = None; + unreadable.machine.model_suppressed = false; + + let withheld = render(&withheld); + let unreadable = render(&unreadable); + + assert!(withheld.contains("withheld"), "got {withheld}"); + assert!(!unreadable.contains("withheld"), "got {unreadable}"); +} + +#[test] +fn a_single_node_machine_says_why_there_are_no_hops() { + // Every host measured so far is single-node, so this is the common case and + // must not read as a failure -- a runner who thinks the tool broke will not + // send the file. + let mut record = fully_populated(); + record.node_hops.clear(); + + let text = render(&record); + + assert!(text.contains("one NUMA node"), "got {text}"); + // Deliberately not a substring search for "failed measurement": the report + // says "not a failed measurement", and an assertion that matched inside + // that denial would fail on correct text. An earlier revision of this test + // did exactly that. + assert!( + !text.to_lowercase().contains("error"), + "an ordinary single-node machine was reported as an error: {text}" + ); + assert!( + text.contains("fact about the host"), + "the empty case must explain itself rather than merely being empty: {text}" + ); +} + +#[test] +fn a_fully_trusted_run_is_not_marked() { + let text = render(&fully_populated()); + + assert!(text.contains("official build"), "got {text}"); +} + +#[test] +fn an_untrusted_run_names_each_reason_separately() { + // "Something is wrong" is not actionable. A reader triaging a surprising + // submission needs to know whether the build or the topology was the + // problem, and both can be true at once. + let mut record = fully_populated(); + record.build.source = BuildSource::Local; + record.topology_provenance = Provenance::Synthetic; + record.host.provenance = Provenance::Synthetic; + + let text = render(&record); + + assert!(text.contains("not an official CI build"), "got {text}"); + assert!(text.contains("not read from this machine"), "got {text}"); +} + +#[test] +fn the_ordering_caveat_is_stated_even_on_a_clean_run() { + // A long clean run is exactly when someone is most tempted to read it as + // validation of something it never touched. + let text = render(&fully_populated()); + + assert!( + text.contains("memory ordering"), + "the ordering caveat must appear on every run, including trusted ones" + ); +} + +#[test] +fn a_detected_hypervisor_is_named_in_the_report() { + let text = render(&fully_populated()); + + assert!(text.contains("Example Hypervisor"), "got {text}"); +} + +#[test] +fn a_report_with_no_measurements_calls_that_a_fault() { + // An empty table with no comment would read as "measured, nothing to say". + let mut record = fully_populated(); + record.placements.clear(); + + let text = render(&record); + + assert!(text.contains("fault"), "got {text}"); +} + +#[test] +fn changing_a_record_changes_the_report() { + // Guards against a report that renders constants. If this ever passes with + // identical text, the report has stopped being a function of the record. + let record = fully_populated(); + let mut changed = record.clone(); + changed.placements[0].nanos_per_item = 999.9; + + assert_ne!(render(&record), render(&changed)); + assert!(render(&changed).contains("999.9")); +} + +#[test] +fn a_virtualisation_hint_is_not_rendered_as_a_certainty() { + let mut record = fully_populated(); + record.machine.virtualisation = VirtualisationHint::NotDetected; + record.machine.virtualisation_name = None; + + let text = render(&record); + + assert!( + text.contains("not detected"), + "a negative must read as 'not detected' rather than as 'bare metal': {text}" + ); + assert!(!text.contains("bare metal"), "got {text}"); +} From a63061b80aea85d34b4a8fb90a92c93db790571b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 15:22:42 -0400 Subject: [PATCH 069/361] feat(placement-probe): make the terminal output the submission Collection happens by pasting a run into a GitHub Discussions thread, so the terminal output is the submission and must survive the paste. This reverses PT-3.5's original reasoning, which said to write a file because copying terminal output invites truncation. That risk does not disappear by choosing a channel; it has to be mitigated. The target is select-all, copy, paste, done. The tool emits its own markdown fences, so a runner who has never thought about markdown pastes the whole thing and it renders as a code block regardless. A few instruction lines caught inside the fence are trivial noise beside a paste that renders as mangled prose. A file is still written, but as a backup rather than a required step. A checksum makes a truncated or reflowed paste detectable rather than silently half-ingested -- the same principle as the schema golden. FNV-1a, no dependency, and the output says outright that it is not a security control, because a digest printed without qualification invites a reader to assume it proves more than it does. Two defects the fixture had been hiding, both found by rendering a real run rather than by reading a green test. The slice list repeated every placement once per strategy, when every strategy of a placement runs on the same processors. And real slice lines reach 119 characters where the fixture's reached 94 -- so the line-width guard passed while the tool emitted lines half again as long. The fixture now carries a slice copied verbatim from a run, which made the guard fail honestly before the layout was fixed. The width bound is split rather than loosened: the human report is held to 100 columns because every line there is something the layout chose, while the whole submission is held to 120 because the JSON carries string values that cannot be shortened without losing what the record exists to say. Completed item: PT-3.5: The terminal output is the submission. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 32 ++- crates/windows-placement-probe/Cargo.toml | 8 +- crates/windows-placement-probe/src/lib.rs | 2 + .../src/record/tests.rs | 6 +- crates/windows-placement-probe/src/report.rs | 15 +- .../windows-placement-probe/src/submission.rs | 103 ++++++++++ .../src/submission/tests.rs | 184 ++++++++++++++++++ 7 files changed, 340 insertions(+), 10 deletions(-) create mode 100644 crates/windows-placement-probe/src/submission.rs create mode 100644 crates/windows-placement-probe/src/submission/tests.rs diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index d6dfd4a1..26ec83a3 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -252,9 +252,29 @@ that carries them is written. values so they cannot disagree. The reader running the tool should be able to see, in prose, the same conclusion the record encodes -- otherwise nobody notices when a run is nonsense. -- [ ] **PT-3.5** -- Write the record to a **file** by default, named predictably, and tell the user - exactly where it is and what to do with it. Asking someone to copy terminal output invites truncated - and reflowed submissions. +- [x] **PT-3.5** -- **The terminal output is the submission.** Collection happens by asking people to + paste a run into a GitHub Discussions thread on this repository, so the paste is the channel and the + whole record must survive it. + **This reverses this item's original reasoning, and the reversal is the point.** It previously said + to write a file *because* copying terminal output invites truncated and reflowed submissions. That + risk is real and does not go away by choosing a different channel -- it has to be *mitigated* rather + than avoided: + - **Everything needed is on screen.** The record prints to stdout, not only to a file. A submission + that requires the sender to find and attach a file will sometimes arrive without it. + - **A self-check the reader can run.** A short checksum over the record, printed beside it, so a + truncated or reflowed paste is *detectable* rather than silently half-ingested. This is the same + principle as the schema golden: detect corruption instead of trusting the channel. + - **Paste-safe formatting.** GitHub renders Discussions as markdown, so the output must survive a + fenced code block and must not depend on colour, cursor control, or overlong lines that wrap. + - **Tell the runner exactly what to do**, in the output itself: which thread, and to paste inside a + fenced block. An instruction that lives only in a README is an instruction half of them will not + have read. + **The target is select-all, copy, paste, done.** Every extra step is a submission that does not + arrive, so the tool emits its own markdown fences: a runner who has never thought about markdown + pastes the whole thing and it renders as a code block anyway. Instructions caught inside the fence + are trivial noise next to a paste that renders as mangled prose. + A file is still written, because it costs nothing and someone will prefer to attach one -- but it is + a backup, never a required step, and the run must be complete and submittable without it. - [x] **PT-3.6** -- Read the three machine-description fields PT-1.2 settled, each of which needs a source this crate does not currently use. **Every one of them is optional in the record**, so a host @@ -281,6 +301,12 @@ that carries them is written. times two million items, on top of the placements. On a four-node machine that is a materially longer run than on this one, and the person deserves to know before it starts. +- [ ] **PT-4.6** -- **Set up the Discussions thread people paste into, and link it from the tool.** + The tool's output names where to send a result, so that destination has to exist before the tool + ships, not after -- an instruction pointing at a thread that is not there is worse than no + instruction. Pin it, and state in the first post what is collected and what a submission is used + for, so a reader who arrives from a search rather than from the README still sees it. + - [ ] **PT-4.3** -- **Say exactly what is collected and what is not**, in the tool's own output and in its README, and make it verifiable by reading the record. Collected, per PT-1.2: core/cache/NUMA shape, timings, CPU model, OS build, and the virtualisation hint. **Not** collected: hostname, user diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index cd00c2ba..7d1a94eb 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -32,11 +32,9 @@ serde = ["dep:serde", "windows-topology-sys/serde"] windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys" } windows-waitable-queues = { version = "0.1.0", path = "../windows-waitable-queues" } serde = { version = "1.0", features = ["derive"], optional = true } - -[dev-dependencies] -# Only the tests need a serializer: the schema golden is derived by serializing -# a record and walking the result, rather than by restating its key paths in a -# list somebody has to remember to update. +# The tool emits the record itself, so a serializer is not a test-only concern. +# It is also what derives the schema golden: the archived shape comes from +# serializing a record and walking the result, never from a hand-kept list. serde_json = "1.0" [dependencies.windows-sys] diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index a6b46d7a..dfc864f5 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -65,3 +65,5 @@ pub mod peer_index_cache; pub mod record; /// The human-readable report, rendered from the record. pub mod report; +/// Turning a run into something a person can paste into a discussion thread. +pub mod submission; diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index c50ca6cc..6dff0df8 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -22,7 +22,11 @@ pub(crate) fn fully_populated() -> SubmissionRecord { let measurement = MeasurementRecord { placement: "SMT siblings (one core)".to_owned(), strategy: "baseline".to_owned(), - slice: "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0".to_owned(), + // A real slice, copied verbatim from a run. An earlier fixture used a + // shortened one, which let a line-width test pass while the tool emitted + // lines half again as long. + slice: "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0 [same-cache,same-class]" + .to_owned(), producer_group: 0, producer_number: 0, producer_numa_node: 0, diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index c290f99b..309a0eff 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -99,8 +99,21 @@ fn render_placements(out: &mut String, record: &SubmissionRecord) { } let _ = writeln!(out); let _ = writeln!(out, "the slice each row was measured on:"); + // One entry per *placement*, not per row. Every strategy of a placement runs + // on the same processors, so listing them per row repeats each slice as many + // times as there are strategies and says nothing new. + let mut shown: Vec<&str> = Vec::new(); for entry in &record.placements { - let _ = writeln!(out, " {:<26} {}", entry.placement, entry.slice); + if shown.contains(&entry.placement.as_str()) { + continue; + } + shown.push(&entry.placement); + // The slice goes on its own line rather than beside the label: a real + // slice names two processors with five fields each, and a single line + // carrying both a label and that reaches about 120 characters, which + // wraps on a normal terminal and risks being reflowed on paste. + let _ = writeln!(out, " {}", entry.placement); + let _ = writeln!(out, " {}", entry.slice); } } diff --git a/crates/windows-placement-probe/src/submission.rs b/crates/windows-placement-probe/src/submission.rs new file mode 100644 index 00000000..943a54c9 --- /dev/null +++ b/crates/windows-placement-probe/src/submission.rs @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Mike Grier +//! Turning a run into something a person can paste into a discussion thread. +//! +//! # The paste is the channel +//! +//! Results are collected by asking people to paste a run into a GitHub +//! Discussions thread. That makes the terminal output the submission, so it has +//! to survive being selected, copied, and dropped into a comment box. +//! +//! **The target is select-all, copy, paste, done.** Every additional step is a +//! submission that does not arrive, which is why this module emits its own +//! markdown fences: a runner who has never thought about markdown pastes the +//! whole thing and it renders as a code block regardless. A few lines of +//! instruction caught inside the fence are trivial noise beside a paste that +//! renders as mangled prose. +//! +//! # Why a checksum +//! +//! A paste can be truncated by a scrollback limit, reflowed by a narrow +//! terminal, or half-selected by a mouse. The checksum makes that **detectable** +//! rather than silently half-ingested -- the same principle as the schema +//! golden, which detects a shape change rather than trusting nobody caused one. +//! +//! It is a non-cryptographic digest and defends against accident, not against a +//! person who wants to submit a false record. That threat is not in scope: the +//! cost of a fabricated placement measurement is a wrong row in a table that +//! disagrees with every other host, which is visible. + +use crate::record::SubmissionRecord; +use crate::report; + +/// Where a runner is asked to send a result. +pub const DISCUSSION_URL: &str = "https://github.com/MikeGrier/windows-threadpool-sys/discussions"; + +/// A 64-bit FNV-1a digest, rendered as sixteen hex characters. +/// +/// FNV-1a because it needs no dependency, is a dozen lines, and is entirely +/// adequate for catching a truncated or reflowed paste. It is **not** a +/// security control and the output says so rather than letting a reader assume +/// otherwise. +#[must_use] +pub fn checksum(bytes: &[u8]) -> String { + const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + + let mut hash = OFFSET; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(PRIME); + } + format!("{hash:016x}") +} + +/// The complete text a runner is asked to paste. +/// +/// # Errors +/// +/// Returns whatever serializing the record failed with. +pub fn render_submission(record: &SubmissionRecord) -> Result { + // Pretty-printed rather than compact. A collector parses either, but a + // *person* is being asked to look at this before sending it, and a single + // enormous line is both unreadable and the most likely thing a terminal + // will wrap. + let json = serde_json::to_string_pretty(record)?; + let digest = checksum(json.as_bytes()); + + let mut out = String::new(); + out.push_str("Paste EVERYTHING below into a reply at:\n"); + out.push_str(DISCUSSION_URL); + out.push_str("\n\n"); + + // The fence is emitted by the tool so the runner does not have to know it + // is needed. + out.push_str("```text\n"); + out.push_str(&report::render(record)); + out.push_str("\n-- the record --\n"); + out.push_str("(checksum "); + out.push_str(&digest); + out.push_str(", FNV-1a over the JSON below; it catches a truncated or\n"); + out.push_str("reflowed paste, and is not a security control)\n\n"); + out.push_str(&json); + out.push_str("\n```\n"); + + Ok(out) +} + +/// A predictable file name for the same record. +/// +/// Includes the timestamp so a second run does not overwrite a first, and the +/// schema version so a directory of collected files can be sorted by shape +/// without opening any of them. +#[must_use] +pub fn file_name(record: &SubmissionRecord) -> String { + let stamp: String = record + .recorded_at + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + format!("placement-probe-v{}-{}.json", record.schema_version, stamp) +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-placement-probe/src/submission/tests.rs b/crates/windows-placement-probe/src/submission/tests.rs new file mode 100644 index 00000000..16c09000 --- /dev/null +++ b/crates/windows-placement-probe/src/submission/tests.rs @@ -0,0 +1,184 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for the paste-ready submission. + +use super::{DISCUSSION_URL, checksum, file_name, render_submission}; +use crate::record::tests::fully_populated; + +#[test] +fn the_submission_carries_its_own_markdown_fences() { + // The whole friction argument rests on this: a runner who has never thought + // about markdown must be able to select all, copy, paste, and have it + // render as a code block. + let text = render_submission(&fully_populated()).expect("must render"); + + assert!(text.contains("```text\n"), "no opening fence"); + assert!(text.trim_end().ends_with("```"), "no closing fence"); + assert_eq!( + text.matches("```").count(), + 2, + "exactly one fenced block, or the paste will render half as prose" + ); +} + +#[test] +fn the_submission_names_where_to_send_it() { + // An instruction that lives only in a README is one that half of them will + // not have read. + let text = render_submission(&fully_populated()).expect("must render"); + + assert!(text.contains(DISCUSSION_URL), "got {text}"); + assert!( + text.lines() + .next() + .is_some_and(|line| line.contains("Paste")), + "the instruction must be the first thing on screen" + ); +} + +#[test] +fn the_submission_contains_both_the_report_and_the_record() { + // Everything needed on screen: a submission that requires the sender to + // find a file will sometimes arrive without it. + let record = fully_populated(); + let text = render_submission(&record).expect("must render"); + + assert!( + text.contains("what does thread placement cost"), + "the human report is missing" + ); + assert!( + text.contains("\"schema_version\""), + "the machine-readable record is missing" + ); + assert!( + text.contains("Example CPU"), + "the machine description is missing" + ); +} + +#[test] +fn the_checksum_is_printed_and_matches_the_json_that_follows_it() { + // Guards the thing that makes the checksum worth having: it must be a + // digest of what was actually emitted, not of something adjacent. + let record = fully_populated(); + let text = render_submission(&record).expect("must render"); + + let json = serde_json::to_string_pretty(&record).expect("must serialize"); + let expected = checksum(json.as_bytes()); + + assert!( + text.contains(&expected), + "the printed checksum does not match the emitted JSON" + ); + assert!( + text.contains(&json), + "the JSON in the text is not the record" + ); +} + +#[test] +fn the_checksum_changes_when_the_record_does() { + // A digest that did not move with its input would be worse than none, since + // a reader would trust it. + let a = fully_populated(); + let mut b = fully_populated(); + b.placements[0].nanos_per_item = 42.0; + + let json_a = serde_json::to_string_pretty(&a).expect("must serialize"); + let json_b = serde_json::to_string_pretty(&b).expect("must serialize"); + + assert_ne!(checksum(json_a.as_bytes()), checksum(json_b.as_bytes())); +} + +#[test] +fn the_checksum_catches_a_truncated_paste() { + // The failure actually being defended against: a scrollback limit or a + // half-dragged selection. + let json = serde_json::to_string_pretty(&fully_populated()).expect("must serialize"); + let truncated = &json[..json.len() / 2]; + + assert_ne!(checksum(json.as_bytes()), checksum(truncated.as_bytes())); +} + +#[test] +fn the_checksum_is_stable_for_identical_input() { + let bytes = b"placement"; + + assert_eq!(checksum(bytes), checksum(bytes)); + assert_eq!(checksum(bytes).len(), 16, "sixteen hex characters"); + assert!(checksum(bytes).chars().all(|c| c.is_ascii_hexdigit())); +} + +#[test] +fn the_checksum_is_described_as_not_a_security_control() { + // A digest printed without qualification invites a reader to assume it + // proves more than it does. + let text = render_submission(&fully_populated()).expect("must render"); + + assert!( + text.contains("not a security control"), + "the checksum must not overstate what it establishes" + ); +} + +#[test] +fn the_human_report_stays_within_a_narrow_terminal() { + // Not producing an overlong line beats detecting a reflowed one, and this + // half of the output is entirely under our control -- every line is + // something the report chose to lay out that way. + let text = crate::report::render(&fully_populated()); + + for line in text.lines() { + assert!( + line.chars().count() <= 100, + "a {}-character report line will wrap on a normal terminal: {line:?}", + line.chars().count() + ); + } +} + +#[test] +fn the_whole_submission_stays_within_a_wide_terminal() { + // A looser bound, and deliberately so: the JSON's own lines carry string + // *values* -- a slice names two processors with five fields each -- and + // those cannot be shortened without removing information the record exists + // to carry. 120 is the width this settles on, with the human half held to + // the stricter bound above. + let text = render_submission(&fully_populated()).expect("must render"); + + for line in text.lines() { + assert!( + line.chars().count() <= 120, + "a {}-character line is long enough to be reflowed on paste: {line:?}", + line.chars().count() + ); + } +} + +#[test] +fn the_file_name_is_predictable_and_safe_for_a_filesystem() { + let record = fully_populated(); + let name = file_name(&record); + + assert!(name.starts_with("placement-probe-v1-"), "got {name}"); + assert!(name.ends_with(".json"), "got {name}"); + assert!( + !name.contains(':'), + "a colon is not valid in a Windows file name: {name}" + ); + assert!( + name.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.'), + "got {name}" + ); +} + +#[test] +fn two_runs_do_not_collide_on_one_file_name() { + // Overwriting a previous result silently is a data loss nobody notices. + let first = fully_populated(); + let mut second = fully_populated(); + second.recorded_at = "2026-09-01T13:00:00Z".to_owned(); + + assert_ne!(file_name(&first), file_name(&second)); +} From 336136cce77289262db86c926eadd1f9c479b0bc Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 15:45:54 -0400 Subject: [PATCH 070/361] feat(placement-probe): add the single entry point a runner is asked to execute One binary, one run, one paste. The internal probes stay separate because a development loop wants the opposite, but a stranger doing a favour must not be asked to run three things and collate them. Before measuring, the tool states what it collects using the values it just read rather than a promise about them, and says what it does not collect as a commitment. --preview shows all of that and measures nothing, so someone can decide with the real values in front of them without first spending the run. --no-cpu-model withholds the model, and says in the same breath that this does not make confidential hardware safe to submit, because the topology describes the part whether or not it is named. Two defects found by running it rather than by reading tests. The runtime estimate said "roughly 1-8 seconds" and the run took 0.6 -- under its own floor. A floor is the useless half of that promise: someone deciding whether to start needs the worst case, and finishing early is never the failure. It is now a single upper bound taken from the slowest per-item cost measured across these hosts. The plan also over-counted. It reported 18 timed handoffs where the run performed 12, because it counted efficiency classes while the run only measures a class that has a usable within-class pair -- which this host has none of. The predicate is now extracted and both the plan and the run call it, so an estimate cannot promise a comparison the run then skips. Restating it in the plan would have been a second copy of one rule, which is how the two drift. The pin-failure message is rewritten for a stranger: it names the processor, gives the OS error, offers the usual cause, and says outright that the run stops rather than measuring unpinned, because an unpinned thread measures wherever the scheduler put it and would produce a plausible number answering a different question. It asks for the message to be reported, since a failure here is genuinely informative. That message is a raw string rather than escaped continuations: cargo fmt reindents a multi-line literal and the backslash continuations then swallow the blank lines, collapsing a laid-out message into one paragraph. Clippy caught it as "multiple lines skipped by escaped newline". Completed item: PT-4.1: One entry point. Completed item: PT-4.2: State the runtime before doing the work. Completed item: PT-4.3: Say exactly what is collected and what is not. Completed item: PT-4.4: Pin the thread-pinning failure behaviour for a stranger's machine. Completed item: PT-4.5: Let the runner see everything before sending it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 10 +- crates/windows-placement-probe/Cargo.toml | 7 + .../src/bin/placement_probe.rs | 216 ++++++++++++++++++ .../src/core_affinity.rs | 128 +++++++++-- .../src/peer_index_cache.rs | 26 ++- 5 files changed, 362 insertions(+), 25 deletions(-) create mode 100644 crates/windows-placement-probe/src/bin/placement_probe.rs diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 26ec83a3..c3510a3d 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -292,11 +292,11 @@ that carries them is written. ## M4: the runner's experience, and their trust -- [ ] **PT-4.1** -- **One entry point.** A single binary that runs everything and produces one record. +- [x] **PT-4.1** -- **One entry point.** A single binary that runs everything and produces one record. "Run these three and send me all three outputs" is friction for someone doing a favour, and invites partial submissions that cannot be compared. -- [ ] **PT-4.2** -- **State the runtime before doing the work**, from the discovered topology rather +- [x] **PT-4.2** -- **State the runtime before doing the work**, from the discovered topology rather than a guess: the hop matrix alone is `n*(n-1)/2` hops times two strategies times three repetitions times two million items, on top of the placements. On a four-node machine that is a materially longer run than on this one, and the person deserves to know before it starts. @@ -307,7 +307,7 @@ that carries them is written. instruction. Pin it, and state in the first post what is collected and what a submission is used for, so a reader who arrives from a search rather than from the README still sees it. -- [ ] **PT-4.3** -- **Say exactly what is collected and what is not**, in the tool's own output and in +- [x] **PT-4.3** -- **Say exactly what is collected and what is not**, in the tool's own output and in its README, and make it verifiable by reading the record. Collected, per PT-1.2: core/cache/NUMA shape, timings, CPU model, OS build, and the virtualisation hint. **Not** collected: hostname, user name, file paths, environment variables, serial numbers, or anything about installed software -- @@ -317,13 +317,13 @@ that carries them is written. the honest limit from PT-1.2, that the flag does not make confidential hardware safe to submit, because the topology describes the part regardless. -- [ ] **PT-4.4** -- Pin the thread-pinning failure behaviour for a stranger's machine. It currently +- [x] **PT-4.4** -- Pin the thread-pinning failure behaviour for a stranger's machine. It currently panics, which is right for us (a silently unpinned thread measures the scheduler, not the placement) but reads as a crash to someone doing a favour. It must fail with an explanation of what could not be pinned and why the run cannot continue honestly -- **and must not fall back to an unpinned measurement**, which would produce a plausible number that means nothing. -- [ ] **PT-4.5** -- **Let the runner see everything before sending it, and decide with the real values +- [x] **PT-4.5** -- **Let the runner see everything before sending it, and decide with the real values rather than a promise.** This is a stronger privacy property than any suppression flag, and cheaper: the record is a text file, so the honest instruction is "open it and read it -- if you are not happy with something in there, do not send it." diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index 7d1a94eb..547a9c4b 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -21,6 +21,13 @@ description = "Measures what thread placement costs on a Windows machine -- SMT [lib] path = "src/lib.rs" +# One binary, deliberately. A stranger doing a favour must not be asked to run +# three things and collate them; the internal probes in windows-platform-probes +# stay separate because a development loop wants the opposite. +[[bin]] +name = "placement-probe" +path = "src/bin/placement_probe.rs" + [features] # The submission record is the product, so serialization is not optional the way # it is in the layers below. The feature exists so the measurement code can be diff --git a/crates/windows-placement-probe/src/bin/placement_probe.rs b/crates/windows-placement-probe/src/bin/placement_probe.rs new file mode 100644 index 00000000..80bf5e9f --- /dev/null +++ b/crates/windows-placement-probe/src/bin/placement_probe.rs @@ -0,0 +1,216 @@ +// Copyright (c) 2026 Mike Grier +//! The one thing a runner is asked to execute. +//! +//! **One binary, one run, one paste.** "Run these three and send me all three +//! outputs" is friction for someone doing a favour, and invites partial +//! submissions that cannot be compared with each other. + +use std::process::ExitCode; + +use windows_placement_probe::core_affinity::{self, RunPlan}; +use windows_placement_probe::fingerprint::{Fingerprint, discover_places}; +use windows_placement_probe::machine::MachineDescription; +use windows_placement_probe::record::SubmissionRecord; +use windows_placement_probe::submission::{self, DISCUSSION_URL}; + +/// What the run was asked to do. +struct Options { + /// Show what would be collected, and measure nothing. + preview: bool, + /// Withhold the CPU model. + suppress_model: bool, + /// Skip writing the backup file. + no_file: bool, +} + +fn main() -> ExitCode { + let options = match parse_arguments() { + Ok(options) => options, + Err(message) => { + eprintln!("{message}"); + return ExitCode::FAILURE; + } + }; + + let machine = MachineDescription::read(options.suppress_model); + + let places = match discover_places() { + Ok(places) => places, + Err(error) => { + eprintln!("could not read this machine's topology: {error}"); + return ExitCode::FAILURE; + } + }; + let plan = RunPlan::for_processors(&places); + + print_collection_notice(&machine, options.suppress_model); + print_plan(&plan); + + if options.preview { + println!(); + println!("Preview only -- nothing was measured and no file was written."); + println!("Run again without --preview to take the measurement."); + return ExitCode::SUCCESS; + } + + println!(); + println!("Measuring. This machine will be busy until it finishes."); + println!(); + + let observation = match core_affinity::measure() { + Ok(observation) => observation, + Err(error) => { + eprintln!("the measurement could not run: {error}"); + return ExitCode::FAILURE; + } + }; + let host = match Fingerprint::discover() { + Ok(host) => host, + Err(error) => { + eprintln!("could not read this machine's shape: {error}"); + return ExitCode::FAILURE; + } + }; + + let record = SubmissionRecord::new(&observation, host, machine); + let text = match submission::render_submission(&record) { + Ok(text) => text, + Err(error) => { + eprintln!("the record could not be written out: {error}"); + return ExitCode::FAILURE; + } + }; + + if !options.no_file { + write_backup(&record); + } + + print!("{text}"); + ExitCode::SUCCESS +} + +/// State what is collected **before** the run, not after. +/// +/// A person deciding whether to do this a favour should be able to decide with +/// the real values in front of them rather than a promise about them, which is +/// why the preview exists and why this prints what was actually read. +fn print_collection_notice(machine: &MachineDescription, suppressed: bool) { + println!("== windows-placement-probe =="); + println!(); + println!("This measures what thread placement costs on your machine, and prints"); + println!("a result you can paste into a discussion thread. It makes no network"); + println!("connections; sending the result is your decision and your action."); + println!(); + println!("What it collects about this machine, as read just now:"); + println!( + " cpu model {}", + match (&machine.cpu_model, suppressed) { + (Some(model), _) => model.as_str(), + (None, true) => "(withheld: --no-cpu-model)", + (None, false) => "(this host would not say)", + } + ); + println!( + " os build {}", + machine.os_build.as_deref().unwrap_or("(unknown)") + ); + println!( + " virtualisation {}{}", + machine.virtualisation, + match &machine.virtualisation_name { + Some(name) => format!(" ({name})"), + None => String::new(), + } + ); + println!(" topology processor, core, cache and NUMA layout"); + println!(" timings how long a handoff takes at each placement"); + println!(); + println!("What it does NOT collect: your host name, your user name, file paths,"); + println!("environment variables, serial numbers, or anything about installed"); + println!("software. Read the printed record before sending it -- if you are not"); + println!("happy with something in it, do not send it."); + if !suppressed { + println!(); + println!("Pass --no-cpu-model to withhold the model. Note that it does not make"); + println!("confidential hardware safe to submit: the topology describes the part"); + println!("whether or not it is named."); + } +} + +fn print_plan(plan: &RunPlan) { + println!(); + println!("-- what this run will do --"); + println!(" {:>3} placement(s) on this machine", plan.placements); + println!(" {:>3} NUMA node pair(s)", plan.node_hops); + println!(" {:>3} efficiency class comparison(s)", plan.classes); + println!( + " {:>3} timed handoffs in total ({} strategies x {} repetitions)", + plan.timed_runs(), + plan.strategies, + plan.repetitions + ); + println!(); + println!( + " Should take under {:.0} seconds, and usually much less. That is an", + plan.estimated_seconds().ceil().max(1.0) + ); + println!(" upper bound taken from the slowest machine measured so far -- how long"); + println!(" a handoff takes is the thing being measured, so it cannot be exact."); +} + +/// Write the record beside the report, as a convenience rather than a step. +/// +/// A failure here is reported and does not fail the run: the submission is the +/// text on screen, and losing the backup copy costs nothing that matters. +fn write_backup(record: &SubmissionRecord) { + let name = submission::file_name(record); + match serde_json::to_string_pretty(record) { + Ok(json) => match std::fs::write(&name, json) { + Ok(()) => println!("(a copy of the record was also written to {name})"), + Err(error) => { + println!("(could not write {name}: {error} -- paste the text below instead)") + } + }, + Err(error) => println!("(could not serialize the record to a file: {error})"), + } +} + +fn parse_arguments() -> Result { + let mut options = Options { + preview: false, + suppress_model: false, + no_file: false, + }; + + for argument in std::env::args().skip(1) { + match argument.as_str() { + "--preview" => options.preview = true, + "--no-cpu-model" => options.suppress_model = true, + "--no-file" => options.no_file = true, + "--help" | "-h" => return Err(help()), + other => { + return Err(format!("unrecognised argument {other:?}\n\n{}", help())); + } + } + } + + Ok(options) +} + +fn help() -> String { + format!( + "windows-placement-probe -- measures what thread placement costs\n\ + \n\ + USAGE:\n\ + \x20 placement-probe [OPTIONS]\n\ + \n\ + OPTIONS:\n\ + \x20 --preview Show what would be collected and measure nothing.\n\ + \x20 --no-cpu-model Withhold the CPU model from the record.\n\ + \x20 --no-file Do not write the backup copy of the record.\n\ + \x20 -h, --help Print this message.\n\ + \n\ + Results are collected at:\n\ + \x20 {DISCUSSION_URL}\n" + ) +} diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index 0f9f216c..dbb082b8 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -70,7 +70,112 @@ use crate::peer_index_cache::{ITEMS, Strategy, time_model_on}; /// Repetitions per placement; the median is reported. /// /// Odd, so the median is an observation rather than an average of two. -const REPETITIONS: usize = 3; +pub const REPETITIONS: usize = 3; + +/// The within-class pair a run would measure for one efficiency class, if any. +/// +/// **Extracted so the plan and the run bind to one definition.** Restating this +/// predicate in [`RunPlan`] would be a second copy of a rule, and the two would +/// drift -- the plan would promise a comparison the run then skipped, or the +/// reverse. Here the plan asks the same function the run uses. +/// +/// Requires different *cores*, not merely different processors: on an SMT host +/// one class might otherwise be measured as siblings and another as two cores, +/// and the comparison between classes would be measuring the placement +/// difference instead. +#[must_use] +fn within_class_pair( + places: &[ProcessorPlace], + class: u8, +) -> Option<(ProcessorPlace, ProcessorPlace)> { + let members: Vec<&ProcessorPlace> = places + .iter() + .filter(|place| place.efficiency_class == class) + .collect(); + + members + .iter() + .flat_map(|a| members.iter().map(move |b| (**a, **b))) + .find(|(a, b)| a.core != b.core && a.cache_domain == b.cache_domain) +} + +/// Every efficiency class this machine has. +#[must_use] +fn efficiency_classes(places: &[ProcessorPlace]) -> Vec { + let mut classes: Vec = places.iter().map(|place| place.efficiency_class).collect(); + classes.sort_unstable(); + classes.dedup(); + classes +} + +/// What a run on this machine will involve, worked out before any of it starts. +/// +/// # Why this is computed rather than estimated +/// +/// A person is being asked to give up minutes of their machine as a favour, and +/// on a large multi-socket host the hop matrix alone grows as `n*(n-1)/2`. The +/// *counts* here are exact -- they come from the same selection the run will +/// use -- so only the per-run duration is approximate, and it is presented as a +/// range rather than a single confident number. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RunPlan { + /// Placements this machine can express. + pub placements: usize, + /// Distinct NUMA node pairs. + pub node_hops: usize, + /// Efficiency classes compared like with like. + pub classes: usize, + /// Strategies measured per selection. + pub strategies: usize, + /// Repetitions per strategy. + pub repetitions: usize, +} + +impl RunPlan { + /// Work out what a run on these processors will involve. + #[must_use] + pub fn for_processors(places: &[ProcessorPlace]) -> Self { + Self { + placements: representative_pairs(places).len(), + node_hops: node_pairs(places).len(), + // Exact, not an upper bound: a class is only measured when it has a + // usable within-class pair, and this asks the same function the run + // will. Counting classes instead would over-report on any host + // whose classes have no such pair -- which is this workspace's own + // x64 host, where it inflated the count by half. + classes: efficiency_classes(places) + .into_iter() + .filter(|class| within_class_pair(places, *class).is_some()) + .count(), + strategies: 2, + repetitions: REPETITIONS, + } + } + + /// How many timed handoffs the run performs. + #[must_use] + pub fn timed_runs(self) -> usize { + (self.placements + self.node_hops + self.classes) * self.strategies * self.repetitions + } + + /// How long the run should take at most, in seconds. + /// + /// **An upper bound rather than a range, and that is a correction.** An + /// earlier version quoted a low-to-high range, and the first real run + /// finished in 0.6 s against a stated "roughly 1-8 seconds" -- under its own + /// floor. A floor is the useless half of the promise anyway: someone + /// deciding whether to start a favour needs to know the worst case, and + /// finishing early is never the failure. + /// + /// 220 ns/item is the slowest per-item cost measured across the hosts this + /// has run on, seen crossing a distant domain. A machine slower than that + /// will overrun the estimate, and its result is exactly the one worth + /// having. + #[must_use] + pub fn estimated_seconds(self) -> f64 { + (self.timed_runs() * ITEMS) as f64 * 220e-9 + } +} /// How a producer and a consumer are placed relative to each other. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] @@ -382,23 +487,10 @@ pub fn measure() -> std::io::Result { // cores faster at this" is answerable and not folded into a single // same-class row. let mut by_class = Vec::new(); - let mut classes: Vec = processors.iter().map(|p| p.efficiency_class).collect(); - classes.sort_unstable(); - classes.dedup(); - for class in classes { - let members: Vec<_> = processors - .iter() - .filter(|p| p.efficiency_class == class) - .collect(); - let Some((producer, consumer)) = members - .iter() - .flat_map(|a| members.iter().map(move |b| (**a, **b))) - // Different cores, not merely different processors: on an SMT host - // one class might otherwise be measured as siblings and the other - // as two cores, and the comparison between classes would be - // measuring the placement difference instead. - .find(|(a, b)| a.core != b.core && a.cache_domain == b.cache_domain) - else { + for class in efficiency_classes(&processors) { + // The same function `RunPlan` asks, so the estimate a runner is shown + // cannot promise a comparison this loop then skips. + let Some((producer, consumer)) = within_class_pair(&processors, class) else { continue; }; for strategy in [Strategy::Baseline, Strategy::Cached] { diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index 8f2092bd..d62be90c 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -341,10 +341,32 @@ fn pin_current_thread(cpu: Option<(u16, u8)>) { // processor the caller took from the discovered topology, and the previous // affinity is not wanted, so a null pointer is passed for it. let ok = unsafe { SetThreadGroupAffinity(GetCurrentThread(), &affinity, ptr::null_mut()) }; + // A raw string rather than an escaped-continuation one: `cargo fmt` + // reindents a multi-line string literal and the backslash continuations + // then swallow the blank lines, which turns a carefully laid-out message + // into one paragraph. This is the message a stranger sees when the tool + // gives up, so its shape matters. assert!( ok != 0, - "SetThreadGroupAffinity failed for group {group} processor {number}: {}", - std::io::Error::last_os_error() + r" +This run is stopping, and no measurement was taken. + +Could not confine a thread to processor {number} in group {group}: + {error} + +That processor was reported by this machine's own topology, so this is +unexpected rather than a limit of the tool. A process restricted to a +subset of processors -- by a job object, a container, or a `start /affinity` +-- is the usual cause. + +The run stops rather than measuring without pinning. An unpinned thread +measures wherever the scheduler happened to put it, which would produce a +plausible number that answers a different question, and nothing in the +output would say so. + +Reporting this is genuinely useful: please include this message. +", + error = std::io::Error::last_os_error() ); } From 7abed6995487468b2026326e7d3a0e17f64d9a33 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 16:01:14 -0400 Subject: [PATCH 071/361] feat(placement-probe): point runners at the specific discussion thread The tool now names discussion 55 rather than the discussions index, so someone doing a favour lands on the reply box instead of a list they have to search. A link that costs an extra navigation is one more place a submission stops. A test asserts the destination still ends in a discussion number. The mistake it catches is a plausible one to make later -- trimming the URL back to the index during a tidy-up, or a thread being recreated and the number not following -- and it is sabotage-verified. Completed item: PT-4.6: Set up the Discussions thread people paste into, and link it from the tool. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 8 ++++++- .../windows-placement-probe/src/submission.rs | 7 ++++++- .../src/submission/tests.rs | 21 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index c3510a3d..ccd39c25 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -301,11 +301,17 @@ that carries them is written. times two million items, on top of the placements. On a four-node machine that is a materially longer run than on this one, and the person deserves to know before it starts. -- [ ] **PT-4.6** -- **Set up the Discussions thread people paste into, and link it from the tool.** +- [x] **PT-4.6** -- **Set up the Discussions thread people paste into, and link it from the tool.** The tool's output names where to send a result, so that destination has to exist before the tool ships, not after -- an instruction pointing at a thread that is not there is worse than no instruction. Pin it, and state in the first post what is collected and what a submission is used for, so a reader who arrives from a search rather than from the README still sees it. + **Done: [discussion 55](https://github.com/MikeGrier/windows-threadpool-sys/discussions/55), "Please + share data from `windows-placement-probe`".** The tool points at that thread rather than at the + discussions index, so a runner lands on the reply box instead of a list they have to search -- a + link that costs an extra navigation is one more place a submission stops. A test asserts the URL + still ends in a discussion number, which catches the plausible later mistake of trimming it back to + the index during a tidy-up. - [x] **PT-4.3** -- **Say exactly what is collected and what is not**, in the tool's own output and in its README, and make it verifiable by reading the record. Collected, per PT-1.2: core/cache/NUMA diff --git a/crates/windows-placement-probe/src/submission.rs b/crates/windows-placement-probe/src/submission.rs index 943a54c9..35586398 100644 --- a/crates/windows-placement-probe/src/submission.rs +++ b/crates/windows-placement-probe/src/submission.rs @@ -30,7 +30,12 @@ use crate::record::SubmissionRecord; use crate::report; /// Where a runner is asked to send a result. -pub const DISCUSSION_URL: &str = "https://github.com/MikeGrier/windows-threadpool-sys/discussions"; +/// +/// **The specific thread, not the discussions index.** Someone doing a favour +/// should land on the reply box rather than on a list they have to search: a +/// link costing an extra navigation is one more place a submission stops. +pub const DISCUSSION_URL: &str = + "https://github.com/MikeGrier/windows-threadpool-sys/discussions/55"; /// A 64-bit FNV-1a digest, rendered as sixteen hex characters. /// diff --git a/crates/windows-placement-probe/src/submission/tests.rs b/crates/windows-placement-probe/src/submission/tests.rs index 16c09000..57e7100b 100644 --- a/crates/windows-placement-probe/src/submission/tests.rs +++ b/crates/windows-placement-probe/src/submission/tests.rs @@ -20,6 +20,27 @@ fn the_submission_carries_its_own_markdown_fences() { ); } +#[test] +fn the_destination_is_a_specific_thread_rather_than_the_discussions_index() { + // The mistake this catches is a plausible one to make later: trimming the + // URL back to the index during a tidy-up, or a thread being recreated and + // the number not following. Either leaves a runner searching a list, and + // some of them will simply stop there. + let tail = DISCUSSION_URL + .rsplit('/') + .next() + .expect("a URL has at least one segment"); + + assert!( + tail.chars().all(|c| c.is_ascii_digit()) && !tail.is_empty(), + "the destination must end in a discussion number, got {DISCUSSION_URL:?}" + ); + assert!( + DISCUSSION_URL.contains("/discussions/"), + "got {DISCUSSION_URL:?}" + ); +} + #[test] fn the_submission_names_where_to_send_it() { // An instruction that lives only in a README is one that half of them will From 093b592dd327b002ea6b325581ae8867e83f0b29 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 16:16:44 -0400 Subject: [PATCH 072/361] feat(placement-probe): release the tool as a downloadable binary from CI The tool is distributed as an artifact attached to a GitHub release rather than through crates.io, because the download is the provenance: a binary built here is traceable to the commit that produced it, in a way a local build of byte-identical source is not. That is what makes the record's "official build" distinction mean something rather than decorate it. The workflow checks both directions rather than trusting either. It builds with the commit and source stamps and asserts the artifact reports itself official AND names this commit -- a stale stamp would make every submission traceable to the wrong code. It then rebuilds without the stamps and asserts that binary marks itself unofficial, which is the case that would otherwise never be watched and whose silent failure would make the whole distinction decorative. It then rebuilds with the stamps, because the negative check overwrote the artifact and shipping that file would attach an UNOFFICIAL binary to an official release. Adds --version for this, printing the whole build identity rather than a version number: CI asserts on it, and a downloader can check the same thing before trusting a binary they did not build. Two defects fixed by reading the workflow rather than running it. A dry_run input was declared and never read, which would have been a switch that silently did nothing -- dispatch is inherently build-and-verify-only, since the release job runs only for a tag. And the release notes mentioned --preview in backticks inside a double-quoted bash string, where a backtick is command substitution; they now go through a quoted heredoc into --notes-file. aarch64-pc-windows-msvc is in the matrix and is NOT verified. The cross-build fails here with unresolved external symbol __imp_GetProcessHeap, which is a missing local ARM64 MSVC library rather than a code fault, since std itself uses that symbol. CI runners ship those libraries, but that is an expectation rather than a measurement, so the checklist says to dispatch the workflow once before tagging. The README is written for someone who has never seen this repository: what the tool answers, why their machine is interesting given every host measured so far has one NUMA node and the two hosts express disjoint placement sets, what is collected as a commitment rather than a description, and the honest limit that --no-cpu-model does not make confidential hardware safe to submit. PT-5.5 stays open on purpose. Walking the download path needs a real release and a machine without this checkout; doing it against a local build would test something else while looking like it passed. Completed item: PT-5.1: CI builds the tool on tag and attaches the binary. Completed item: PT-5.2: A README written for someone who has never seen this repository. Completed item: PT-5.4: Package metadata and a statement of what is and is not covered by semver. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-placement-probe.yml | 187 ++++++++++++++++++ CHECKLIST-placement-tool.md | 25 ++- crates/windows-placement-probe/Cargo.toml | 14 ++ crates/windows-placement-probe/README.md | 92 +++++++++ .../src/bin/placement_probe.rs | 15 ++ 5 files changed, 329 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/release-placement-probe.yml create mode 100644 crates/windows-placement-probe/README.md diff --git a/.github/workflows/release-placement-probe.yml b/.github/workflows/release-placement-probe.yml new file mode 100644 index 00000000..e729950f --- /dev/null +++ b/.github/workflows/release-placement-probe.yml @@ -0,0 +1,187 @@ +# Copyright (c) 2026 Mike Grier +name: release-placement-probe + +# The tool is distributed as a downloadable binary rather than through +# crates.io, because **the download is the provenance**: an artifact attached to +# a release here is traceable to the commit that built it, in a way a local +# build of byte-identical source is not. That is what makes the "official build" +# distinction in the submission record mean something rather than decorate it. +on: + push: + tags: + - 'placement-probe-v*' + # Lets the whole path be exercised before a tag exists. A release process + # nobody has walked is a release process that does not work. + # + # Dispatch is inherently build-and-verify-only: the release job below runs + # only for a `placement-probe-v*` tag, so there is no dry-run input to get + # wrong. An earlier revision declared one and never read it, which would have + # been a switch that silently did nothing. + workflow_dispatch: + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +jobs: + build: + name: build ${{ matrix.target }} + runs-on: windows-latest + # Build under the floating stable toolchain rather than the repository's + # rust-toolchain.toml MSRV pin: rustup resolves a directory override ahead + # of `rustup default`, so without this every cargo call below would silently + # use the pinned MSRV. The MSRV floor is guarded by ci.yml's own job. + env: + RUSTUP_TOOLCHAIN: stable + strategy: + fail-fast: false + matrix: + # Both Windows architectures this workspace has ever measured on. The + # aarch64 artifact matters specifically: the two hosts measured so far + # disagreed about a queue result, and the ARM64 one is the more + # revealing of the two for anything ordering-related. + target: + - x86_64-pc-windows-msvc + - aarch64-pc-windows-msvc + steps: + - uses: actions/checkout@v7 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build the tool + shell: bash + env: + # What stamps the artifact as official. `build.rs` reads these, and + # without them a binary honestly reports itself as a local build -- + # which is the behaviour the verify step below depends on. + PLACEMENT_PROBE_COMMIT: ${{ github.sha }} + PLACEMENT_PROBE_SOURCE: ci + run: | + cargo build --release --locked \ + --target "${{ matrix.target }}" \ + -p windows-placement-probe --bin placement-probe + + - name: Verify the artifact reports itself official + # Only the native artifact can be executed on this runner; the aarch64 + # binary is cross-compiled and cannot run here. Verifying the one that + # can is what makes the claim testable at all, and a distinction nobody + # has watched hold is a distinction that does not work. + if: matrix.target == 'x86_64-pc-windows-msvc' + shell: bash + run: | + identity="$(./target/${{ matrix.target }}/release/placement-probe.exe --version)" + echo "build identity: ${identity}" + case "${identity}" in + *'!!UNOFFICIAL!!'*) + echo "::error::a CI build reported itself unofficial: ${identity}" >&2 + echo "::error::the commit and source stamps did not reach build.rs" >&2 + exit 1 + ;; + esac + # The stamps must carry the *right* commit, not merely some commit: a + # stale one would make every submission traceable to the wrong code. + case "${identity}" in + *"$(echo "${GITHUB_SHA}" | cut -c1-12)"*) ;; + *) + echo "::error::identity does not name this commit: ${identity}" >&2 + exit 1 + ;; + esac + + - name: Verify an unstamped build reports itself unofficial + # The negative case, and the one that would otherwise never be watched. + # If a build claimed to be official without the stamps, the whole + # distinction would be decorative and nothing would say so. + if: matrix.target == 'x86_64-pc-windows-msvc' + shell: bash + run: | + cargo build --release --locked \ + --target "${{ matrix.target }}" \ + -p windows-placement-probe --bin placement-probe + identity="$(./target/${{ matrix.target }}/release/placement-probe.exe --version)" + echo "unstamped identity: ${identity}" + case "${identity}" in + *'!!UNOFFICIAL!!'*) ;; + *) + echo "::error::an unstamped build claimed to be official: ${identity}" >&2 + exit 1 + ;; + esac + + - name: Rebuild with the stamps for release + # The negative check above overwrote the artifact with an unstamped one. + # Rebuilding is not a formality: shipping that binary would attach a + # file marked UNOFFICIAL to an official release. + shell: bash + env: + PLACEMENT_PROBE_COMMIT: ${{ github.sha }} + PLACEMENT_PROBE_SOURCE: ci + run: | + cargo build --release --locked \ + --target "${{ matrix.target }}" \ + -p windows-placement-probe --bin placement-probe + + - name: Name the artifact for its architecture + shell: bash + run: | + arch="${{ matrix.target }}" + arch="${arch%%-*}" + mkdir -p dist + cp "target/${{ matrix.target }}/release/placement-probe.exe" \ + "dist/placement-probe-${arch}.exe" + + - uses: actions/upload-artifact@v4 + with: + name: placement-probe-${{ matrix.target }} + path: dist/placement-probe-*.exe + if-no-files-found: error + + release: + name: attach to the release + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/placement-probe-v') + permissions: + # The only job that needs it, and only for the tag path. + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Write the release notes + # A file rather than an inline `--notes` string. The notes mention + # `--preview` in backticks, and a backtick inside a double-quoted bash + # string is command substitution: the shell would try to execute it. + # Quoting the heredoc delimiter also stops any `$` being expanded. + run: | + cat > notes.md <<'NOTES' + Measures what thread placement costs on a Windows machine, and prints a + result you can paste back. + + Download the binary for your architecture and run it. Pass `--preview` + first if you would like to see exactly what it collects before it + measures anything, and `--no-cpu-model` to withhold the processor name. + + It makes **no network connections**. Sending the result is your + decision and your action: + https://github.com/MikeGrier/windows-threadpool-sys/discussions/55 + + Machines with more than one NUMA node are the ones we most need, since + every host measured so far has exactly one. + NOTES + + - name: Publish the release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "windows-placement-probe ${GITHUB_REF_NAME##*-v}" \ + --notes-file notes.md \ + artifacts/placement-probe-*.exe diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index ccd39c25..165696fe 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -350,12 +350,25 @@ provenance**. A binary attached to a release in this repository is traceable to it, in a way a locally built copy of the same source is not -- which is what makes PT-3.5's "official build" distinction meaningful rather than decorative. -- [ ] **PT-5.1** -- CI builds the tool on tag and attaches the binary to a GitHub release, injecting +- [x] **PT-5.1** -- CI builds the tool on tag and attaches the binary to a GitHub release, injecting the commit into the environment variable PT-3.5 reads. **Verify the negative case**: a locally built binary must produce a record marked as an unofficial build, and a CI-built one must not. A distinction nobody has watched fail is a distinction that does not work. - -- [ ] **PT-5.2** -- A README written for someone who has never seen this repository: what question the + **Done, and both directions are checked inside the workflow itself rather than trusted.** It builds + with the stamps and asserts the artifact reports itself official *and names this commit*; then + rebuilds **without** them and asserts that binary marks itself unofficial; then rebuilds with the + stamps for release, because the negative check overwrote the artifact and shipping that file would + attach an `!!UNOFFICIAL!!` binary to an official release. + A `--version` flag was added for this, printing the whole build identity rather than a version + number -- CI asserts on it, and a downloader can check the same thing before trusting a binary. + **`aarch64-pc-windows-msvc` is in the matrix and is NOT verified here.** The cross-build fails on + this machine with `unresolved external symbol __imp_GetProcessHeap`, which is a missing local ARM64 + MSVC library rather than a code fault -- `std` itself uses that symbol, so a real defect would break + every ARM64 Rust program. CI runners do ship those libraries, but that is a reasonable expectation + and not a measurement. **Run the workflow once via `workflow_dispatch` before tagging**: dispatch + builds and verifies without releasing, which is exactly what that trigger is for. + +- [x] **PT-5.2** -- A README written for someone who has never seen this repository: what question the tool answers, why their machine is interesting, where to download it, how to run it, what to send back, and what it collects. Assume no context and no obligation. Lead with the download, not with `cargo install`. @@ -366,7 +379,7 @@ build" distinction meaningful rather than decorative. case against is that the weaker path is also the more discoverable one, and submissions will drift towards it. -- [ ] **PT-5.4** -- Package metadata and a statement of what is and is not covered by semver. The +- [x] **PT-5.4** -- Package metadata and a statement of what is and is not covered by semver. The **record's schema is a compatibility surface** the moment anyone stores one; the internal measurement code is not. @@ -374,6 +387,10 @@ build" distinction meaningful rather than decorative. download, run, find the record, read the README's instructions for sending it. A path nobody has walked is a path that does not work, and the person walking it will be doing a favour rather than debugging. + **Deliberately left open: this cannot be completed from here.** It needs a real release to download + from and a machine without this checkout, and doing it against a local build would test something + else while looking like it had passed. The ARM64 development machine is the obvious first walker, + and it doubles as the check that the unverified `aarch64` artifact from PT-5.1 actually runs. ## M6: is a set of "equivalent" processors actually equivalent? diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index 547a9c4b..483a4f91 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -17,6 +17,20 @@ license.workspace = true repository.workspace = true homepage.workspace = true description = "Measures what thread placement costs on a Windows machine -- SMT siblings, cache domains, efficiency classes and NUMA hops -- and produces one structured record to send back." +readme = "README.md" + +# What semver covers here, stated because the two halves differ sharply. +# +# **The record's schema is a compatibility surface** the moment anyone stores a +# result, and it has its own versioning that does not move with the crate's: +# `SCHEMA_VERSION` plus an append-only golden per version in `schema/`. A stored +# record can be validated against the version it declares years later, which is +# the property that matters when the records live in a discussion thread and +# cannot be regenerated. +# +# **The measurement code is not a compatibility surface.** It is an instrument, +# not a library to build on, and its types will change whenever a better +# measurement needs them to. Nothing should depend on this crate as a library. [lib] path = "src/lib.rs" diff --git a/crates/windows-placement-probe/README.md b/crates/windows-placement-probe/README.md new file mode 100644 index 00000000..91abc71d --- /dev/null +++ b/crates/windows-placement-probe/README.md @@ -0,0 +1,92 @@ +# windows-placement-probe + +**Measures what thread placement costs on your machine, and prints a result you +can paste back.** + +Where two communicating threads run changes how fast they can hand work to each +other -- by more than most optimisations are worth. On one machine measured so +far, moving a producer/consumer pair from one locality domain to another cost +**5.6x** on identical code. + +## Why your machine is interesting + +The designs this informs are shared; the hardware available to the author is +not. Two things are missing and cannot be fixed locally at any price: + +- **Every host measured so far has exactly one NUMA node.** The cost of + crossing between nodes -- what a multi-socket server does constantly -- is + entirely unmeasured. +- **The two hosts measured express disjoint sets of placements.** Neither can + produce a single row the other can, so this is not a matter of collecting + more of the same. A result from a machine unlike either is worth more than a + hundred repetitions here. + +If your machine has more than one NUMA node, it can answer a question nothing +here can. + +## Running it + +Download the binary for your architecture from the +[latest release](https://github.com/MikeGrier/windows-threadpool-sys/releases), +then: + +```text +placement-probe --preview see exactly what it collects, measure nothing +placement-probe measure, and print a result to paste +``` + +The run states its own worst-case duration before starting. It is usually a +second or two, and grows with the number of NUMA nodes. + +Then paste the output into +[the collection thread](https://github.com/MikeGrier/windows-threadpool-sys/discussions/55). +The tool prints its own markdown fences, so you can select everything, copy, and +paste -- it will render correctly without you doing anything else. + +## What it collects, and what it does not + +**Collected:** the shape of the machine (logical processors, cores, cache +domains, efficiency classes, NUMA nodes), the CPU model, the OS build, whether +virtualisation was detected, and the timings it measures. + +**Not collected:** your host name, your user name, file paths, environment +variables, serial numbers, or anything about installed software. That list is a +commitment, not a description of the current implementation. + +**It makes no network connections.** It writes a file and prints text; sending +either is your decision and your action. + +`--preview` shows the values it would collect **before** measuring, so you can +decide with the real values in front of you rather than a promise about them. +`--no-cpu-model` withholds the processor name. + +### If the hardware is confidential, do not send the result + +`--no-cpu-model` reduces incidental leakage and nothing more. An unreleased part +is identified by its **topology** -- an unusual core count, a novel cache +arrangement -- at least as well as by its name, and the topology is the +measurement. No switch fixes that, and it would be dishonest to imply otherwise. + +## Trusting the binary + +Run `placement-probe --version`. A binary built by this repository's CI reports +its commit and reads as official; anything else is marked `!!UNOFFICIAL!!`, +including a build from a local working copy. The same marking appears in the +result, so a submission always says which build produced it. + +That distinction is why the download is the recommended path: an artifact +attached to a release here is traceable to the commit that built it, in a way a +local build of byte-identical source is not. + +## What a result does not establish + +These are **timing** measurements. They say nothing about memory ordering, and a +long clean run is not evidence of correctness in that sense -- stress testing is +measurably blind to a weakened ordering. The tool says so in its own output +rather than leaving it to be assumed. + +## An instrument, not a library + +This crate exists to produce measurements. It is not a placement policy, it does +not decide where your threads should run, and nothing in it is tuned for use in +a running system. diff --git a/crates/windows-placement-probe/src/bin/placement_probe.rs b/crates/windows-placement-probe/src/bin/placement_probe.rs index 80bf5e9f..fc5d274f 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe.rs @@ -7,6 +7,7 @@ use std::process::ExitCode; +use windows_placement_probe::build_identity::BuildIdentity; use windows_placement_probe::core_affinity::{self, RunPlan}; use windows_placement_probe::fingerprint::{Fingerprint, discover_places}; use windows_placement_probe::machine::MachineDescription; @@ -21,6 +22,8 @@ struct Options { suppress_model: bool, /// Skip writing the backup file. no_file: bool, + /// Print the build identity and exit. + version: bool, } fn main() -> ExitCode { @@ -32,6 +35,15 @@ fn main() -> ExitCode { } }; + if options.version { + // Deliberately the whole identity rather than just a version number. + // CI asserts on this line that a released artifact reports itself + // official, and a runner can check the same thing before trusting a + // download -- both need the commit and the source, not just "0.1.0". + println!("{}", BuildIdentity::current()); + return ExitCode::SUCCESS; + } + let machine = MachineDescription::read(options.suppress_model); let places = match discover_places() { @@ -180,6 +192,7 @@ fn parse_arguments() -> Result { preview: false, suppress_model: false, no_file: false, + version: false, }; for argument in std::env::args().skip(1) { @@ -187,6 +200,7 @@ fn parse_arguments() -> Result { "--preview" => options.preview = true, "--no-cpu-model" => options.suppress_model = true, "--no-file" => options.no_file = true, + "--version" | "-V" => options.version = true, "--help" | "-h" => return Err(help()), other => { return Err(format!("unrecognised argument {other:?}\n\n{}", help())); @@ -208,6 +222,7 @@ fn help() -> String { \x20 --preview Show what would be collected and measure nothing.\n\ \x20 --no-cpu-model Withhold the CPU model from the record.\n\ \x20 --no-file Do not write the backup copy of the record.\n\ + \x20 -V, --version Print this build's identity and exit.\n\ \x20 -h, --help Print this message.\n\ \n\ Results are collected at:\n\ From c6aa7f91797aff65b3b9db102b0ba5906a245228 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 16:23:31 -0400 Subject: [PATCH 073/361] ci(placement-probe): let a pull request verify the release build Opening a PR did not verify anything: the release workflow triggered only on tags and workflow_dispatch, and ci.yml cross-compiles only thumbv7em for wtf-string. A tag would therefore have been the first time aarch64-pc-windows-msvc was ever built, turning a build failure into a broken release. workflow_dispatch could not have covered it either, which the previous note missed. GitHub only offers dispatch for workflows already present on the default branch, so a workflow still on a feature branch cannot be dispatched -- exactly when verification is wanted. That instruction was unusable as written. The workflow now also runs on a pull request touching the tool, path-scoped so unrelated PRs do not pay for two Windows builds. The release job stays guarded on the tag ref, so a pull request publishes nothing however it runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-placement-probe.yml | 29 +++++++++++++++---- CHECKLIST-placement-tool.md | 20 +++++++++---- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-placement-probe.yml b/.github/workflows/release-placement-probe.yml index e729950f..57591e45 100644 --- a/.github/workflows/release-placement-probe.yml +++ b/.github/workflows/release-placement-probe.yml @@ -10,13 +10,30 @@ on: push: tags: - 'placement-probe-v*' - # Lets the whole path be exercised before a tag exists. A release process - # nobody has walked is a release process that does not work. + # **A pull request touching the tool builds and verifies it, without + # releasing.** This is what makes the release path exercised rather than + # hoped for, and it matters most for the `aarch64` artifact: `ci.yml` + # cross-compiles only `thumbv7em` for `wtf-string`, so nothing else in this + # repository would ever build the ARM64 target before a tag turned a failure + # into a broken release. # - # Dispatch is inherently build-and-verify-only: the release job below runs - # only for a `placement-probe-v*` tag, so there is no dry-run input to get - # wrong. An earlier revision declared one and never read it, which would have - # been a switch that silently did nothing. + # It also covers a trap in the alternative. `workflow_dispatch` is only + # offered for workflows that already exist on the **default branch**, so a + # dispatch cannot verify a workflow still on a feature branch -- which is + # exactly when verification is wanted. + # + # Path-scoped so an unrelated pull request does not pay for two Windows + # builds. The release job below is guarded on the tag ref, so nothing is + # published from a pull request no matter what runs here. + pull_request: + paths: + - 'crates/windows-placement-probe/**' + - '.github/workflows/release-placement-probe.yml' + - 'Cargo.lock' + # Kept for a re-run after merge, when the file does live on the default + # branch. Inherently build-and-verify-only for the same reason as above: an + # earlier revision declared a `dry_run` input and never read it, which would + # have been a switch that silently did nothing. workflow_dispatch: permissions: diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 165696fe..92aac48b 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -361,12 +361,20 @@ build" distinction meaningful rather than decorative. attach an `!!UNOFFICIAL!!` binary to an official release. A `--version` flag was added for this, printing the whole build identity rather than a version number -- CI asserts on it, and a downloader can check the same thing before trusting a binary. - **`aarch64-pc-windows-msvc` is in the matrix and is NOT verified here.** The cross-build fails on - this machine with `unresolved external symbol __imp_GetProcessHeap`, which is a missing local ARM64 - MSVC library rather than a code fault -- `std` itself uses that symbol, so a real defect would break - every ARM64 Rust program. CI runners do ship those libraries, but that is a reasonable expectation - and not a measurement. **Run the workflow once via `workflow_dispatch` before tagging**: dispatch - builds and verifies without releasing, which is exactly what that trigger is for. + **`aarch64-pc-windows-msvc` is in the matrix and cannot be verified locally.** The cross-build fails + on this machine with `unresolved external symbol __imp_GetProcessHeap`, which is a missing local + ARM64 MSVC library rather than a code fault -- `std` itself uses that symbol, so a real defect would + break every ARM64 Rust program. + **The pull request verifies it, which is better than the dispatch this originally called for.** The + workflow now also triggers on a pull request touching the tool, building and verifying both targets + without releasing. Two things made that the right answer rather than a convenience: + - **Nothing else in this repository builds the ARM64 target.** `ci.yml` cross-compiles only + `thumbv7em` for `wtf-string`, so without this a tag would be the first time `aarch64` was ever + attempted -- turning a build failure into a broken release. + - **`workflow_dispatch` could not have done it.** GitHub only offers dispatch for workflows already + on the **default branch**, so a workflow still on a feature branch cannot be dispatched at all -- + which is precisely when it needs verifying. The original instruction here was unusable. + The release job stays guarded on the tag ref, so a pull request publishes nothing however it runs. - [x] **PT-5.2** -- A README written for someone who has never seen this repository: what question the tool answers, why their machine is interesting, where to download it, how to run it, what to send From 69b408fc05be7ca94b05cdbf61cdfd30539a4207 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 16:32:45 -0400 Subject: [PATCH 074/361] docs: measure both directions, and pin down where the ring's memory lives Raised by the engineer asking why the hop count was the number of edges rather than twice it. It should be twice it, and answering exposed a worse defect underneath. node_pairs is undirected on the reasoning that 0->1 and 1->0 traverse the same link. That conflates the link, which is symmetric, with the workload over it, which is not: the producer writes slots and release-stores tail while the consumer reads slots and release-stores head, and a remote write needs exclusive ownership and invalidation where a remote read does not. Swapping the ends is a different measurement rather than a repeat, so the count is n*(n-1) and the runtime estimate must follow. Underneath that: Ring::new runs on the calling thread, which is never pinned, so under first-touch the ring memory lands on whatever node the orchestrating thread happened to occupy -- possibly neither the producer's nor the consumer's. On a multi-socket machine there are three positions and the third is uncontrolled and unrecorded, so two runs could differ solely because the main thread migrated with nothing in the output saying so. A hop measured with memory on an unknown third node is not a measurement of that hop. Both queued as M1C, ahead of the NUMA machines being spent, because neither is fixable after the fact: the records cannot be regenerated and the hardware is borrowed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 92aac48b..08561da7 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -171,6 +171,39 @@ the entire point of this tool -- has more than 64 logical processors, so Windows message; a collapsed topology costs a wrong answer nobody can detect from the output, on hardware that is not coming back. +## M1C: direction and memory placement, before a NUMA machine is spent + +**Raised by the engineer asking why the hop count was the edge count rather than twice it.** It should +be twice it, and answering that exposed a second defect underneath. + +- [ ] **M1C.1** -- **Measure both directions of a node pair.** `node_pairs` is undirected, with the + reasoning that `0 -> 1` and `1 -> 0` "traverse the same link". That conflates the *link*, which is + symmetric, with the *workload over it*, which is not: the producer **writes** slots and + release-stores `tail`, the consumer **reads** slots and release-stores `head`, and a remote write + needs exclusive ownership and invalidation where a remote read does not. Swapping the ends is a + different measurement, not a repeat. + Doubles the hop count, so state the cost plainly: `n*(n-1)` rather than `n*(n-1)/2`. On a four-node + host that is 12 hops instead of 6, and the runtime estimate must follow. + **Keep the two directions distinguishable in the record.** Reporting a mean of them would destroy + exactly the asymmetry this item exists to measure. + +- [ ] **M1C.2** -- **Control and record which node the ring's memory is on.** `Ring::new` runs on the + calling thread, which is never pinned, so under first-touch the ring lands on whatever node the + *orchestrating* thread happened to occupy -- possibly neither the producer's nor the consumer's. + **On a multi-socket machine there are three positions, not two**, and the third is currently + uncontrolled and unrecorded. Two runs could differ solely because the main thread migrated, with + nothing in the output to say so. + This is not a refinement; it is what makes a NUMA number mean anything. A hop measured with the + memory on an unknown third node is not a measurement of that hop. + Decide and record the policy rather than inheriting one: allocating from the producer's node is the + realistic default for a queue, and whichever is chosen, **the memory node belongs in the record** + beside the two processor nodes. + +- [ ] **M1C.3** -- **Say what the placement label means once direction exists.** A row currently reads + as a pair of positions; it must read as producer-here, consumer-there, memory-somewhere. The + existing `Placement` names are direction-free and will quietly under-describe a directed run, which + is the "table with right labels and wrong pairs" failure in a new place. + ## M2: the move - [x] **PT-2.1** -- Move `fingerprint`, `core_affinity` and `peer_index_cache` into the new crate, and From f57ac9ca7117196c8a0421d2587bc1f9913fef62 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 16:34:11 -0400 Subject: [PATCH 075/361] docs: measure the ring's memory on both endpoints, as separate rows Remote-write and remote-read cost are measured independently, which is the pair of quantities M1C.1's direction asymmetry is actually about: with the memory on the producer's node the producer writes locally and the consumer reads remotely, and moving the memory reverses exactly that. Four configurations per undirected edge -- two directions times two memory placements -- so 2*n*(n-1) hop measurements rather than n*(n-1)/2. On a four-node host that is 24 rather than 6, and 144 timed handoffs for the hops alone. Under two minutes at the worst per-item cost measured so far, which is affordable for hardware this scarce, and PT-4.2's estimate must be updated with it or the tool will under-promise the wait on exactly the machines that take longest. The design carries a consistency check worth keeping: two of the four configurations are producer-local and two consumer-local, differing only in which physical node holds each role. On a symmetric interconnect each pair should agree, and if they disagree the interconnect is asymmetric -- a finding rather than noise. Averaging the pairs, or measuring one of each, would discard it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 08561da7..c89711c0 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -195,9 +195,25 @@ be twice it, and answering that exposed a second defect underneath. nothing in the output to say so. This is not a refinement; it is what makes a NUMA number mean anything. A hop measured with the memory on an unknown third node is not a measurement of that hop. - Decide and record the policy rather than inheriting one: allocating from the producer's node is the - realistic default for a queue, and whichever is chosen, **the memory node belongs in the record** - beside the two processor nodes. + **Decided: measure both endpoints as separate rows.** The memory goes on the producer's node in one + row and the consumer's in another, and **the memory node is recorded beside the two processor + nodes** in every row. + This measures remote-write and remote-read cost independently, which is the pair of quantities the + asymmetry in M1C.1 is actually about: with memory on the producer's node the producer writes locally + and the consumer reads remotely, and swapping the memory reverses exactly that. + + **The cost, stated plainly.** Four configurations per undirected edge -- two directions times two + memory placements -- so `2*n*(n-1)` hop measurements rather than today's `n*(n-1)/2`. On a four-node + host that is 24 rather than 6, and at two strategies and three repetitions it is 144 timed handoffs + for the hops alone. Under two minutes at the worst per-item cost measured so far, which is + affordable for hardware this scarce. **PT-4.2's estimate must be updated with it**, or the tool will + under-promise the wait on precisely the machines that take longest. + + **The design carries its own consistency check, which is worth keeping rather than optimising + away.** Of the four configurations per edge, two are "producer-local" and two are "consumer-local", + differing only in which physical node each role sits on. On a symmetric interconnect each pair + should agree; **if they disagree, the interconnect is asymmetric, and that is a finding** rather + than noise. Averaging the pairs, or measuring only one of each, would discard it. - [ ] **M1C.3** -- **Say what the placement label means once direction exists.** A row currently reads as a pair of positions; it must read as producer-here, consumer-there, memory-somewhere. The From 53f2357e57c9cfde09124858c0cb8491136acfd8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 16:50:44 -0400 Subject: [PATCH 076/361] feat(placement-probe): measure a node hop in both directions, on both nodes An edge between two NUMA nodes admits four measurements, and the probe was taking one. Two defects, found by a reader asking why the hop count was not twice what it was: Direction. `node_pairs` selected one direction per edge, reasoning that both traverse the same link. The link is symmetric; the workload is not. The producer writes and the consumer reads, so swapping the endpoints swaps which side pays for the crossing. Memory. `Ring::new` allocated on whichever thread happened to call it, so under first-touch the slots landed on an uncontrolled node -- possibly neither endpoint's. A hop measured with the data on an unknown third node is not a measurement of that hop. The ring is now placed deliberately, by touching every slot from a thread pinned into the target node, and each row records the node the run *achieved* rather than the one it asked for: a placement that fails records `null` and the report prints "unknown" instead of a number that reads like a successful run. Both endpoints are measured as separate rows, which keeps a consistency check the design pays for: of the four configurations, two are producer-local and two consumer-local, and on a symmetric interconnect each pair should agree. A disagreement is a finding about the hardware, not noise, and averaging would discard it. The plan is bound to the loops rather than counting alongside them. `RunPlan` asks `memory_placements` -- the same function the hop loop iterates -- so the promised handoff count cannot drift from the run again, as it did when the plan quoted 18 against a run of 12. Sabotage confirmed: dropping the memory factor fails `every_timed_handoff_the_run_performs_is_in_the_plan`. The `Placement` labels stay symmetric, deliberately. Two processors either are SMT siblings or are not; there is no honest `CrossNumaNodeForward`. Direction lives where it is real -- the slice's `prod=`/`cons=` roles, the hop table's `a -> b` column, and the recorded ring node -- and the placement table now says it covers one direction per row, so a reader cannot mistake a row for a summary of the four measurements an edge admits. Schema raised to 2 for the added `memory_node`, with `v1.txt` untouched. `null` means different things per array, and the field says so: in `node_hops` a placement was attempted and failed; in `placements` and `by_class` none was arranged. Three fixtures were tidier than reality and are corrected: the shared record's "hop" had both endpoints on node 0, so it was not a crossing at all, and the hop table -- the one part of the report this workspace's single-node hardware cannot render -- had no test rendering it populated. It does now, with all four rows of a real edge. Completed items: M1C.1, M1C.2, M1C.3 Completed item: M1C.1: Measure each node hop in both directions Completed item: M1C.2: Place the ring deliberately and record where it landed Completed item: M1C.3: Say what the placement label means once direction exists Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 6 +- crates/windows-placement-probe/schema/v2.txt | 89 +++++++ .../src/core_affinity.rs | 148 +++++++++--- .../src/core_affinity/tests.rs | 220 +++++++++++++++--- .../src/fingerprint/tests.rs | 6 +- .../src/peer_index_cache.rs | 131 ++++++++++- .../src/peer_index_cache/tests.rs | 99 ++++++++ crates/windows-placement-probe/src/record.rs | 15 +- .../src/record/tests.rs | 1 + crates/windows-placement-probe/src/report.rs | 29 ++- .../src/report/tests.rs | 99 ++++++++ .../src/submission/tests.rs | 11 +- 12 files changed, 785 insertions(+), 69 deletions(-) create mode 100644 crates/windows-placement-probe/schema/v2.txt create mode 100644 crates/windows-placement-probe/src/peer_index_cache/tests.rs diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index c89711c0..c826c07d 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -176,7 +176,7 @@ the entire point of this tool -- has more than 64 logical processors, so Windows **Raised by the engineer asking why the hop count was the edge count rather than twice it.** It should be twice it, and answering that exposed a second defect underneath. -- [ ] **M1C.1** -- **Measure both directions of a node pair.** `node_pairs` is undirected, with the +- [x] **M1C.1** -- **Measure both directions of a node pair.** `node_pairs` is undirected, with the reasoning that `0 -> 1` and `1 -> 0` "traverse the same link". That conflates the *link*, which is symmetric, with the *workload over it*, which is not: the producer **writes** slots and release-stores `tail`, the consumer **reads** slots and release-stores `head`, and a remote write @@ -187,7 +187,7 @@ be twice it, and answering that exposed a second defect underneath. **Keep the two directions distinguishable in the record.** Reporting a mean of them would destroy exactly the asymmetry this item exists to measure. -- [ ] **M1C.2** -- **Control and record which node the ring's memory is on.** `Ring::new` runs on the +- [x] **M1C.2** -- **Control and record which node the ring's memory is on.** `Ring::new` runs on the calling thread, which is never pinned, so under first-touch the ring lands on whatever node the *orchestrating* thread happened to occupy -- possibly neither the producer's nor the consumer's. **On a multi-socket machine there are three positions, not two**, and the third is currently @@ -215,7 +215,7 @@ be twice it, and answering that exposed a second defect underneath. should agree; **if they disagree, the interconnect is asymmetric, and that is a finding** rather than noise. Averaging the pairs, or measuring only one of each, would discard it. -- [ ] **M1C.3** -- **Say what the placement label means once direction exists.** A row currently reads +- [x] **M1C.3** -- **Say what the placement label means once direction exists.** A row currently reads as a pair of positions; it must read as producer-here, consumer-there, memory-somewhere. The existing `Placement` names are direction-free and will quietly under-describe a directed run, which is the "table with right labels and wrong pairs" failure in a new place. diff --git a/crates/windows-placement-probe/schema/v2.txt b/crates/windows-placement-probe/schema/v2.txt new file mode 100644 index 00000000..ad93aba3 --- /dev/null +++ b/crates/windows-placement-probe/schema/v2.txt @@ -0,0 +1,89 @@ +# Schema v2 for windows-placement-probe submission records. +# +# Every key path the record serializes to, sorted. Derived by serializing a +# fully populated record and walking the result -- never written by hand, so it +# cannot drift from the type it describes. +# +# APPEND-ONLY. Never edit a published version: records already in the wild +# claim this number, and they cannot be regenerated. To change the shape, raise +# SCHEMA_VERSION and add the next file beside this one. +# +# Changed from v1: every measurement row gains "memory_node", naming which NUMA +# node held the ring. A v1 row does not carry it, and must not be read as though +# the memory was somewhere irrelevant -- it was simply not controlled or +# recorded. + +build +build.commit +build.crate_version +build.dirty +build.source +by_class +by_class[] +by_class[].consumer_batch +by_class[].consumer_group +by_class[].consumer_numa_node +by_class[].consumer_number +by_class[].memory_node +by_class[].nanos_per_item +by_class[].placement +by_class[].producer_batch +by_class[].producer_group +by_class[].producer_numa_node +by_class[].producer_number +by_class[].slice +by_class[].strategy +host +host.arch +host.cache_domain_sizes +host.cache_domain_sizes[] +host.cores +host.efficiency_classes +host.efficiency_classes[] +host.efficiency_classes[][] +host.numa_node_sizes +host.numa_node_sizes[] +host.partitioning_cache_level +host.processors +host.provenance +host.smt +machine +machine.cpu_model +machine.model_suppressed +machine.os_build +machine.virtualisation +machine.virtualisation_name +node_hops +node_hops[] +node_hops[].consumer_batch +node_hops[].consumer_group +node_hops[].consumer_numa_node +node_hops[].consumer_number +node_hops[].memory_node +node_hops[].nanos_per_item +node_hops[].placement +node_hops[].producer_batch +node_hops[].producer_group +node_hops[].producer_numa_node +node_hops[].producer_number +node_hops[].slice +node_hops[].strategy +placements +placements[] +placements[].consumer_batch +placements[].consumer_group +placements[].consumer_numa_node +placements[].consumer_number +placements[].memory_node +placements[].nanos_per_item +placements[].placement +placements[].producer_batch +placements[].producer_group +placements[].producer_numa_node +placements[].producer_number +placements[].slice +placements[].strategy +recorded_at +recorded_at_epoch_seconds +schema_version +topology_provenance diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index dbb082b8..30ccb7e4 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -65,7 +65,7 @@ use std::collections::BTreeMap; use crate::fingerprint::{ProcessorPlace, Slice, discover_places}; -use crate::peer_index_cache::{ITEMS, Strategy, time_model_on}; +use crate::peer_index_cache::{ITEMS, Strategy, time_model_on, time_model_placed}; /// Repetitions per placement; the median is reported. /// @@ -121,8 +121,14 @@ fn efficiency_classes(places: &[ProcessorPlace]) -> Vec { pub struct RunPlan { /// Placements this machine can express. pub placements: usize, - /// Distinct NUMA node pairs. + /// Directed NUMA node pairs: `(a, b)` and `(b, a)` are both counted. + /// + /// A hop is not symmetric even though the link is. The producer writes and + /// the consumer reads, so swapping the endpoints swaps which side pays for + /// the crossing. pub node_hops: usize, + /// Ring placements measured per hop -- see [`memory_placements`]. + pub memory_placements_per_hop: usize, /// Efficiency classes compared like with like. pub classes: usize, /// Strategies measured per selection. @@ -135,9 +141,17 @@ impl RunPlan { /// Work out what a run on these processors will involve. #[must_use] pub fn for_processors(places: &[ProcessorPlace]) -> Self { + let hops = node_pairs(places); Self { placements: representative_pairs(places).len(), - node_hops: node_pairs(places).len(), + node_hops: hops.len(), + // Asked, not assumed. Taken from a hop this run will actually + // perform, so a change to `memory_placements` moves the promise + // with it. Any value on a machine with no hops, since it multiplies + // a zero. + memory_placements_per_hop: hops.values().next().map_or(0, |(producer, consumer)| { + memory_placements(*producer, *consumer).len() + }), // Exact, not an upper bound: a class is only measured when it has a // usable within-class pair, and this asks the same function the run // will. Counting classes instead would over-report on any host @@ -153,9 +167,14 @@ impl RunPlan { } /// How many timed handoffs the run performs. + /// + /// Hops are counted separately from the rest because they are the only + /// selections measured at more than one memory placement. #[must_use] pub fn timed_runs(self) -> usize { - (self.placements + self.node_hops + self.classes) * self.strategies * self.repetitions + let selections = + self.placements + self.classes + self.node_hops * self.memory_placements_per_hop; + selections * self.strategies * self.repetitions } /// How long the run should take at most, in seconds. @@ -178,6 +197,28 @@ impl RunPlan { } /// How a producer and a consumer are placed relative to each other. +/// +/// # A label names a relationship, not a direction +/// +/// These names are deliberately symmetric, and that is not an oversight left +/// over from before hops became directed. The *relationship* between two +/// processors genuinely is symmetric -- two processors either are SMT siblings +/// or are not, share a cache domain or do not -- so there is no honest +/// `CrossNumaNodeForward` to name. Splitting the labels by direction would +/// invent a distinction the topology does not have. +/// +/// The *workload* is what is asymmetric: the producer writes and the consumer +/// reads, so swapping them swaps which side pays. Direction therefore lives +/// where it is real, not in the label: +/// +/// - in the slice, whose participants carry `prod=` and `cons=` roles; +/// - in the node-pair column of the hop table, printed `a -> b`; +/// - in the ring's node, recorded per row as [`Measurement::memory_node`]. +/// +/// This matters when reading a table. A `CrossNumaNode` row is **one** direction +/// at **one** memory placement, never a summary of the four measurements an +/// edge admits. Taking it for a summary is the failure this note exists to +/// prevent: right labels over pairs that do not cover what the reader assumes. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Placement { /// Two SMT siblings: the same physical core, sharing L1. @@ -261,6 +302,12 @@ pub struct Measurement { pub consumer_batch: f64, /// The same for the producer side. pub producer_batch: f64, + /// Which NUMA node held the ring's slots, when a placement was arranged. + /// + /// None on a run that asked for none, and on one whose placement could + /// not be achieved -- the two are the same fact here (we do not know where + /// the memory is) and neither may be reported as a node. + pub memory_node: Option, } /// Everything one invocation measured. @@ -396,10 +443,19 @@ fn assert_group_support(processors: &[ProcessorPlace]) { /// Choose one representative processor pair for each *distinct pair of NUMA /// nodes*. /// -/// Keyed by `(low, high)` node id, so a node pair appears once rather than once -/// per direction: this measures the link, and `0 -> 1` and `1 -> 0` traverse the -/// same one. The producer is always on the lower-numbered node, which makes a -/// run reproducible rather than dependent on enumeration order. +/// Keyed by `(producer node, consumer node)`, and **both directions are +/// measured**. +/// +/// An earlier version keyed on `(low, high)` and kept one direction, reasoning +/// that the two traverse the same link. That conflates the *link*, which is +/// symmetric, with the *workload over it*, which is not: the producer **writes** +/// slots and release-stores `tail` while the consumer **reads** them and +/// release-stores `head`, and a remote write needs exclusive ownership and +/// invalidation where a remote read does not. Swapping the ends is a different +/// measurement rather than a repeat of one. +/// +/// Combined with the two memory placements in [`measure`], that gives four +/// configurations per undirected edge and `2*n*(n-1)` hop measurements in all. /// /// # Why this exists separately from [`representative_pairs`] /// @@ -418,10 +474,9 @@ pub fn node_pairs( let mut chosen = BTreeMap::new(); for producer in places { for consumer in places { - if producer.numa_node >= consumer.numa_node { - // `>=` rather than `!=` collapses the two directions onto the - // canonical `(low, high)` key and drops same-node pairs, which - // are not a crossing at all. + if producer.numa_node == consumer.numa_node { + // Same node is not a crossing. Both *directions* are kept: see + // the note above on why they are different measurements. continue; } chosen @@ -432,6 +487,24 @@ pub fn node_pairs( chosen } +/// Where the ring is placed for one directed node hop. +/// +/// **One definition, asked by both the run and the plan.** The hop loop iterates +/// this to decide what to measure, and [`RunPlan`] asks its length to decide +/// what to promise. Restating the count in the plan is how the plan came to +/// under-report once already: an earlier version quoted 18 timed handoffs +/// against a run that performed 12, because the two counted independently. +/// +/// The two entries are the two quantities a single row would average away. With +/// the ring on the producer's node the producer writes locally and the consumer +/// reads across; on the consumer's node that reverses. Remote-write and +/// remote-read are not interchangeable, and on some interconnects they are not +/// even close. +#[must_use] +pub fn memory_placements(producer: ProcessorPlace, consumer: ProcessorPlace) -> [u32; 2] { + [producer.numa_node, consumer.numa_node] +} + /// Measure every expressible placement under baseline and cached strategies. /// /// # Do not add a seam here to inject a processor list @@ -479,6 +552,9 @@ pub fn measure() -> std::io::Result { nanos_per_item: median.nanos / ITEMS as f64, consumer_batch: ITEMS as f64 / median.consumer_refreshes.max(1) as f64, producer_batch: ITEMS as f64 / median.producer_refreshes.max(1) as f64, + // Placement rows do not choose a node: they vary where the + // threads run, holding everything else as it falls. + memory_node: median.memory_node, }); } } @@ -508,6 +584,9 @@ pub fn measure() -> std::io::Result { nanos_per_item: median.nanos / ITEMS as f64, consumer_batch: ITEMS as f64 / median.consumer_refreshes.max(1) as f64, producer_batch: ITEMS as f64 / median.producer_refreshes.max(1) as f64, + // Placement rows do not choose a node: they vary where the + // threads run, holding everything else as it falls. + memory_node: median.memory_node, }); } } @@ -515,22 +594,35 @@ pub fn measure() -> std::io::Result { let mut by_node_pair = Vec::new(); for ((left, right), (producer, consumer)) in node_pairs(&processors) { debug_assert_eq!((producer.numa_node, consumer.numa_node), (left, right)); - for strategy in [Strategy::Baseline, Strategy::Cached] { - let mut samples: Vec<_> = (0..REPETITIONS) - .map(|_| time_model_on(strategy, Some(producer.id()), Some(consumer.id()))) - .collect(); - samples.sort_by(|a, b| a.nanos.total_cmp(&b.nanos)); - let median = samples[samples.len() / 2]; - by_node_pair.push(Measurement { - slice: Slice::pair(producer, consumer), - producer, - consumer, - placement: classify(producer, consumer), - strategy, - nanos_per_item: median.nanos / ITEMS as f64, - consumer_batch: ITEMS as f64 / median.consumer_refreshes.max(1) as f64, - producer_batch: ITEMS as f64 / median.producer_refreshes.max(1) as f64, - }); + // Both memory placements, as separate rows -- see `memory_placements`, + // which is also what the plan counts, so the two cannot disagree. + for memory_node in memory_placements(producer, consumer) { + for strategy in [Strategy::Baseline, Strategy::Cached] { + let mut samples: Vec<_> = (0..REPETITIONS) + .map(|_| { + time_model_placed( + strategy, + Some(producer.id()), + Some(consumer.id()), + Some(memory_node), + ) + }) + .collect(); + samples.sort_by(|a, b| a.nanos.total_cmp(&b.nanos)); + let median = samples[samples.len() / 2]; + by_node_pair.push(Measurement { + slice: Slice::pair(producer, consumer), + producer, + consumer, + placement: classify(producer, consumer), + strategy, + nanos_per_item: median.nanos / ITEMS as f64, + consumer_batch: ITEMS as f64 / median.consumer_refreshes.max(1) as f64, + producer_batch: ITEMS as f64 / median.producer_refreshes.max(1) as f64, + // What the run *achieved*, not what it asked for. + memory_node: median.memory_node, + }); + } } } diff --git a/crates/windows-placement-probe/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs index 151239f3..cfbd8a53 100644 --- a/crates/windows-placement-probe/src/core_affinity/tests.rs +++ b/crates/windows-placement-probe/src/core_affinity/tests.rs @@ -7,7 +7,7 @@ //! seconds. What is worth testing here is that the probe cannot silently //! mislabel a pair, because every conclusion it prints is keyed on that label. -use super::{Placement, classify, node_pairs, representative_pairs}; +use super::{Placement, RunPlan, classify, memory_placements, node_pairs, representative_pairs}; use crate::fingerprint::ProcessorPlace; /// A processor on its own physical core, which is the non-SMT case. @@ -581,18 +581,18 @@ fn a_four_node_host_still_reports_exactly_one_node_crossing_row() { // measures each hop separately; as above, only selection is testable offline. // --------------------------------------------------------------------------- -/// Every chosen pair must genuinely span the two nodes it is filed under, in -/// canonical order. +/// Every chosen pair must genuinely span the two nodes it is filed under, with +/// the producer on the node the key names first. fn assert_node_pairs_are_faithful(places: &[ProcessorPlace]) { - for ((low, high), (producer, consumer)) in node_pairs(places) { - assert!(low < high, "key ({low}, {high}) is not in canonical order"); + for ((from, to), (producer, consumer)) in node_pairs(places) { + assert_ne!(from, to, "key ({from}, {to}) is not a crossing"); assert_eq!( - producer.numa_node, low, - "producer {producer} is not on node {low}" + producer.numa_node, from, + "producer {producer} is not on node {from}" ); assert_eq!( - consumer.numa_node, high, - "consumer {consumer} is not on node {high}" + consumer.numa_node, to, + "consumer {consumer} is not on node {to}" ); assert_eq!( classify(producer, consumer), @@ -618,19 +618,22 @@ fn a_single_node_host_has_no_node_pairs() { } #[test] -fn a_two_node_host_has_exactly_one_node_pair() { +fn a_two_node_host_measures_its_one_edge_in_both_directions() { let places = two_socket_many_cache_domains(); let pairs = node_pairs(&places); - assert_eq!(pairs.len(), 1); + // Two rows for one edge: the producer on node 0 and the producer on + // node 1 are different measurements of it. + assert_eq!(pairs.len(), 2); assert!(pairs.contains_key(&(0, 1))); + assert!(pairs.contains_key(&(1, 0))); assert_node_pairs_are_faithful(&places); } #[test] -fn a_four_node_host_measures_every_hop_exactly_once() { - // The whole reason this exists: six distinct hops, not one row standing in - // for all of them. +fn a_four_node_host_measures_every_hop_in_both_directions() { + // The whole reason this exists: twelve distinct hops -- six edges, each + // measured both ways -- not one row standing in for all of them. let places = synthesize(&HostSpec { nodes: 4, cache_domains_per_node: 1, @@ -641,15 +644,34 @@ fn a_four_node_host_measures_every_hop_exactly_once() { let mut keys: Vec<_> = pairs.keys().copied().collect(); keys.sort_unstable(); - assert_eq!(keys, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]); + assert_eq!( + keys, + vec![ + (0, 1), + (0, 2), + (0, 3), + (1, 0), + (1, 2), + (1, 3), + (2, 0), + (2, 1), + (2, 3), + (3, 0), + (3, 1), + (3, 2), + ] + ); assert_node_pairs_are_faithful(&places); } #[test] -fn node_pairs_are_undirected_so_a_hop_is_never_measured_twice() { - // `0 -> 1` and `1 -> 0` traverse the same link, so measuring both would - // double the cost of the table and invite a reader to treat the difference - // between them as signal when it is noise. +fn node_pairs_are_directed_so_both_ends_take_a_turn_producing() { + // The correction this replaced: an earlier version selected one direction + // per edge, reasoning that both traverse the same link. The link is + // symmetric; the workload is not. The producer writes and the consumer + // reads, so `0 -> 1` and `1 -> 0` measure a remote write and a remote read + // over that link, which are different quantities and on some interconnects + // not close ones. let places = synthesize(&HostSpec { nodes: 3, cache_domains_per_node: 1, @@ -658,20 +680,20 @@ fn node_pairs_are_undirected_so_a_hop_is_never_measured_twice() { }); let pairs = node_pairs(&places); - assert_eq!(pairs.len(), 3); - for (low, high) in pairs.keys() { - assert!(low < high); + assert_eq!(pairs.len(), 6); + for (from, to) in pairs.keys() { assert!( - !pairs.contains_key(&(*high, *low)), - "both directions of ({low}, {high}) were selected" + pairs.contains_key(&(*to, *from)), + "({from}, {to}) was selected but its reverse was not" ); } } #[test] -fn the_hop_count_is_the_triangular_number_of_the_node_count() { +fn the_hop_count_is_every_ordered_pair_of_distinct_nodes() { // A property rather than a fixture, so a host size nobody wrote a test for - // is still covered. + // is still covered. `n * (n - 1)`, not the triangular number: every ordered + // pair, because order is what decides who writes. for nodes in 1..=8_u32 { let places = synthesize(&HostSpec { nodes, @@ -679,7 +701,7 @@ fn the_hop_count_is_the_triangular_number_of_the_node_count() { cores_per_cache_domain: 1, threads_per_core: 1, }); - let expected = (nodes * nodes.saturating_sub(1) / 2) as usize; + let expected = (nodes * nodes.saturating_sub(1)) as usize; assert_eq!( node_pairs(&places).len(), @@ -725,8 +747,9 @@ fn a_node_pair_is_still_selected_when_the_nodes_are_not_numbered_from_zero() { let pairs = node_pairs(&places); - assert_eq!(pairs.len(), 1); + assert_eq!(pairs.len(), 2); assert!(pairs.contains_key(&(2, 5))); + assert!(pairs.contains_key(&(5, 2))); assert_node_pairs_are_faithful(&places); } @@ -858,3 +881,144 @@ mod processor_groups { ); } } + +// --------------------------------------------------------------------------- +// The plan. +// +// The plan is printed before a run starts, and someone decides whether to spend +// their afternoon on the strength of it. It has been wrong twice: once quoting +// 18 timed handoffs against a run that performed 12, and once quoting a floor of +// 1 second against a run that took 0.6. Both had the same cause -- the plan +// counted independently of the loop it describes -- so these tests check the +// plan against the same functions the run asks. +// --------------------------------------------------------------------------- + +/// What the hop loop will do, derived rather than restated. +fn expected_hop_selections(places: &[ProcessorPlace]) -> usize { + node_pairs(places) + .values() + .map(|(producer, consumer)| memory_placements(*producer, *consumer).len()) + .sum() +} + +#[test] +fn a_single_node_plan_promises_no_hops() { + let places = synthesize(&HostSpec { + nodes: 1, + cache_domains_per_node: 2, + cores_per_cache_domain: 2, + threads_per_core: 2, + }); + + let plan = RunPlan::for_processors(&places); + + assert_eq!(plan.node_hops, 0, "a single-node host promised a crossing"); + assert_eq!( + plan.memory_placements_per_hop, 0, + "a host with no hops promised memory placements for them" + ); +} + +#[test] +fn the_plan_counts_a_hop_once_per_memory_placement() { + // The count that was missed: an edge measured in both directions at both + // ring placements is four selections, not one. + let places = two_socket_many_cache_domains(); + + let plan = RunPlan::for_processors(&places); + + assert_eq!( + plan.node_hops, 2, + "both directions of the edge were not planned" + ); + assert_eq!( + plan.memory_placements_per_hop, 2, + "the plan did not expect both ring placements" + ); + assert_eq!( + plan.node_hops * plan.memory_placements_per_hop, + expected_hop_selections(&places), + "the plan's hop selections do not match what the run will perform" + ); +} + +#[test] +fn the_plan_counts_every_hop_selection_on_hosts_of_every_size() { + // A property rather than a fixture: the machines this tool was written for + // are larger than anything available to write a fixture against, and the + // plan's error grows with the node count, so the untested sizes are exactly + // the ones where being wrong costs the most. + for nodes in 1..=8_u32 { + let places = synthesize(&HostSpec { + nodes, + cache_domains_per_node: 1, + cores_per_cache_domain: 2, + threads_per_core: 1, + }); + + let plan = RunPlan::for_processors(&places); + + assert_eq!( + plan.node_hops * plan.memory_placements_per_hop, + expected_hop_selections(&places), + "wrong hop selection count for {nodes} nodes" + ); + } +} + +#[test] +fn every_timed_handoff_the_run_performs_is_in_the_plan() { + // Ties the headline number to the loops rather than to a hand-derived + // constant. A constant would need editing whenever the run changes, which + // is precisely the edit that gets forgotten. + let places = two_socket_many_cache_domains(); + + let plan = RunPlan::for_processors(&places); + + let selections = + representative_pairs(&places).len() + plan.classes + expected_hop_selections(&places); + assert_eq!( + plan.timed_runs(), + selections * plan.strategies * plan.repetitions, + "the promised handoff count does not match the run" + ); +} + +#[test] +fn memory_placements_names_both_endpoints() { + // Both, and in this order: the producer's node first, so the first row of a + // hop is the one where the producer writes locally. + let places = two_socket_many_cache_domains(); + let (producer, consumer) = *node_pairs(&places) + .get(&(0, 1)) + .expect("the two-socket fixture has a 0 -> 1 hop"); + + assert_eq!( + memory_placements(producer, consumer), + [producer.numa_node, consumer.numa_node] + ); +} + +#[test] +fn a_longer_run_is_never_promised_as_shorter() { + // The estimate must not shrink when the machine grows. It is read as a + // worst case, and a bigger machine that promises less is the one failure + // mode a reader cannot detect from the output. + let mut previous = 0.0_f64; + for nodes in 1..=6_u32 { + let places = synthesize(&HostSpec { + nodes, + cache_domains_per_node: 1, + cores_per_cache_domain: 2, + threads_per_core: 1, + }); + + let seconds = RunPlan::for_processors(&places).estimated_seconds(); + + assert!( + seconds >= previous, + "{nodes} nodes promised {seconds}s, less than the {previous}s promised for fewer" + ); + previous = seconds; + } +} diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index b029b249..4c72ad72 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -568,9 +568,13 @@ mod from_topology { assert!(pairs.contains_key(&Placement::CrossCacheSameClass)); assert!(pairs.contains_key(&Placement::CrossNumaNode)); + // Both directions, from positions the real conversion produced: the + // edge is one link but two measurements, because the producer writes + // and the consumer reads. let hops = node_pairs(&places); - assert_eq!(hops.len(), 1); + assert_eq!(hops.len(), 2); assert!(hops.contains_key(&(0, 1))); + assert!(hops.contains_key(&(1, 0))); } } diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index d62be90c..9f5fe563 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -61,6 +61,9 @@ use windows_sys::Win32::System::SystemInformation::GROUP_AFFINITY; use windows_sys::Win32::System::Threading::{GetCurrentThread, SetThreadGroupAffinity}; use windows_waitable_queues::spsc; +#[cfg(test)] +mod tests; + /// Items handed across the ring in one timed run. pub const ITEMS: usize = 2_000_000; @@ -170,6 +173,12 @@ pub fn measure() -> Observation { /// One timed pass, with the shared-read counts that pass performed. #[derive(Debug, Clone, Copy)] pub struct Sample { + /// Which NUMA node held the ring, when that could be arranged. + /// + /// None means no placement was requested, or one was requested and could + /// not be achieved -- recorded rather than assumed, because a hop measured + /// with the data somewhere unknown is not a measurement of that hop. + pub memory_node: Option, /// Wall-clock nanoseconds for the whole pass. pub nanos: f64, /// How many times the consumer read the producer's shared position. @@ -231,6 +240,9 @@ fn time_real_spsc() -> Sample { // a retry. consumer_refreshes: ITEMS as u64, producer_refreshes: ITEMS as u64, + // The calibration runs the shipping queue, which allocates its own ring + // wherever it likes: this path has no placement to report. + memory_node: None, } } @@ -245,6 +257,8 @@ struct Ring { capacity: usize, head: CacheAligned, tail: CacheAligned, + /// Which NUMA node the slots were placed on, if the placement succeeded. + memory_node: Option, } // SAFETY: the two positions partition the slots between the threads exactly as @@ -254,19 +268,93 @@ struct Ring { unsafe impl Sync for Ring {} impl Ring { - fn new(capacity: usize) -> Self { + /// Build a ring whose slots live on a chosen NUMA node. + /// + /// # Why the node is a parameter rather than left to chance + /// + /// Windows places a page on the node of the thread that **first touches** + /// it. The obvious implementation allocates on whichever thread happens to + /// call this -- here, an unpinned orchestrator -- so the ring lands on a + /// node that may be neither the producer's nor the consumer's, and a re-run + /// can differ purely because that thread migrated. + /// + /// On a multi-socket machine that makes the number meaningless: a hop + /// measured with the data on an unknown third node is not a measurement of + /// that hop. So the caller names the node, and the record carries it beside + /// the two processor nodes. + /// + /// `None` allocates without a preference, which is the honest behaviour on + /// a machine with one node and the only option when the allocation fails. + fn new_on(capacity: usize, node: Option) -> Self { let mut slots = Vec::with_capacity(capacity); slots.resize_with(capacity, || UnsafeCell::new(0)); - Self { + let mut ring = Self { slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, head: CacheAligned(AtomicUsize::new(0)), tail: CacheAligned(AtomicUsize::new(0)), + memory_node: node, + }; + ring.place_on(node); + ring + } + + /// Bind the slots to `node` by touching them from a thread pinned there. + /// + /// **First touch is the mechanism, so a thread on the target node has to do + /// the touching.** `VirtualAllocExNuma` would express the preference more + /// directly, but it allocates whole pages outside Rust's allocator and + /// would mean hand-managing the slot array's lifetime for a property that + /// first-touch already provides. + /// + /// A failure to pin is deliberately *not* fatal here, unlike in + /// [`pin_current_thread`]: the fallback is a ring on the orchestrator's + /// node, and `memory_node` then records `None` rather than claiming a + /// placement that did not happen. + fn place_on(&mut self, node: Option) { + let Some(node) = node else { + return; + }; + let Some(cpu) = first_processor_of_node(node) else { + self.memory_node = None; + return; + }; + + let slots = &mut self.slots; + let placed = std::thread::scope(|scope| { + scope + .spawn(|| { + if !try_pin_current_thread(cpu) { + return false; + } + // Write, not read: a read can be served from a shared + // zero page on some configurations, which would leave the + // pages unplaced while looking touched. + for slot in slots.iter_mut() { + *slot.get_mut() = 0; + } + true + }) + .join() + .unwrap_or(false) + }); + + if !placed { + self.memory_node = None; } } } +/// The first logical processor belonging to `node`, for pinning the toucher. +fn first_processor_of_node(node: u32) -> Option<(u16, u8)> { + crate::fingerprint::discover_places() + .ok()? + .into_iter() + .find(|place| place.numa_node == node) + .map(|place| place.id()) +} + fn time_model(strategy: Strategy) -> Sample { time_model_on(strategy, None, None) } @@ -289,7 +377,23 @@ pub fn time_model_on( producer_cpu: Option<(u16, u8)>, consumer_cpu: Option<(u16, u8)>, ) -> Sample { - let ring = Ring::new(CAPACITY); + time_model_placed(strategy, producer_cpu, consumer_cpu, None) +} + +/// As [`time_model_on`], and also chooses which NUMA node holds the ring. +/// +/// The third position. With the memory on the producer's node the producer +/// writes locally and the consumer reads remotely; moving it to the consumer's +/// node reverses exactly that, and those are different costs rather than two +/// samples of one. +pub fn time_model_placed( + strategy: Strategy, + producer_cpu: Option<(u16, u8)>, + consumer_cpu: Option<(u16, u8)>, + memory_node: Option, +) -> Sample { + let ring = Ring::new_on(CAPACITY, memory_node); + let placed_on = ring.memory_node; let started = Instant::now(); let (consumer_refreshes, producer_refreshes) = thread::scope(|scope| { let shared = ˚ @@ -306,6 +410,7 @@ pub fn time_model_on( nanos: started.elapsed().as_nanos() as f64, consumer_refreshes, producer_refreshes, + memory_node: placed_on, } } @@ -323,6 +428,26 @@ pub fn time_model_on( /// processors that is not a matter of widening the mask; the call has no way to /// express the target at all. `SetThreadGroupAffinity` takes the group /// explicitly, and is the only way to pin across the whole machine. +/// Pin without stopping the run on failure. +/// +/// Separate from [pin_current_thread] because the two failures mean different +/// things. A measurement thread that cannot be pinned invalidates the run and +/// must stop; the page-touching thread only decides *where the memory lands*, +/// and a failure there is recorded as an unknown node rather than a lie. +fn try_pin_current_thread(cpu: (u16, u8)) -> bool { + let (group, number) = cpu; + if u32::from(number) >= usize::BITS { + return false; + } + let affinity = GROUP_AFFINITY { + Mask: 1_usize << number, + Group: group, + Reserved: [0; 3], + }; + // SAFETY: as in pin_current_thread. + unsafe { SetThreadGroupAffinity(GetCurrentThread(), &affinity, ptr::null_mut()) != 0 } +} + fn pin_current_thread(cpu: Option<(u16, u8)>) { let Some((group, number)) = cpu else { return; diff --git a/crates/windows-placement-probe/src/peer_index_cache/tests.rs b/crates/windows-placement-probe/src/peer_index_cache/tests.rs new file mode 100644 index 00000000..c5aee701 --- /dev/null +++ b/crates/windows-placement-probe/src/peer_index_cache/tests.rs @@ -0,0 +1,99 @@ +// Copyright (c) Mike Grier. + +//! Tests for the ring's memory placement. +//! +//! The timing itself is not testable offline -- it needs two real cores and +//! several seconds -- but the *honesty* of the placement is, and that is the +//! part a reader of the output depends on. A row claiming the ring sat on node +//! 3 when it did not is worse than a row admitting it does not know, because +//! nothing downstream can tell the difference. +//! +//! Every case here runs on a single-node host, which is what every machine +//! available to this workspace is. Node 0 exists everywhere Windows runs, and a +//! node that cannot exist is the other half of the pair. + +use super::{CAPACITY, Ring, first_processor_of_node}; + +/// A node id no machine will have. +/// +/// `u32::MAX` rather than a plausible-but-large number: the point is to be +/// certainly absent, not probably absent, so the test cannot start passing for +/// the wrong reason on a big enough machine. +const ABSENT_NODE: u32 = u32::MAX; + +#[test] +fn a_ring_asked_for_no_placement_records_none() { + let ring = Ring::new_on(CAPACITY, None); + + assert_eq!( + ring.memory_node, None, + "a ring that was never placed claimed a node" + ); +} + +#[test] +fn a_ring_placed_on_an_existing_node_records_it() { + // Node 0 exists on every Windows host, including the single-node VM slices + // this is developed on, so this case is reachable without NUMA hardware. + let ring = Ring::new_on(CAPACITY, Some(0)); + + assert_eq!( + ring.memory_node, + Some(0), + "a ring placed on node 0 did not record it" + ); +} + +#[test] +fn a_ring_that_could_not_be_placed_records_none_rather_than_the_node_it_wanted() { + // The defect this exists to prevent. The obvious implementation stores the + // requested node and never revisits it, so a failed placement produces a + // row that reads exactly like a successful one. `memory_node` must be what + // the run *achieved*, not what it asked for. + let ring = Ring::new_on(CAPACITY, Some(ABSENT_NODE)); + + assert_eq!( + ring.memory_node, None, + "a ring recorded a node it could not be placed on" + ); +} + +#[test] +fn the_first_processor_of_an_absent_node_is_none() { + assert_eq!( + first_processor_of_node(ABSENT_NODE), + None, + "a node that cannot exist offered a processor" + ); +} + +#[test] +fn node_zero_offers_a_processor_to_touch_from() { + // If this ever fails, placement silently degrades to "unknown" on every row + // rather than erroring, so it is worth asserting the happy path exists at + // all rather than inferring it from the ring test above. + assert!( + first_processor_of_node(0).is_some(), + "node 0 offered no processor, so nothing can place memory on it" + ); +} + +#[test] +fn a_placed_ring_is_still_a_usable_ring() { + // Placement writes to every slot from another thread. That must leave the + // ring in its initial state and not, say, a half-full one -- a ring whose + // indices moved would time a shorter run and report it as a faster one. + let ring = Ring::new_on(CAPACITY, Some(0)); + + assert_eq!(ring.slots.len(), CAPACITY, "placement resized the ring"); + assert_eq!( + ring.head.0.load(std::sync::atomic::Ordering::Relaxed), + 0, + "placement advanced the head" + ); + assert_eq!( + ring.tail.0.load(std::sync::atomic::Ordering::Relaxed), + 0, + "placement advanced the tail" + ); +} diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index ab4c48f1..77387ae0 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -51,7 +51,7 @@ use crate::machine::MachineDescription; /// **The golden files are append-only and a published version is never /// redefined.** Once a record exists in the wild claiming schema N, N's meaning /// is fixed, because that record cannot be regenerated. -pub const SCHEMA_VERSION: u32 = 1; +pub const SCHEMA_VERSION: u32 = 2; /// One run's complete output. #[derive(Clone, Debug)] @@ -124,6 +124,18 @@ pub struct MeasurementRecord { pub consumer_batch: f64, /// The same for the producer side. pub producer_batch: f64, + /// Which NUMA node held the ring's slots. + /// + /// **The third position.** A hop measured with the data on an unknown node + /// is not a measurement of that hop, so this is recorded rather than left + /// to whichever node the orchestrating thread happened to occupy. + /// + /// `null` means two different things, told apart by which array the row is + /// in. In `node_hops` a placement was always arranged, so `null` there means + /// one was attempted and **could not be achieved** -- a caveat on that row. + /// In `placements` and `by_class` none is ever arranged, so `null` is the + /// normal case and means the ring was left wherever the allocator put it. + pub memory_node: Option, } impl From<&Measurement> for MeasurementRecord { @@ -141,6 +153,7 @@ impl From<&Measurement> for MeasurementRecord { nanos_per_item: measurement.nanos_per_item, consumer_batch: measurement.consumer_batch, producer_batch: measurement.producer_batch, + memory_node: measurement.memory_node, } } } diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index 6dff0df8..e6f833a8 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -36,6 +36,7 @@ pub(crate) fn fully_populated() -> SubmissionRecord { nanos_per_item: 10.5, consumer_batch: 84.9, producer_batch: 1.0, + memory_node: Some(0), }; SubmissionRecord { diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index 309a0eff..170b9312 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -85,6 +85,15 @@ fn render_placements(out: &mut String, record: &SubmissionRecord) { return; } + // Says what the table covers, because the label alone does not. Each row is + // one direction on one representative pair, with the ring left wherever the + // allocator put it -- not an average over an edge. The hop table below is + // where direction and ring placement are varied deliberately. + let _ = writeln!( + out, + " One direction per row (prod -> cons), ring left where it fell." + ); + let _ = writeln!(out); let _ = writeln!( out, "{:<26} {:<10} {:>12} {:>12}", @@ -136,19 +145,31 @@ fn render_node_hops(out: &mut String, record: &SubmissionRecord) { return; } + // Three positions, three columns. `->` and not `<->`: the row describes a + // direction, because the producer writes and the consumer reads, and the + // ring sits on one node or the other while they do. A row that named only + // the two endpoints would leave the reader unable to tell a remote write + // from a remote read -- the two costs this table exists to separate. let _ = writeln!( out, - "{:<14} {:<10} {:>12} {:>12}", - "node pair", "strategy", "ns/item", "batch depth" + "{:<12} {:<8} {:<10} {:>12} {:>12}", + "prod -> cons", "ring on", "strategy", "ns/item", "batch depth" ); for entry in &record.node_hops { + let ring_on = match entry.memory_node { + Some(node) => format!("node {node}"), + // Reported, not hidden. A hop whose ring landed somewhere unknown + // is still a measurement, but not of the pair it names. + None => "unknown".to_owned(), + }; let _ = writeln!( out, - "{:<14} {:<10} {:>12.1} {:>12.1}", + "{:<12} {:<8} {:<10} {:>12.1} {:>12.1}", format!( - "{} <-> {}", + "{} -> {}", entry.producer_numa_node, entry.consumer_numa_node ), + ring_on, entry.strategy, entry.nanos_per_item, entry.consumer_batch diff --git a/crates/windows-placement-probe/src/report/tests.rs b/crates/windows-placement-probe/src/report/tests.rs index ab73cca8..a6c6c8fc 100644 --- a/crates/windows-placement-probe/src/report/tests.rs +++ b/crates/windows-placement-probe/src/report/tests.rs @@ -170,3 +170,102 @@ fn a_virtualisation_hint_is_not_rendered_as_a_certainty() { ); assert!(!text.contains("bare metal"), "got {text}"); } + +/// The four rows one NUMA edge produces: two directions, each at two ring +/// placements. +/// +/// The shared fixture's "hop" has both endpoints on node 0, which is not a +/// crossing at all -- it is there to populate the array, not to describe one. +/// The hop table is the part of the report this workspace's hardware cannot +/// exercise, so the fixture has to be the thing that is realistic. +fn one_numa_edge() -> Vec { + let mut rows = Vec::new(); + for (producer_node, consumer_node) in [(0_u32, 1_u32), (1, 0)] { + for memory_node in [producer_node, consumer_node] { + let mut row = fully_populated().node_hops[0].clone(); + row.placement = "cross NUMA node".to_owned(); + row.producer_numa_node = producer_node; + row.consumer_numa_node = consumer_node; + row.memory_node = Some(memory_node); + // Distinct per row, so a report that collapses two rows into one is + // visible rather than merely suspected. + row.nanos_per_item = 100.0 + f64::from(producer_node) * 10.0 + f64::from(memory_node); + rows.push(row); + } + } + rows +} + +#[test] +fn every_row_of_a_numa_edge_is_separately_identifiable() { + // The defect this guards. Four measurements of one edge differ only in + // direction and ring placement, so a table that prints neither renders four + // rows a reader cannot tell apart -- and the two quantities the table exists + // to separate, remote write and remote read, are lost in the middle of it. + let mut record = fully_populated(); + record.node_hops = one_numa_edge(); + + let text = render(&record); + + for (from, to, memory) in [(0, 1, 0), (0, 1, 1), (1, 0, 0), (1, 0, 1)] { + // Two tokens on one line rather than a formatted row: asserting the + // exact spacing would make this a test of the column widths, which are + // free to change, instead of a test that the row is identifiable. + let matched = text.lines().filter(|line| { + line.contains(&format!("{from} -> {to}")) && line.contains(&format!("node {memory}")) + }); + assert_eq!( + matched.count(), + 1, + "the report does not distinguish {from} -> {to} with the ring on node {memory}: +{text}" + ); + } +} + +#[test] +fn a_hop_reads_as_a_direction_and_not_as_a_link() { + // `<->` was the earlier rendering, and it was wrong in a way that read as + // correct: it says the row describes a link, when the row describes one + // side writing and the other reading across it. + let mut record = fully_populated(); + record.node_hops = one_numa_edge(); + + let text = render(&record); + + assert!( + !text.contains("<->"), + "a hop is still rendered as an undirected link:\n{text}" + ); +} + +#[test] +fn a_hop_whose_ring_could_not_be_placed_says_so() { + // Not a hidden caveat. A hop measured with the ring on an unknown node is + // still a measurement, but not of the pair it names, and the row has to + // admit that rather than leave a blank column reading as a zero. + let mut record = fully_populated(); + let mut rows = one_numa_edge(); + rows[0].memory_node = None; + record.node_hops = rows; + + let text = render(&record); + + assert!( + text.contains("unknown"), + "a hop with no achieved placement did not admit it:\n{text}" + ); +} + +#[test] +fn the_placement_table_says_it_covers_one_direction() { + // Without this line the placement rows read as though they summarised a + // placement, when each is a single direction with the ring wherever the + // allocator left it. + let text = render(&fully_populated()); + + assert!( + text.contains("One direction per row"), + "the placement table does not say what it covers:\n{text}" + ); +} diff --git a/crates/windows-placement-probe/src/submission/tests.rs b/crates/windows-placement-probe/src/submission/tests.rs index 57e7100b..ab75548e 100644 --- a/crates/windows-placement-probe/src/submission/tests.rs +++ b/crates/windows-placement-probe/src/submission/tests.rs @@ -181,7 +181,16 @@ fn the_file_name_is_predictable_and_safe_for_a_filesystem() { let record = fully_populated(); let name = file_name(&record); - assert!(name.starts_with("placement-probe-v1-"), "got {name}"); + // Asks the constant rather than naming a version. Hardcoding `v1` made this + // fail on the bump to v2 as though the name were broken, when the name was + // correctly following the schema it describes. + assert!( + name.starts_with(&format!( + "placement-probe-v{}-", + crate::record::SCHEMA_VERSION + )), + "got {name}" + ); assert!(name.ends_with(".json"), "got {name}"); assert!( !name.contains(':'), From ee776f864140d9729fdf492c4b6fc01a02c339a9 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 17:14:10 -0400 Subject: [PATCH 077/361] docs(placement-probe): propagate D-28 to the comments that still state the refuted hypothesis Running the release binary reproduced D-28's numbers -- siblings 10.7-11.7 ns baseline against 5.8-6.1 cached at batch depth 87-140, cross-cache 19.4-24.3 against 38.1-40.5 at batch depth 1.3-2.8, stable over four runs -- and that is what surfaced this: two comments in the probe still assert the hypothesis those numbers refute. `Placement::SameCoreSiblings` gave the refuted mechanism as the reason the variant exists ("siblings sharing L1 have every reason to stay in lockstep, which is the shallow-batch condition that makes caching lose"). The variant's reason for existing is sound; the mechanism attached to it is backwards. Siblings produce the deepest batches measured on this host and caching wins on them. The module header presented the question as open ("The hypothesis this probe exists to test..."), which reads as though the answer is not in yet. It is in, and it is a refutation. This is restatement drift, not a new finding: the correction was recorded in D-28 when it was made and never propagated to the source comments. Swept `lockstep` and `shallow batch` across the workspace -- 8 hits in 5 files, 2 updated here, 4 in windows-waitable-queues/DESIGN-NOTES.md which is the authority being cited, 1 in windows-platform-probes describing cost-driven depth, and 1 in windows-thread-ambient-sys about priority changes, both unrelated uses of the word. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core_affinity.rs | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index 30ccb7e4..68e8dde1 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -40,6 +40,20 @@ //! the hypothesis is wrong and the host difference needs another explanation; //! the numbers below say so either way. //! +//! # It did refute it, and that is the finding +//! +//! Read the above as the question, not the answer. The x64 host has answered +//! it, and the equal-speed half of the hypothesis is **wrong in the direction +//! nobody predicted**: SMT siblings -- as evenly matched as two threads can be, +//! sharing L1 -- produce the *deepest* batches on that host, not the shallowest, +//! and caching wins on them by 1.8x. The shallow batches are between cores. +//! +//! What survives is the part that matters: batch depth is set by **placement**, +//! the verdict on caching flips inside a single machine, and no rule keyed to +//! the instruction set can be right. The mechanism proposed for it did not +//! survive. See +//! [DESIGN-NOTES.md](../../windows-waitable-queues/DESIGN-NOTES.md) `D-28`. +//! //! # What it controls for //! //! Cross-class pairs differ in two ways at once -- the cores run at different @@ -227,10 +241,22 @@ pub enum Placement { /// and kept distinct from `SameCacheSameClass` for a measured reason: on an /// SMT host a sibling pair and a two-core pair behind one cache would /// otherwise land in the same bucket, and the probe would report whichever - /// it happened to select. That is precisely the distinction needed to - /// explain why peer-index caching loses on an SMT x64 host and wins on a - /// non-SMT ARM64 one -- siblings sharing L1 have every reason to stay in - /// lockstep, which is the shallow-batch condition that makes caching lose. + /// it happened to select. + /// + /// **That distinction refuted the hypothesis it was built to test, and this + /// comment used to state the refuted version.** The prediction was that + /// siblings sharing L1 stay in lockstep, giving shallow batches, and that + /// this was the condition making peer-index caching lose. Measurement says + /// the opposite: siblings produce by far the *deepest* batches on an x64 + /// host and caching wins there by 1.8x, while the shallow batches -- and the + /// loss -- are on the cross-core row. Sharing a cache decouples the two + /// sides rather than locking them together. + /// + /// The verdict therefore flips *inside* one machine, which is why no rule + /// keyed to the instruction set can be right. See + /// [DESIGN-NOTES.md](../../windows-waitable-queues/DESIGN-NOTES.md) + /// `D-28`, and note that this probe reproduces those numbers on the host it + /// was written on -- which is the point of shipping it. /// /// Absent on a machine without SMT, where it is reported inexpressible /// rather than merged into another category. From 90e007f5fa71c7d7226514890757635ec730eafe Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 17:30:20 -0400 Subject: [PATCH 078/361] feat(placement-probe): lay the record out for a reader rather than a parser `to_string_pretty` gives every array element its own line. Eight cache domains cost eight lines saying `2`, and an eight-socket host would spend sixty-four lines listing its nodes one integer at a time -- on precisely the machine whose submission matters most, and in a paste people are asked to read before sending. Arrays holding no object now collapse onto one line when they fit, or fill lines up to a width budget when they do not; sixty-four node sizes come out as four lines. Objects still expand one field per line, so a reader scanning for a field finds it in a predictable place, and arrays of measurement rows are unchanged. Field order turned out to be the interesting part. The first draft laid out a `serde_json::Value`, whose object is a `BTreeMap`, and every test passed: the JSON was valid, the data round-tripped, the arrays collapsed. Reading the actual output of a run showed it had quietly sorted `build` above `schema_version` and made every measurement row open with `consumer_batch` instead of `placement`. The layout now walks an order-preserving tree of its own. `serde_json`'s `preserve_order` feature would have fixed that in one line and is deliberately not used: cargo unifies features across a build, and four other crates in this workspace share `serde_json`, so switching their map type to satisfy this module's typography would have been a change to them. The three tests that guard order are the ones this commit is really for -- sabotage confirms all three fail when routed back through `Value`, as does the round trip when a separator is dropped and the width guard when a key's width is not charged against its value. Three call sites independently asked `serde_json` for a layout: the submission, the backup file, and three tests checking the checksum. They now all ask one function, so the printed record, the file, and the digest cannot disagree. Completed item: PT-4.7: Lay the JSON out for a reader rather than a parser Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 12 + .../src/bin/placement_probe.rs | 4 +- crates/windows-placement-probe/src/lib.rs | 2 + .../windows-placement-probe/src/paste_json.rs | 309 ++++++++++++++++++ .../src/paste_json/tests.rs | 285 ++++++++++++++++ .../windows-placement-probe/src/submission.rs | 13 +- .../src/submission/tests.rs | 8 +- placement-probe-v2-2026-08-31T21-18-17Z.json | 110 +++++++ 8 files changed, 733 insertions(+), 10 deletions(-) create mode 100644 crates/windows-placement-probe/src/paste_json.rs create mode 100644 crates/windows-placement-probe/src/paste_json/tests.rs create mode 100644 placement-probe-v2-2026-08-31T21-18-17Z.json diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index c826c07d..67ff1b57 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -391,6 +391,18 @@ that carries them is written. and no opaque blobs. A file that must be decoded to be checked cannot honestly be described as inspectable. +- [x] **PT-4.7** -- **Lay the JSON out for a reader rather than a parser.** `to_string_pretty` gives + every array element its own line, so eight cache domains cost eight lines saying `2` and a + sixty-four-node host would spend sixty-four lines listing its nodes one integer at a time -- on + precisely the machine whose submission matters most. Arrays holding no object now collapse onto one + line, or fill lines up to a width budget, while objects still expand one field per line. + **Field order is part of the contract, not incidental.** The first draft laid out a + `serde_json::Value`, whose object is a `BTreeMap`, and every test still passed while the output + quietly sorted `build` above `schema_version` and made each measurement row open with + `consumer_batch`. Caught by reading a run, not by the suite. Order is now preserved by walking an + order-preserving tree; `serde_json`'s `preserve_order` feature is deliberately **not** used, because + cargo unifies features and four other crates in this workspace share `serde_json`. + ## M5: distribution **The CI-built artifact is the canonical way to get this tool**, not `cargo install`. Two reasons, and diff --git a/crates/windows-placement-probe/src/bin/placement_probe.rs b/crates/windows-placement-probe/src/bin/placement_probe.rs index fc5d274f..18be5d71 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe.rs @@ -176,7 +176,9 @@ fn print_plan(plan: &RunPlan) { /// text on screen, and losing the backup copy costs nothing that matters. fn write_backup(record: &SubmissionRecord) { let name = submission::file_name(record); - match serde_json::to_string_pretty(record) { + // The same layout as the printed record, so the backup a runner attaches + // and the text they paste are byte-identical. + match windows_placement_probe::paste_json::to_paste_json(record) { Ok(json) => match std::fs::write(&name, json) { Ok(()) => println!("(a copy of the record was also written to {name})"), Err(error) => { diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index dfc864f5..ef919387 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -59,6 +59,8 @@ pub mod core_affinity; pub mod fingerprint; /// What machine this was, beyond its measurable shape. pub mod machine; +/// JSON laid out to be read in a terminal rather than by a machine. +pub mod paste_json; /// The handoff itself, and the strategies the placement experiment compares. pub mod peer_index_cache; /// The record a run produces and a runner sends back. diff --git a/crates/windows-placement-probe/src/paste_json.rs b/crates/windows-placement-probe/src/paste_json.rs new file mode 100644 index 00000000..d601036a --- /dev/null +++ b/crates/windows-placement-probe/src/paste_json.rs @@ -0,0 +1,309 @@ +// Copyright (c) Mike Grier. + +//! JSON laid out to be read in a terminal and pasted into a thread. +//! +//! `serde_json::to_string_pretty` puts every array element on its own line, +//! which is the right default for a file nobody reads and the wrong one here. +//! This record is read by a person before they send it, and scrolled past by +//! everyone who reads the thread afterwards. A machine reporting eight cache +//! domains spent eight lines saying `2`; an eight-socket host would spend +//! sixty-four lines listing its nodes one integer at a time -- and that is +//! exactly the machine whose submission matters most. +//! +//! # The rule +//! +//! Two decisions, in this order: +//! +//! 1. **An object always expands**, one field per line, even when it would fit. +//! Objects are where the meaning is, and a reader scanning for `os_build` +//! should find it in a predictable place rather than somewhere in the middle +//! of a long line. +//! 2. **An array holding no object collapses** onto one line when it fits, and +//! otherwise fills lines up to the width budget rather than going one +//! element per line. So `[2, 2, 2, 2, 2, 2, 2, 2]` is one line, `[[0, 16]]` +//! is one line, sixty-four node sizes are a few lines, and `placements` -- +//! an array of objects -- expands as before. +//! +//! # Field order is the record's, not the alphabet's +//! +//! The layout walks an order-preserving tree of its own rather than +//! [`serde_json::Value`], whose object is a `BTreeMap` and so sorts keys. +//! Sorting looks harmless and is not: `schema_version` and `recorded_at` are +//! written first because that is what a reader needs first, and alphabetical +//! order buries them under `build` and `by_class`. Each measurement likewise +//! leads with `placement` and `strategy`, not `consumer_batch`. This was not +//! foreseen -- it was read off the output of a run, having been introduced by +//! an earlier draft of this very module. +//! +//! `serde_json`'s `preserve_order` feature would fix it by making its map an +//! `IndexMap`. It is deliberately not used: cargo unifies features across a +//! build, four other crates in this workspace share `serde_json`, and switching +//! their map type to satisfy this module's typography would be a change to +//! them. +//! +//! # This is layout only +//! +//! The bytes of every scalar come from `serde_json` itself, so numbers keep +//! their exact rendering and strings keep their exact escaping; this module +//! chooses only where the whitespace goes. That claim is worth the test that +//! backs it: the output is parsed back and compared against the value it came +//! from, so a layout that changed the data could not pass. + +use std::fmt; + +use serde::Serialize; +use serde::de::{Deserialize, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde_json::Value; + +/// The column a laid-out line tries not to exceed. +/// +/// Well under the 120 the submission's own width test enforces, because that +/// test is about surviving a paste and this is about being comfortable to read +/// in a terminal that is probably 80 or 100 wide. It is a target rather than a +/// guarantee: a single long string cannot be broken and is emitted whole. +const MAX_WIDTH: usize = 96; + +/// Spaces per level of nesting. +const INDENT: usize = 2; + +/// A JSON value that remembers the order its fields were written in. +/// +/// Scalars keep a [`serde_json::Value`] so their rendering stays `serde_json`'s +/// job; the ordering of object fields is this type's only contribution. +enum Node { + /// Anything that is not a container. + Scalar(Value), + /// Elements in order. + Array(Vec), + /// Fields in the order they were serialized, which is declaration order. + Object(Vec<(String, Node)>), +} + +/// Builds a [`Node`] from whatever the deserializer hands over, in order. +struct NodeVisitor; + +impl<'de> Visitor<'de> for NodeVisitor { + type Value = Node; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("any JSON value") + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut items = Vec::new(); + while let Some(item) = seq.next_element()? { + items.push(item); + } + Ok(Node::Array(items)) + } + + fn visit_map>(self, mut map: A) -> Result { + // `next_entry` yields fields in document order, which is the whole + // reason this type exists. + let mut fields = Vec::new(); + while let Some(entry) = map.next_entry::()? { + fields.push(entry); + } + Ok(Node::Object(fields)) + } + + fn visit_bool(self, value: bool) -> Result { + Ok(Node::Scalar(Value::Bool(value))) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(Node::Scalar(Value::from(value))) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(Node::Scalar(Value::from(value))) + } + + fn visit_f64(self, value: f64) -> Result { + Ok(Node::Scalar(Value::from(value))) + } + + fn visit_str(self, value: &str) -> Result { + Ok(Node::Scalar(Value::String(value.to_owned()))) + } + + fn visit_unit(self) -> Result { + Ok(Node::Scalar(Value::Null)) + } + + fn visit_none(self) -> Result { + Ok(Node::Scalar(Value::Null)) + } +} + +impl<'de> Deserialize<'de> for Node { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_any(NodeVisitor) + } +} + +/// Serialize `value` as JSON laid out for a person. +/// +/// # Errors +/// +/// Returns whatever serializing `value` failed with. The round trip through +/// text is how field order is kept: `serde_json` writes fields in declaration +/// order, and reading them straight back preserves it. +pub fn to_paste_json(value: &T) -> Result { + let ordered = serde_json::to_string(value)?; + let node: Node = serde_json::from_str(&ordered)?; + let mut out = String::new(); + write_value(&node, 0, 0, &mut out)?; + Ok(out) +} + +/// Whether an object appears anywhere inside `node`, including at its root. +/// +/// This is what decides that an array expands. Checking the whole subtree and +/// not just the immediate elements keeps rule 1 honest: an object must not slip +/// onto one line by being nested inside an array that happened to fit. +fn holds_an_object(node: &Node) -> bool { + match node { + Node::Object(_) => true, + Node::Array(items) => items.iter().any(holds_an_object), + Node::Scalar(_) => false, + } +} + +/// The one-line form of `node`, with a space after each separator. +/// +/// `serde_json`'s own compact form omits those spaces, which is right for a +/// wire format and cramped for something a person reads. +fn compact(node: &Node) -> Result { + match node { + Node::Array(items) => { + let parts = items + .iter() + .map(compact) + .collect::, _>>()? + .join(", "); + Ok(format!("[{parts}]")) + } + Node::Object(fields) => { + let mut parts = Vec::with_capacity(fields.len()); + for (key, child) in fields { + parts.push(format!( + "{}: {}", + serde_json::to_string(key)?, + compact(child)? + )); + } + Ok(format!("{{{}}}", parts.join(", "))) + } + // Scalars are `serde_json`'s to render, never this module's. + Node::Scalar(scalar) => serde_json::to_string(scalar), + } +} + +/// Append `count` spaces. +fn indent_by(out: &mut String, count: usize) { + for _ in 0..count { + out.push(' '); + } +} + +/// Write `node` starting at `column`, indenting any continuation to `indent`. +/// +/// `column` is where the value's first character lands, which is past the key +/// on an object field. Passing the indent instead would let a field's value +/// overrun the budget by the width of its own name. +fn write_value( + node: &Node, + indent: usize, + column: usize, + out: &mut String, +) -> Result<(), serde_json::Error> { + let inline = compact(node)?; + if !holds_an_object(node) && column + inline.len() <= MAX_WIDTH { + out.push_str(&inline); + return Ok(()); + } + + match node { + Node::Object(fields) if !fields.is_empty() => { + out.push_str("{\n"); + let inner = indent + INDENT; + for (position, (key, child)) in fields.iter().enumerate() { + indent_by(out, inner); + let key_text = serde_json::to_string(key)?; + out.push_str(&key_text); + out.push_str(": "); + write_value(child, inner, inner + key_text.len() + ": ".len(), out)?; + if position + 1 < fields.len() { + out.push(','); + } + out.push('\n'); + } + indent_by(out, indent); + out.push('}'); + } + Node::Array(items) if !items.is_empty() => { + out.push_str("[\n"); + let inner = indent + INDENT; + if holds_an_object(node) { + for (position, item) in items.iter().enumerate() { + indent_by(out, inner); + write_value(item, inner, inner, out)?; + if position + 1 < items.len() { + out.push(','); + } + out.push('\n'); + } + } else { + write_filled(items, inner, out)?; + } + indent_by(out, indent); + out.push(']'); + } + // An empty container, or a scalar too long for the budget. Nothing can + // be laid out, so it goes as it is rather than being broken. + other => out.push_str(&compact(other)?), + } + + Ok(()) +} + +/// Write `items` as filled lines, wrapping at the width budget. +/// +/// The alternative for a long array is one element per line, which is what this +/// module exists to avoid. +fn write_filled(items: &[Node], indent: usize, out: &mut String) -> Result<(), serde_json::Error> { + indent_by(out, indent); + let mut column = indent; + let mut line_is_empty = true; + + for (position, item) in items.iter().enumerate() { + let piece = compact(item)?; + let comma = usize::from(position + 1 < items.len()); + + if !line_is_empty && column + " ".len() + piece.len() + comma > MAX_WIDTH { + out.push('\n'); + indent_by(out, indent); + column = indent; + line_is_empty = true; + } + if !line_is_empty { + out.push(' '); + column += " ".len(); + } + + out.push_str(&piece); + column += piece.len(); + if comma == 1 { + out.push(','); + column += 1; + } + line_is_empty = false; + } + + out.push('\n'); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-placement-probe/src/paste_json/tests.rs b/crates/windows-placement-probe/src/paste_json/tests.rs new file mode 100644 index 00000000..371b75cb --- /dev/null +++ b/crates/windows-placement-probe/src/paste_json/tests.rs @@ -0,0 +1,285 @@ +// Copyright (c) Mike Grier. + +//! Tests for the paste layout. +//! +//! The one that matters is the round trip: layout must never change data. The +//! rest describe the shape a reader is promised, and several are written +//! against a machine larger than the host they run on, because the whole reason +//! this module exists is the submission from a big NUMA server. + +use serde_json::{Value, json}; + +use super::{MAX_WIDTH, to_paste_json}; + +/// Parse laid-out text back, so a test can compare data rather than characters. +fn round_trip(value: &Value) -> Value { + let text = to_paste_json(value).expect("must lay out"); + serde_json::from_str(&text).unwrap_or_else(|error| panic!("not valid JSON: {error}\n{text}")) +} + +#[test] +fn layout_never_changes_the_data() { + // The claim the module makes about itself. Awkward values on purpose: a + // string that needs escaping, floats that must keep their digits, an empty + // container of each kind, and a null. + let value = json!({ + "escaping": "a \"quoted\" \\ back\\slash\nand a newline\ttab", + "unicode": "em dash \u{2014} and an emoji \u{1f600}", + "float": 10.672_85, + "small_float": 0.000_000_1, + "big": 18_446_744_073_709_551_615_u64, + "negative": -42, + "nothing": null, + "yes": true, + "empty_object": {}, + "empty_array": [], + "nested": [[1, 2], [3, 4]], + "objects": [{"a": 1}, {"b": [1, 2, 3]}], + }); + + assert_eq!(round_trip(&value), value); +} + +#[test] +fn a_short_array_of_scalars_is_one_line() { + // The case that prompted this: eight cache domains should not be eight + // lines saying `2`. + let text = to_paste_json(&json!({"cache_domain_sizes": [2, 2, 2, 2, 2, 2, 2, 2]})) + .expect("must lay out"); + + assert!( + text.contains("\"cache_domain_sizes\": [2, 2, 2, 2, 2, 2, 2, 2]"), + "the array was not collapsed:\n{text}" + ); +} + +#[test] +fn an_array_of_short_arrays_is_one_line() { + let text = to_paste_json(&json!({"efficiency_classes": [[0, 16]]})).expect("must lay out"); + + assert!( + text.contains("\"efficiency_classes\": [[0, 16]]"), + "the nested array was not collapsed:\n{text}" + ); +} + +#[test] +fn a_long_array_of_scalars_fills_lines_instead_of_one_per_element() { + // The eight-socket case. One element per line would be 64 lines; this + // asserts it is a handful, and asserts the count rather than a fixed + // rendering so the width budget can move without editing the test. + let value = json!({"numa_node_sizes": vec![16; 64]}); + + let text = to_paste_json(&value).expect("must lay out"); + + let lines = text.lines().count(); + assert!( + lines < 12, + "64 elements took {lines} lines, which is close to one per element:\n{text}" + ); + assert_eq!(round_trip(&value), value); +} + +#[test] +fn no_line_exceeds_the_width_budget() { + // Holds for values with nothing unbreakable in them. A long string is the + // documented exception and is covered separately. + let value = json!({ + "numa_node_sizes": vec![1024; 64], + "cache_domain_sizes": vec![2; 40], + "nested": vec![vec![7, 8]; 30], + }); + + let text = to_paste_json(&value).expect("must lay out"); + + for line in text.lines() { + assert!( + line.chars().count() <= MAX_WIDTH, + "a {}-character line exceeds the budget: {line:?}", + line.chars().count() + ); + } +} + +#[test] +fn an_unbreakable_value_is_emitted_whole_rather_than_broken() { + // A string longer than the budget has no split point that keeps the JSON + // valid, so the budget yields. Stated as a test because the alternative -- + // breaking it -- would produce something that no longer parses. + let long = "x".repeat(MAX_WIDTH * 2); + let value = json!({ "slice": long }); + + let text = to_paste_json(&value).expect("must lay out"); + + assert!(text.contains(&long), "the long value was altered:\n{text}"); + assert_eq!(round_trip(&value), value); +} + +#[test] +fn an_object_always_expands_even_when_it_would_fit() { + // Rule 1. A reader scanning for a field should find it in a predictable + // place, so objects do not collapse just because they are short. + let value = json!({"build": {"dirty": true, "source": "local"}}); + + let text = to_paste_json(&value).expect("must lay out"); + + assert!( + text.contains("\"dirty\": true,\n"), + "a short object was collapsed onto one line:\n{text}" + ); +} + +#[test] +fn an_array_holding_an_object_expands_one_element_per_line() { + // Rule 2's exclusion. Measurement rows are the part a reader compares + // against each other, and they are only comparable when aligned. + let value = json!({"placements": [{"ns": 1.0}, {"ns": 2.0}]}); + + let text = to_paste_json(&value).expect("must lay out"); + + assert_eq!( + text.matches("\"ns\"").count(), + 2, + "expected both rows:\n{text}" + ); + assert!(!text.contains("}, {"), "two objects shared a line:\n{text}"); +} + +#[test] +fn an_object_nested_inside_a_fitting_array_still_expands() { + // The hole rule 1 would have if the check looked only at an array's + // immediate elements: a tiny object could slip onto one line by being + // wrapped in an array that fits the budget. + let value = json!({"rows": [[{"a": 1}]]}); + + let text = to_paste_json(&value).expect("must lay out"); + + assert!( + text.contains("\"a\": 1\n"), + "an object nested in a short array was collapsed:\n{text}" + ); +} + +#[test] +fn empty_containers_stay_on_one_line() { + let value = json!({"node_hops": [], "by_class": {}}); + + let text = to_paste_json(&value).expect("must lay out"); + + assert!(text.contains("\"node_hops\": []"), "got:\n{text}"); + assert!(text.contains("\"by_class\": {}"), "got:\n{text}"); +} + +#[test] +fn the_layout_is_deterministic() { + // A checksum is printed over this text, so identical input must produce + // identical bytes or the digest would be worthless. + let value = json!({"a": [1, 2, 3], "b": {"c": vec![9; 50]}}); + + assert_eq!( + to_paste_json(&value).expect("must lay out"), + to_paste_json(&value).expect("must lay out") + ); +} + +#[test] +fn a_field_name_is_counted_against_the_width_of_its_value() { + // The off-by-a-key-width bug. If the value is measured from the indent + // rather than from the column it starts at, a long name pushes its own + // value past the budget. + let name = "a".repeat(MAX_WIDTH - 20); + let value = json!({ name: vec![100; 4] }); + + let text = to_paste_json(&value).expect("must lay out"); + + for line in text.lines() { + assert!( + line.chars().count() <= MAX_WIDTH, + "a {}-character line exceeds the budget: {line:?}", + line.chars().count() + ); + } +} + +// --------------------------------------------------------------------------- +// Field order. +// +// An earlier draft of this module laid out a `serde_json::Value`, whose object +// is a `BTreeMap`. Every test above still passed: the JSON was valid, the data +// round-tripped, the arrays collapsed. It was only reading the tool's actual +// output that showed `build` had taken first place from `schema_version` and +// every measurement row now opened with `consumer_batch`. These are the tests +// that would have caught it. +// --------------------------------------------------------------------------- + +/// The top-level field names, in the order they appear. +fn top_level_keys(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let field = line.strip_prefix(" \"")?; + // Two-space indent only, so nested objects are not collected. + let (name, _) = field.split_once('"')?; + Some(name.to_owned()) + }) + .collect() +} + +#[test] +fn a_record_keeps_the_order_its_fields_are_declared_in() { + // Not merely "unsorted": the exact order, because the order is a choice. + // What a reader needs first -- which schema, when, from what build -- comes + // first, and the measurements come last. + let record = crate::record::tests::fully_populated(); + + let text = to_paste_json(&record).expect("must lay out"); + + assert_eq!( + top_level_keys(&text), + vec![ + "schema_version", + "recorded_at", + "recorded_at_epoch_seconds", + "build", + "machine", + "host", + "topology_provenance", + "placements", + "node_hops", + "by_class", + ] + ); +} + +#[test] +fn a_measurement_row_leads_with_what_identifies_it() { + // Alphabetical order puts `consumer_batch` first, which is a detail, and + // buries `placement` and `strategy`, which are what the row is about. + let record = crate::record::tests::fully_populated(); + + let text = to_paste_json(&record).expect("must lay out"); + let placement = text.find("\"placement\":").expect("a row must be present"); + let strategy = text + .find("\"strategy\":") + .expect("a row must have a strategy"); + let batch = text + .find("\"consumer_batch\":") + .expect("a row must have a batch depth"); + + assert!( + placement < strategy && strategy < batch, + "a measurement row is not in declaration order:\n{text}" + ); +} + +#[test] +fn nested_objects_keep_their_order_too() { + // The build stamp is the case where sorting is least obvious and most + // annoying: `commit` would come before `crate_version`. + let record = crate::record::tests::fully_populated(); + + let text = to_paste_json(&record).expect("must lay out"); + let version = text.find("\"crate_version\":").expect("must be present"); + let commit = text.find("\"commit\":").expect("must be present"); + + assert!(version < commit, "a nested object was reordered:\n{text}"); +} diff --git a/crates/windows-placement-probe/src/submission.rs b/crates/windows-placement-probe/src/submission.rs index 35586398..37585a2c 100644 --- a/crates/windows-placement-probe/src/submission.rs +++ b/crates/windows-placement-probe/src/submission.rs @@ -26,6 +26,7 @@ //! cost of a fabricated placement measurement is a wrong row in a table that //! disagrees with every other host, which is visible. +use crate::paste_json; use crate::record::SubmissionRecord; use crate::report; @@ -62,11 +63,13 @@ pub fn checksum(bytes: &[u8]) -> String { /// /// Returns whatever serializing the record failed with. pub fn render_submission(record: &SubmissionRecord) -> Result { - // Pretty-printed rather than compact. A collector parses either, but a - // *person* is being asked to look at this before sending it, and a single - // enormous line is both unreadable and the most likely thing a terminal - // will wrap. - let json = serde_json::to_string_pretty(record)?; + // Laid out for a person rather than for a parser. A collector parses + // either, but a *person* is being asked to look at this before sending it, + // and both a single enormous line and eight lines saying `2` fail that. + // See `paste_json` for the rule; every caller asks it rather than choosing + // a layout of their own, so the checksum and the file cannot disagree with + // what was printed. + let json = paste_json::to_paste_json(record)?; let digest = checksum(json.as_bytes()); let mut out = String::new(); diff --git a/crates/windows-placement-probe/src/submission/tests.rs b/crates/windows-placement-probe/src/submission/tests.rs index ab75548e..73cf0e47 100644 --- a/crates/windows-placement-probe/src/submission/tests.rs +++ b/crates/windows-placement-probe/src/submission/tests.rs @@ -84,7 +84,7 @@ fn the_checksum_is_printed_and_matches_the_json_that_follows_it() { let record = fully_populated(); let text = render_submission(&record).expect("must render"); - let json = serde_json::to_string_pretty(&record).expect("must serialize"); + let json = crate::paste_json::to_paste_json(&record).expect("must serialize"); let expected = checksum(json.as_bytes()); assert!( @@ -105,8 +105,8 @@ fn the_checksum_changes_when_the_record_does() { let mut b = fully_populated(); b.placements[0].nanos_per_item = 42.0; - let json_a = serde_json::to_string_pretty(&a).expect("must serialize"); - let json_b = serde_json::to_string_pretty(&b).expect("must serialize"); + let json_a = crate::paste_json::to_paste_json(&a).expect("must serialize"); + let json_b = crate::paste_json::to_paste_json(&b).expect("must serialize"); assert_ne!(checksum(json_a.as_bytes()), checksum(json_b.as_bytes())); } @@ -115,7 +115,7 @@ fn the_checksum_changes_when_the_record_does() { fn the_checksum_catches_a_truncated_paste() { // The failure actually being defended against: a scrollback limit or a // half-dragged selection. - let json = serde_json::to_string_pretty(&fully_populated()).expect("must serialize"); + let json = crate::paste_json::to_paste_json(&fully_populated()).expect("must serialize"); let truncated = &json[..json.len() / 2]; assert_ne!(checksum(json.as_bytes()), checksum(truncated.as_bytes())); diff --git a/placement-probe-v2-2026-08-31T21-18-17Z.json b/placement-probe-v2-2026-08-31T21-18-17Z.json new file mode 100644 index 00000000..6520cf99 --- /dev/null +++ b/placement-probe-v2-2026-08-31T21-18-17Z.json @@ -0,0 +1,110 @@ +{ + "schema_version": 2, + "recorded_at": "2026-08-31T21:18:17Z", + "recorded_at_epoch_seconds": 1788211097, + "build": { + "crate_version": "0.1.0", + "commit": "b6f23ec9f3bc", + "dirty": true, + "source": "local" + }, + "machine": { + "cpu_model": "AMD EPYC 7763 64-Core Processor", + "model_suppressed": false, + "os_build": "10.0.26200.9168", + "virtualisation": "detected", + "virtualisation_name": "Microsoft Corporation" + }, + "host": { + "arch": "x86_64", + "processors": 16, + "cores": 8, + "smt": true, + "partitioning_cache_level": 2, + "cache_domain_sizes": [ + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2 + ], + "efficiency_classes": [ + [ + 0, + 16 + ] + ], + "numa_node_sizes": [ + 16 + ], + "provenance": "measured" + }, + "topology_provenance": "measured", + "placements": [ + { + "placement": "SMT siblings (one core)", + "strategy": "baseline", + "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0 [same-cache,same-class]", + "producer_group": 0, + "producer_number": 0, + "producer_numa_node": 0, + "consumer_group": 0, + "consumer_number": 1, + "consumer_numa_node": 0, + "nanos_per_item": 11.5769, + "consumer_batch": 0.9750413783184924, + "producer_batch": 0.972767850411651, + "memory_node": null + }, + { + "placement": "SMT siblings (one core)", + "strategy": "cached", + "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0 [same-cache,same-class]", + "producer_group": 0, + "producer_number": 0, + "producer_numa_node": 0, + "consumer_group": 0, + "consumer_number": 1, + "consumer_numa_node": 0, + "nanos_per_item": 5.9283, + "consumer_batch": 77.72121400536277, + "producer_batch": 4.596316971210968, + "memory_node": null + }, + { + "placement": "cross cache, same class", + "strategy": "baseline", + "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu2/core1/ec0/cd6/n0 [cross-cache,same-class]", + "producer_group": 0, + "producer_number": 0, + "producer_numa_node": 0, + "consumer_group": 0, + "consumer_number": 2, + "consumer_numa_node": 0, + "nanos_per_item": 18.9689, + "consumer_batch": 0.9902176399350814, + "producer_batch": 0.9293572333035165, + "memory_node": null + }, + { + "placement": "cross cache, same class", + "strategy": "cached", + "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu2/core1/ec0/cd6/n0 [cross-cache,same-class]", + "producer_group": 0, + "producer_number": 0, + "producer_numa_node": 0, + "consumer_group": 0, + "consumer_number": 2, + "consumer_numa_node": 0, + "nanos_per_item": 39.41805, + "consumer_batch": 1.8616028586773499, + "producer_batch": 1.0368184603453228, + "memory_node": null + } + ], + "node_hops": [], + "by_class": [] +} \ No newline at end of file From db6225309cbb142b45378a6c3d0cfef84183bd22 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 17:44:16 -0400 Subject: [PATCH 079/361] docs(placement-probe): decide to publish to crates.io, and defer it behind the download path Records the decision PT-5.3 asked for: yes, publish -- but not until the GitHub release path exists, is documented, and has been walked. Timing is the whole of the reasoning. The product here is data whose provenance can be checked, and a crates.io tarball carries no repository, so a `cargo install`ed build reports its commit as unknown by construction and its records are marked unofficial. The objection to publishing was never reach, it was that the weakest submission path is also the most discoverable one. Publishing later answers that without giving up the reach: the strong path is the one a runner meets first, and crates.io becomes the fallback it should be. Recorded in a new DESIGN-NOTES.md for the crate, including the rejected alternative of baking the commit into the packaged source. That is achievable and answers a different question: "this source came from commit X" is not "CI built this binary from commit X", and only the second is checkable by someone other than the submitter. PT-5.3 is a decision item and is now done; the publication it implies is a separate work item rather than prose in a design note, per this repository's rule that design notes do not queue work. PT-5.6 is added under a new gated M5+ section, with the blocker named precisely rather than left as "later": publishing requires the dependencies on crates.io, and `cargo search` confirms `windows-waitable-queues` is not there while `windows-topology-sys` 0.1.0 is. Two things found by following the change outwards rather than stopping at the item: SH-2.2 listed one crate pinning `windows-topology-sys = "0.1.0"`. Sweeping the manifests found three. `windows-placement-probe` is the dangerous one: the `path` entry means it builds perfectly right up to the moment of publication, at which point the stale pin decides what a downstream user compiles against. PLANS.md still carried the superseded gate -- "gated on releasing windows-topology-sys and windows-waitable-queues" -- which commit 4dbdf93 corrected in the checklist and never propagated here. Its status was also still "not started" with six milestones complete, and its design-notes column did not know this crate had notes. All three fixed. Completed item: PT-5.3: Decide whether to publish to crates.io as well, and record the reasoning Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 45 ++++++++- CHECKLIST-ship-topology-and-queues.md | 10 ++ PLANS.md | 2 +- .../windows-placement-probe/DESIGN-NOTES.md | 52 ++++++++++ placement-probe-v2-2026-08-31T21-38-51Z.json | 94 +++++++++++++++++++ 5 files changed, 201 insertions(+), 2 deletions(-) create mode 100644 crates/windows-placement-probe/DESIGN-NOTES.md create mode 100644 placement-probe-v2-2026-08-31T21-38-51Z.json diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 67ff1b57..dcbd678f 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -442,11 +442,21 @@ build" distinction meaningful rather than decorative. back, and what it collects. Assume no context and no obligation. Lead with the download, not with `cargo install`. -- [ ] **PT-5.3** -- Decide whether to publish to crates.io **as well**, and record the reasoning. It +- [x] **PT-5.3** -- Decide whether to publish to crates.io **as well**, and record the reasoning. It costs a semver obligation and yields records whose commit is *unknown* by construction (a crates.io tarball carries no repository), which is a strictly weaker submission. The case for it is reach; the case against is that the weaker path is also the more discoverable one, and submissions will drift towards it. + **Decided: yes, publish -- but not yet.** Timing is what answers the objection. Publishing *after* + the download path exists, is documented, and has been walked end to end means the strong path is the + one a runner meets first, and crates.io becomes the fallback it should be rather than the default. + Reasoning recorded in + [DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), including the rejected + alternative of baking the commit into the packaged source -- which would let a crates.io build name + a commit while still not showing that CI built it, and so would have the record's trust section + claim something it cannot support. + The publication itself is **PT-5.6** below; it is not part of this item, which was only ever a + decision. - [x] **PT-5.4** -- Package metadata and a statement of what is and is not covered by semver. The **record's schema is a compatibility surface** the moment anyone stores one; the internal measurement @@ -461,6 +471,39 @@ build" distinction meaningful rather than decorative. else while looking like it had passed. The ARM64 development machine is the obvious first walker, and it doubles as the check that the unverified `aarch64` artifact from PT-5.1 actually runs. +## M5+: crates.io, once the strong path is established + +Gated on M5, and named `M5+` rather than given a number because the gate is a +deliverable in another checklist rather than a milestone here. Nothing in this section is an open +obligation of M5: M5 is complete when the GitHub release path works, and this section is pulled in +and numbered when that has happened. + +- [ ] **PT-5.6** -- **Publish `windows-placement-probe` to crates.io**, per PT-5.3's decision. + + > **-> CROSS-COMPONENT PREREQUISITE:** blocked on + > [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) -> `M4` -> + > `SH-4.3` (release `windows-waitable-queues` 0.1.0), and on `SH-4.1` if + > `windows-topology-sys` reaches 0.2.0 first. This is a hard blocker, not a preference: a published + > crate cannot depend on a `path`, and `windows-waitable-queues` is not on crates.io today. + + Also blocked on **PT-5.5** -- the whole point of the decision is that the download path is + established *first*, so publishing before someone has walked it would defeat the reasoning that + chose to publish at all. + + Three things this must not skip, each of which is invisible until it is too late: + - **Update the dependency pins to what is actually published.** During local development cargo uses + the `path` entry and never exercises the `version` entry, so a stale pin costs nothing until the + moment of publication, and then decides which version a downstream user compiles against. The + crate currently pins `windows-topology-sys = "0.1.0"`. + - **Say in the README what a crates.io build costs the data** -- that it produces records marked + unofficial with an unknown commit, and that the release download does not. A runner choosing the + convenient path should know what they are giving up, rather than discovering it in their own + output. + - **Run the tool from a `cargo install`ed copy and read the record**, confirming it marks itself + unofficial and names no commit. This is the negative case for the path being added, and PT-5.1's + lesson applies unchanged: a distinction nobody has watched fail is a distinction that does not + work. + ## M6: is a set of "equivalent" processors actually equivalent? **Not gated on the release, unlike the rest of this file.** The work is an extension of the affinity diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 8cc04d07..4b230dbd 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -127,6 +127,16 @@ release-blocking rather than restating the decision itself. releasing `windows-ioring-sys` too. Decide the order and whether ioring's release is part of this push or follows it -- but decide it, because a workspace that builds locally via `path` dependencies will not reveal this and the first symptom is a consumer unable to resolve the two together. + **Three crates pin `"0.1.0"`, not one**, and they carry different obligations. Swept the workspace's + manifests rather than trusting the one that prompted this: + - `windows-ioring-sys` -- published, so the pin obliges a release, as above. + - `windows-placement-probe` -- to be published later + ([CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) -> `PT-5.6`). Its pin obliges no + release now, but must be corrected before that publication or it would ship depending on a + topology version it was never developed against. This is the nastiest of the three: the `path` + entry means it keeps building perfectly all the way to the moment of publish. + - `windows-platform-probes` -- never published, so its pin is inert. Update it with the others + anyway rather than leaving a manifest that misstates what it was built against. - [ ] **SH-2.4** -- Clear the **eight rustdoc warnings** in `windows-waitable-queues` before it is published. They pre-date this branch and were found while doing SH-1.1: an unresolved link to diff --git a/PLANS.md b/PLANS.md index c46c1c1c..c477f540 100644 --- a/PLANS.md +++ b/PLANS.md @@ -20,7 +20,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | not started | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | not started | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | -| [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | not started | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Gated on releasing `windows-topology-sys` and `windows-waitable-queues` first** -- the tool depends on the former and calibrates against the latter. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 publishes. | [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | +| [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | in progress | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Only the crates.io publication is gated on releasing `windows-topology-sys` and `windows-waitable-queues`** -- an earlier version of this row gated the whole tool on them, which was wrong: the canonical artifact is a CI-built binary compiled from this repository through `path` dependencies, and it needs nothing published. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M1B keys a processor by `(group, number)` so a machine with more than 64 of them is not silently miscounted; M1C measures each NUMA hop in both directions and places the ring deliberately; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 distributes the binary; M5+ publishes to crates.io once the download path is established. | [crates/windows-placement-probe/DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | | [crates/windows-ioring-sys/CHECKLIST.md](crates/windows-ioring-sys/CHECKLIST.md) | in progress | Memory-safe Rust over the Windows `IoRing` submission/completion ring, as a new crate. M1-M19 are complete (0.2.0 shipped 2026-08-30, restoring availability after all three 0.1.x versions were yanked); M1-M18 are archived. **M20** queues documentation and policy-test repairs from the 2026-08-30 NUMA-sharding measurement, and the pinned-thread `M6+` work stays parked. | [crates/windows-ioring-sys/DESIGN-NOTES.md](crates/windows-ioring-sys/DESIGN-NOTES.md) | diff --git a/crates/windows-placement-probe/DESIGN-NOTES.md b/crates/windows-placement-probe/DESIGN-NOTES.md new file mode 100644 index 00000000..be36b7dd --- /dev/null +++ b/crates/windows-placement-probe/DESIGN-NOTES.md @@ -0,0 +1,52 @@ +# Design notes: windows-placement-probe + +Decisions about the tool that measures what thread placement costs and produces +a record someone can paste into a discussion thread. + +## crates.io is a second path, and it must not become the first one + +**Decided: publish to crates.io, but not yet.** The reasoning is about which +path a runner meets first, not about whether the reach is worth having. + +The product of this tool is not a binary, it is **data whose provenance can be +checked**. A binary attached to a GitHub release is traceable to the commit that +built it, because CI stamps that commit in and the download itself is the +evidence. A crates.io tarball carries no repository, so a `cargo install`ed +build finds no git metadata and reports its commit as unknown -- by +construction, not by oversight. Records from that path are strictly weaker, and +the tool marks them `!!UNOFFICIAL!!` accordingly. + +That is the whole tension: `cargo install ` is the more discoverable +route, needs no release page, and is what a Rust developer reaches for first. +Publishing early would make the weakest submission path also the easiest one, +and submissions would drift to it before anyone noticed. + +**Timing resolves it.** Publishing *after* the download path exists, is +documented in the README, and has been walked end to end by someone without this +repository means the strong path is the one people meet first, and crates.io +becomes the fallback it should be rather than the default. + +**A hard prerequisite, not merely a preference.** Publishing this crate requires +its dependencies to be on crates.io: it depends on `windows-topology-sys` and +`windows-waitable-queues` by path, and a published crate cannot. As of this +writing `windows-topology-sys` 0.1.0 is published and `windows-waitable-queues` +is not, so the earliest possible publication is after that release. The +dependency pins also need to name whatever version is actually published -- +during local development the `path` entry is used and the `version` entry is +never exercised, so a stale pin is invisible until the moment it matters. + +**Rejected: baking the commit into the packaged source so a crates.io build can +name it.** It is achievable -- generate a file at package time and read it when +git is absent -- and it would answer a different question than the one the +marking exists to ask. "This source came from commit X" is not "this binary was +built by CI from commit X"; only the second makes the artifact independently +checkable, because only the second was produced by something other than the +person submitting the record. Blurring the two would leave the record's trust +section saying something it cannot support. The unknown commit is honest, and +honest is the point. + +**What publication will oblige**, recorded so the cost is not rediscovered +later: the record schema becomes a semver surface the moment anyone stores one +(see the package metadata), and the README must say plainly that a crates.io +build produces records marked unofficial, so nobody chooses that path without +knowing what it costs the data. diff --git a/placement-probe-v2-2026-08-31T21-38-51Z.json b/placement-probe-v2-2026-08-31T21-38-51Z.json new file mode 100644 index 00000000..779cee8e --- /dev/null +++ b/placement-probe-v2-2026-08-31T21-38-51Z.json @@ -0,0 +1,94 @@ +{ + "schema_version": 2, + "recorded_at": "2026-08-31T21:38:51Z", + "recorded_at_epoch_seconds": 1788212331, + "build": { + "crate_version": "0.1.0", + "commit": "b6f23ec9f3bc", + "dirty": true, + "source": "local" + }, + "machine": { + "cpu_model": "AMD EPYC 7763 64-Core Processor", + "model_suppressed": false, + "os_build": "10.0.26200.9168", + "virtualisation": "detected", + "virtualisation_name": "Microsoft Corporation" + }, + "host": { + "arch": "x86_64", + "processors": 16, + "cores": 8, + "smt": true, + "partitioning_cache_level": 2, + "cache_domain_sizes": [2, 2, 2, 2, 2, 2, 2, 2], + "efficiency_classes": [[0, 16]], + "numa_node_sizes": [16], + "provenance": "measured" + }, + "topology_provenance": "measured", + "placements": [ + { + "placement": "SMT siblings (one core)", + "strategy": "baseline", + "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0 [same-cache,same-class]", + "producer_group": 0, + "producer_number": 0, + "producer_numa_node": 0, + "consumer_group": 0, + "consumer_number": 1, + "consumer_numa_node": 0, + "nanos_per_item": 11.00725, + "consumer_batch": 0.990568794507494, + "producer_batch": 0.9851318968468392, + "memory_node": null + }, + { + "placement": "SMT siblings (one core)", + "strategy": "cached", + "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0 [same-cache,same-class]", + "producer_group": 0, + "producer_number": 0, + "producer_numa_node": 0, + "consumer_group": 0, + "consumer_number": 1, + "consumer_numa_node": 0, + "nanos_per_item": 5.93745, + "consumer_batch": 82.3587547356284, + "producer_batch": 4.478771741637574, + "memory_node": null + }, + { + "placement": "cross cache, same class", + "strategy": "baseline", + "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu2/core1/ec0/cd6/n0 [cross-cache,same-class]", + "producer_group": 0, + "producer_number": 0, + "producer_numa_node": 0, + "consumer_group": 0, + "consumer_number": 2, + "consumer_numa_node": 0, + "nanos_per_item": 18.7708, + "consumer_batch": 0.9796780484029534, + "producer_batch": 0.9283650331310273, + "memory_node": null + }, + { + "placement": "cross cache, same class", + "strategy": "cached", + "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu2/core1/ec0/cd6/n0 [cross-cache,same-class]", + "producer_group": 0, + "producer_number": 0, + "producer_numa_node": 0, + "consumer_group": 0, + "consumer_number": 2, + "consumer_numa_node": 0, + "nanos_per_item": 38.6031, + "consumer_batch": 4.940809106899346, + "producer_batch": 0.7019201376325006, + "memory_node": null + } + ], + "node_hops": [], + "by_class": [] +} \ No newline at end of file From febed98e78cd8aec5c2cf2d9f17a19485c751251 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 17:45:34 -0400 Subject: [PATCH 080/361] chore: ignore the placement probe's backup record, and drop one that was committed The tool writes its backup record into the working directory, so running it from inside a checkout leaves a `placement-probe-v*.json` behind. The previous commit swept one into the repository, because `git add -A` does not know the difference between a document and a machine's measurements. Removes that file and ignores the pattern. Verified by running the tool and confirming git reports nothing untracked. The tool's behaviour is unchanged: the file is a deliberate backup of the paste, and writing it to the working directory is right for someone who downloaded a binary and ran it. This only stops it landing in a commit for the people who run it from a clone -- which includes whoever walks PT-5.5. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 6 ++ placement-probe-v2-2026-08-31T21-38-51Z.json | 94 -------------------- 2 files changed, 6 insertions(+), 94 deletions(-) delete mode 100644 placement-probe-v2-2026-08-31T21-38-51Z.json diff --git a/.gitignore b/.gitignore index 16ceee39..4a56b3e7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,11 @@ target # Scratch / diagnostic output (git-ignored per repo instructions) .scratch/ + +# windows-placement-probe writes its backup record into the working directory, +# so running the tool from inside a checkout leaves one of these behind. Ignored +# because `git add -A` will otherwise sweep a machine's measurements into a +# commit, which has already happened once. +placement-probe-v*.json .vs .vscode/settings.json diff --git a/placement-probe-v2-2026-08-31T21-38-51Z.json b/placement-probe-v2-2026-08-31T21-38-51Z.json deleted file mode 100644 index 779cee8e..00000000 --- a/placement-probe-v2-2026-08-31T21-38-51Z.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "schema_version": 2, - "recorded_at": "2026-08-31T21:38:51Z", - "recorded_at_epoch_seconds": 1788212331, - "build": { - "crate_version": "0.1.0", - "commit": "b6f23ec9f3bc", - "dirty": true, - "source": "local" - }, - "machine": { - "cpu_model": "AMD EPYC 7763 64-Core Processor", - "model_suppressed": false, - "os_build": "10.0.26200.9168", - "virtualisation": "detected", - "virtualisation_name": "Microsoft Corporation" - }, - "host": { - "arch": "x86_64", - "processors": 16, - "cores": 8, - "smt": true, - "partitioning_cache_level": 2, - "cache_domain_sizes": [2, 2, 2, 2, 2, 2, 2, 2], - "efficiency_classes": [[0, 16]], - "numa_node_sizes": [16], - "provenance": "measured" - }, - "topology_provenance": "measured", - "placements": [ - { - "placement": "SMT siblings (one core)", - "strategy": "baseline", - "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0 [same-cache,same-class]", - "producer_group": 0, - "producer_number": 0, - "producer_numa_node": 0, - "consumer_group": 0, - "consumer_number": 1, - "consumer_numa_node": 0, - "nanos_per_item": 11.00725, - "consumer_batch": 0.990568794507494, - "producer_batch": 0.9851318968468392, - "memory_node": null - }, - { - "placement": "SMT siblings (one core)", - "strategy": "cached", - "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0 [same-cache,same-class]", - "producer_group": 0, - "producer_number": 0, - "producer_numa_node": 0, - "consumer_group": 0, - "consumer_number": 1, - "consumer_numa_node": 0, - "nanos_per_item": 5.93745, - "consumer_batch": 82.3587547356284, - "producer_batch": 4.478771741637574, - "memory_node": null - }, - { - "placement": "cross cache, same class", - "strategy": "baseline", - "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu2/core1/ec0/cd6/n0 [cross-cache,same-class]", - "producer_group": 0, - "producer_number": 0, - "producer_numa_node": 0, - "consumer_group": 0, - "consumer_number": 2, - "consumer_numa_node": 0, - "nanos_per_item": 18.7708, - "consumer_batch": 0.9796780484029534, - "producer_batch": 0.9283650331310273, - "memory_node": null - }, - { - "placement": "cross cache, same class", - "strategy": "cached", - "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu2/core1/ec0/cd6/n0 [cross-cache,same-class]", - "producer_group": 0, - "producer_number": 0, - "producer_numa_node": 0, - "consumer_group": 0, - "consumer_number": 2, - "consumer_numa_node": 0, - "nanos_per_item": 38.6031, - "consumer_batch": 4.940809106899346, - "producer_batch": 0.7019201376325006, - "memory_node": null - } - ], - "node_hops": [], - "by_class": [] -} \ No newline at end of file From 5bf43ce187c9d2dfec809701e1038824a8f948e4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 17:52:49 -0400 Subject: [PATCH 081/361] chore: drop the second committed record, and verify by the right question The previous commit removed one `placement-probe-v*.json` and ignored the pattern, then verified by checking that git reported nothing untracked. That was the wrong question. A second record -- from a run at 21:18 -- had been committed one commit earlier, and gitignore does not apply to a file that is already tracked, so it could never have shown up as untracked. The check confirmed the shape of the answer instead of the property that mattered. `git ls-files` is the question to ask, and it now returns nothing for the pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- placement-probe-v2-2026-08-31T21-18-17Z.json | 110 ------------------- 1 file changed, 110 deletions(-) delete mode 100644 placement-probe-v2-2026-08-31T21-18-17Z.json diff --git a/placement-probe-v2-2026-08-31T21-18-17Z.json b/placement-probe-v2-2026-08-31T21-18-17Z.json deleted file mode 100644 index 6520cf99..00000000 --- a/placement-probe-v2-2026-08-31T21-18-17Z.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "schema_version": 2, - "recorded_at": "2026-08-31T21:18:17Z", - "recorded_at_epoch_seconds": 1788211097, - "build": { - "crate_version": "0.1.0", - "commit": "b6f23ec9f3bc", - "dirty": true, - "source": "local" - }, - "machine": { - "cpu_model": "AMD EPYC 7763 64-Core Processor", - "model_suppressed": false, - "os_build": "10.0.26200.9168", - "virtualisation": "detected", - "virtualisation_name": "Microsoft Corporation" - }, - "host": { - "arch": "x86_64", - "processors": 16, - "cores": 8, - "smt": true, - "partitioning_cache_level": 2, - "cache_domain_sizes": [ - 2, - 2, - 2, - 2, - 2, - 2, - 2, - 2 - ], - "efficiency_classes": [ - [ - 0, - 16 - ] - ], - "numa_node_sizes": [ - 16 - ], - "provenance": "measured" - }, - "topology_provenance": "measured", - "placements": [ - { - "placement": "SMT siblings (one core)", - "strategy": "baseline", - "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0 [same-cache,same-class]", - "producer_group": 0, - "producer_number": 0, - "producer_numa_node": 0, - "consumer_group": 0, - "consumer_number": 1, - "consumer_numa_node": 0, - "nanos_per_item": 11.5769, - "consumer_batch": 0.9750413783184924, - "producer_batch": 0.972767850411651, - "memory_node": null - }, - { - "placement": "SMT siblings (one core)", - "strategy": "cached", - "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu1/core0/ec0/cd2/n0 [same-cache,same-class]", - "producer_group": 0, - "producer_number": 0, - "producer_numa_node": 0, - "consumer_group": 0, - "consumer_number": 1, - "consumer_numa_node": 0, - "nanos_per_item": 5.9283, - "consumer_batch": 77.72121400536277, - "producer_batch": 4.596316971210968, - "memory_node": null - }, - { - "placement": "cross cache, same class", - "strategy": "baseline", - "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu2/core1/ec0/cd6/n0 [cross-cache,same-class]", - "producer_group": 0, - "producer_number": 0, - "producer_numa_node": 0, - "consumer_group": 0, - "consumer_number": 2, - "consumer_numa_node": 0, - "nanos_per_item": 18.9689, - "consumer_batch": 0.9902176399350814, - "producer_batch": 0.9293572333035165, - "memory_node": null - }, - { - "placement": "cross cache, same class", - "strategy": "cached", - "slice": "pinned prod=g0/cpu0/core0/ec0/cd2/n0 cons=g0/cpu2/core1/ec0/cd6/n0 [cross-cache,same-class]", - "producer_group": 0, - "producer_number": 0, - "producer_numa_node": 0, - "consumer_group": 0, - "consumer_number": 2, - "consumer_numa_node": 0, - "nanos_per_item": 39.41805, - "consumer_batch": 1.8616028586773499, - "producer_batch": 1.0368184603453228, - "memory_node": null - } - ], - "node_hops": [], - "by_class": [] -} \ No newline at end of file From a9659ccc66730dde5eb3f70a2d29c6b79cf9b175 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 18:16:09 -0400 Subject: [PATCH 082/361] fix(placement-probe): place the ring's memory for real, and report where it landed Code review found that the NUMA placement added in 53f2357 never placed anything, and reported success regardless. The mechanism was first touch: allocate the slots, then write to them from a thread pinned to the target node, so Windows backs the pages with physical memory from that node. `Vec::resize_with` writes every element as it builds the vector, on the unpinned thread that called it, so the pages were already faulted in before the pinned thread ran and its writes were second touches. An 8 KiB request is served from an already-committed heap segment besides, so there was no first touch left to take. Reporting made it worse than a no-op. `memory_node` was cleared only when pinning failed, which essentially never happens for a node that exists, so every hop row on a multi-socket machine would have claimed a placement that never occurred. The two rows per hop would have been one configuration measured twice, and the difference between them read as interconnect asymmetry -- noise presented as a finding, on exactly the hardware this tool exists to borrow. The pages now come from `VirtualAllocExNuma`, and the node is read back with `QueryWorkingSetEx` rather than assumed. That readback is not belt and braces: measured here, `VirtualAllocExNuma` accepts an out-of-range node and quietly returns pages from node 0, so a non-null return says only that memory was obtained. Had the honest value been derived from the call succeeding, as designed, it would have lied again. `memory_node` is now whatever the pages report, and `None` when that cannot be established. The test that covered this asserted the field held the requested node, which it did unconditionally -- the exact shape of check that cannot detect a well-formed wrong answer. The replacements ask the operating system where the pages are and compare, and the narrower property that actually holds: the record never names a node that was merely asked for. Fixing it introduced a worse bug, caught by running the tool rather than by the suite. Moving the slots behind a raw pointer removed the only thing anchoring the element type, so `UnsafeCell::new(0)` inferred `i32`, and `cast::>()` accepted the mismatch without complaint because a pointer cast reinterprets rather than checks. A 4 KiB allocation was read and written as 8 KiB: undefined behaviour past slot 512 and heap corruption (0xC0000374) a page later, with `len()` reporting 1024 either way. The slot type is now named once and the byte count, the pointer type and the element type all derive from it, so the disagreement is unrepresentable; sabotage confirms the new test fails at slot 513 without the anchor. Also drops a test written alongside it that compared the slot span in bytes against `CAPACITY * 8`. With the pointer typed, that arithmetic is 8 bytes by construction, so the assertion could not fail -- a tautology that reads like coverage is worse than an absent test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/Cargo.toml | 5 + .../src/peer_index_cache.rs | 316 +++++++++++++----- .../src/peer_index_cache/tests.rs | 129 +++++-- 3 files changed, 335 insertions(+), 115 deletions(-) diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index 483a4f91..376ff490 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -75,4 +75,9 @@ features = [ # about the major version, and CPUID's brand string does not exist on # ARM64. The registry is truthful on both counts and on both architectures. "Win32_System_Registry", + # The ring's slots are placed on a chosen NUMA node with VirtualAllocExNuma, + # and the node they actually landed on is read back with QueryWorkingSetEx. + # Asking is not the same as achieving, and the record reports the second. + "Win32_System_Memory", + "Win32_System_ProcessStatus", ] diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index 9f5fe563..74005700 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -55,10 +55,19 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; use std::time::Instant; +use core::ffi::c_void; use core::ptr; +use windows_sys::Win32::System::Memory::{ + MEM_COMMIT, MEM_RELEASE, MEM_RESERVE, PAGE_READWRITE, VirtualAllocExNuma, VirtualFree, +}; +use windows_sys::Win32::System::ProcessStatus::{ + PSAPI_WORKING_SET_EX_INFORMATION, QueryWorkingSetEx, +}; use windows_sys::Win32::System::SystemInformation::GROUP_AFFINITY; -use windows_sys::Win32::System::Threading::{GetCurrentThread, SetThreadGroupAffinity}; +use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetCurrentThread, SetThreadGroupAffinity, +}; use windows_waitable_queues::spsc; #[cfg(test)] @@ -252,13 +261,11 @@ struct CacheAligned(T); /// A minimal SPSC ring, structurally identical to `spsc`'s. struct Ring { - slots: Box<[UnsafeCell]>, + slots: Slots, mask: usize, capacity: usize, head: CacheAligned, tail: CacheAligned, - /// Which NUMA node the slots were placed on, if the placement succeeded. - memory_node: Option, } // SAFETY: the two positions partition the slots between the threads exactly as @@ -270,89 +277,236 @@ unsafe impl Sync for Ring {} impl Ring { /// Build a ring whose slots live on a chosen NUMA node. /// - /// # Why the node is a parameter rather than left to chance - /// - /// Windows places a page on the node of the thread that **first touches** - /// it. The obvious implementation allocates on whichever thread happens to - /// call this -- here, an unpinned orchestrator -- so the ring lands on a - /// node that may be neither the producer's nor the consumer's, and a re-run - /// can differ purely because that thread migrated. - /// - /// On a multi-socket machine that makes the number meaningless: a hop - /// measured with the data on an unknown third node is not a measurement of - /// that hop. So the caller names the node, and the record carries it beside - /// the two processor nodes. - /// /// `None` allocates without a preference, which is the honest behaviour on - /// a machine with one node and the only option when the allocation fails. + /// a machine with one node and the only option when the placement fails. fn new_on(capacity: usize, node: Option) -> Self { - let mut slots = Vec::with_capacity(capacity); - slots.resize_with(capacity, || UnsafeCell::new(0)); - let mut ring = Self { - slots: slots.into_boxed_slice(), + Self { + slots: Slots::on_node(capacity, node), mask: capacity - 1, capacity, head: CacheAligned(AtomicUsize::new(0)), tail: CacheAligned(AtomicUsize::new(0)), - memory_node: node, - }; - ring.place_on(node); - ring + } + } + + /// The NUMA node the slots were **observed** on, or `None` if unknown. + fn memory_node(&self) -> Option { + self.slots.node } +} - /// Bind the slots to `node` by touching them from a thread pinned there. +/// Bit layout of `PSAPI_WORKING_SET_EX_BLOCK`, which `windows-sys` exposes as +/// an opaque `usize` because the SDK declares it as bitfields. +/// +/// Changing any value here is a breaking change: they describe an operating +/// system structure, not a choice this crate is free to make. +mod working_set { + /// Set when the page is resident, and so when the rest is meaningful. + pub const VALID: usize = 1; + /// Offset of the six-bit `Node` field: past `Valid`, `ShareCount`, + /// `Win32Protection` and `Shared` (1 + 3 + 11 + 1 bits). + pub const NODE_SHIFT: u32 = 16; + /// Width of the `Node` field, as a mask. + pub const NODE_MASK: usize = 0x3F; +} + +/// The ring's slot storage, and the NUMA node its pages turned out to be on. +/// +/// # Why this is not simply a `Box<[UnsafeCell]>` +/// +/// Placing memory on a chosen node means owning the pages. An earlier version +/// tried to avoid that by relying on **first touch** -- Windows backs a page +/// with physical memory from the node of whichever thread first accesses it -- +/// and touching the slots from a thread pinned to the target node. +/// +/// **It did not work, and it reported success anyway.** `Vec::resize_with` +/// writes every element as it builds the vector, on the unpinned thread that +/// called it, so the pages were already faulted in before the pinned thread ran +/// and its writes were second touches. An 8 KiB request is served from an +/// already-committed heap segment besides, so there was no first touch left to +/// take. `memory_node` was then set from "pinning succeeded" rather than from +/// anything about the memory -- precisely the lie the field exists to prevent. +/// Every hop row on a multi-socket machine would have claimed a placement that +/// never happened, and the two rows per hop would have been one configuration +/// measured twice, with the difference between them read as interconnect +/// asymmetry. +/// +/// So the pages come from `VirtualAllocExNuma`, which asks for a node directly, +/// and the node is then **read back** rather than assumed. +struct Slots { + /// Start of the slot array. + ptr: *mut Slot, + /// Number of slots. + len: usize, + /// How the storage was obtained, which decides how it is released. + origin: Origin, + /// The node the pages were observed on, never the one requested. /// - /// **First touch is the mechanism, so a thread on the target node has to do - /// the touching.** `VirtualAllocExNuma` would express the preference more - /// directly, but it allocates whole pages outside Rust's allocator and - /// would mean hand-managing the slot array's lifetime for a property that - /// first-touch already provides. + /// `None` means unknown: no node was asked for, the placement failed, or + /// the query could not answer. It never means "assume it worked". + node: Option, +} + +/// One slot in the ring. +/// +/// **Named once because it is stated three times**: the number of bytes to +/// allocate, the pointer type the slots are read through, and the element type +/// the heap path builds. An earlier version spelled those out independently and +/// they disagreed -- `UnsafeCell::new(0)` with nothing to constrain the literal +/// inferred `i32`, `cast::>()` accepted the mismatch without +/// complaint because a pointer cast reinterprets rather than checks, and a +/// 4 KiB allocation was then read as 8 KiB. That is a heap overrun that +/// compiles, passes a length check, and corrupts memory a page later. Deriving +/// all three from one name makes the disagreement unrepresentable. +type Slot = UnsafeCell; + +/// Where a [`Slots`] allocation came from, and therefore how it is freed. +enum Origin { + /// `VirtualAllocExNuma`, released with `VirtualFree`. + Numa, + /// The ordinary allocator, released by reconstituting the `Box`. + Heap, +} + +impl Slots { + /// Allocate `capacity` slots, on `node` when one is asked for. /// - /// A failure to pin is deliberately *not* fatal here, unlike in - /// [`pin_current_thread`]: the fallback is a ring on the orchestrator's - /// node, and `memory_node` then records `None` rather than claiming a - /// placement that did not happen. - fn place_on(&mut self, node: Option) { - let Some(node) = node else { - return; - }; - let Some(cpu) = first_processor_of_node(node) else { - self.memory_node = None; - return; + /// Falls back to the ordinary allocator when no node is requested or the + /// placement fails, recording `None` for the node in both cases. + fn on_node(capacity: usize, node: Option) -> Self { + node.and_then(|node| Self::on_numa_node(capacity, node)) + .unwrap_or_else(|| Self::on_heap(capacity)) + } + + /// Slots from the ordinary allocator, whose node is not chosen or known. + fn on_heap(capacity: usize) -> Self { + // The annotation is load-bearing, not decoration: it is what fixes the + // literal's type. Without it the element type is decided by inference, + // and the only other mention is a pointer cast, which cannot disagree + // out loud. + let mut slots: Vec = Vec::with_capacity(capacity); + slots.resize_with(capacity, || Slot::new(0)); + let slots: Box<[Slot]> = slots.into_boxed_slice(); + let len = slots.len(); + Self { + ptr: Box::into_raw(slots).cast::(), + len, + origin: Origin::Heap, + node: None, + } + } + + /// Slots on `node`, or `None` if the system would not place them there. + fn on_numa_node(capacity: usize, node: u32) -> Option { + let bytes = capacity.checked_mul(size_of::())?; + // SAFETY: a null base asks the system to choose the address. The + // returned region is owned by this `Slots` and freed in `drop`. + let base = unsafe { + VirtualAllocExNuma( + GetCurrentProcess(), + ptr::null(), + bytes, + MEM_RESERVE | MEM_COMMIT, + PAGE_READWRITE, + node, + ) }; + if base.is_null() { + // No placement was made, so none is recorded. + // + // **Do not read a non-null return as "the request was honoured".** + // The documentation says an out-of-range node fails with + // `ERROR_INVALID_PARAMETER`; measured on a single-node host, asking + // for node `u32::MAX` *succeeded* and returned pages on node 0. So + // success here means only that memory was obtained, and the node it + // came from is settled by the observation below rather than by this + // call. Trusting the return value would have reproduced exactly the + // lie this rewrite exists to remove. + return None; + } - let slots = &mut self.slots; - let placed = std::thread::scope(|scope| { - scope - .spawn(|| { - if !try_pin_current_thread(cpu) { - return false; - } - // Write, not read: a read can be served from a shared - // zero page on some configurations, which would leave the - // pages unplaced while looking touched. - for slot in slots.iter_mut() { - *slot.get_mut() = 0; - } - true - }) - .join() - .unwrap_or(false) - }); + // Fault every page in now. Committed pages are demand-zero, so until + // something writes to them no physical page has been drawn from the + // preferred node -- and the query below would have nothing to report. + // Doing it here also keeps the page faults out of the timed run. + let slots = base.cast::(); + for index in 0..capacity { + // SAFETY: `index < capacity`, and the region was sized as + // `capacity * size_of::()` from the same name. + unsafe { slots.add(index).write(Slot::new(0)) }; + } + + Some(Self { + ptr: slots, + len: capacity, + origin: Origin::Numa, + node: observed_node(base), + }) + } +} - if !placed { - self.memory_node = None; +impl Drop for Slots { + fn drop(&mut self) { + match self.origin { + // SAFETY: `ptr` is the base `VirtualAllocExNuma` returned, and + // `MEM_RELEASE` requires a size of zero. + Origin::Numa => unsafe { + VirtualFree(self.ptr.cast::(), 0, MEM_RELEASE); + }, + // SAFETY: reconstitutes exactly the box `on_heap` leaked. + Origin::Heap => unsafe { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + self.ptr, self.len, + ))); + }, } } } -/// The first logical processor belonging to `node`, for pinning the toucher. -fn first_processor_of_node(node: u32) -> Option<(u16, u8)> { - crate::fingerprint::discover_places() - .ok()? - .into_iter() - .find(|place| place.numa_node == node) - .map(|place| place.id()) +impl core::ops::Deref for Slots { + type Target = [Slot]; + + fn deref(&self) -> &Self::Target { + // SAFETY: `ptr` and `len` describe one live allocation owned by `self`, + // initialised by whichever constructor produced it. + unsafe { core::slice::from_raw_parts(self.ptr, self.len) } + } +} + +/// Which NUMA node the page at `address` is actually on. +/// +/// This is the difference between a record that reports a placement and one +/// that reports a *request*. The page must already be resident, which is why +/// the caller faults it in first. +fn observed_node(address: *mut c_void) -> Option { + let mut info = PSAPI_WORKING_SET_EX_INFORMATION { + VirtualAddress: address, + // SAFETY: an all-zero block is the documented input state; the call + // fills it in. + VirtualAttributes: unsafe { core::mem::zeroed() }, + }; + let size = u32::try_from(size_of::()).ok()?; + + // SAFETY: `info` is one correctly sized entry, and the pseudo-handle from + // `GetCurrentProcess` carries every access this needs. + let queried = unsafe { + QueryWorkingSetEx( + GetCurrentProcess(), + ptr::from_mut(&mut info).cast::(), + size, + ) + }; + if queried == 0 { + return None; + } + + // SAFETY: `Flags` is the union's integer view of the same bits. + let flags = unsafe { info.VirtualAttributes.Flags }; + if flags & working_set::VALID == 0 { + // Not resident, so the node field means nothing. Unknown, not zero. + return None; + } + u32::try_from((flags >> working_set::NODE_SHIFT) & working_set::NODE_MASK).ok() } fn time_model(strategy: Strategy) -> Sample { @@ -393,7 +547,7 @@ pub fn time_model_placed( memory_node: Option, ) -> Sample { let ring = Ring::new_on(CAPACITY, memory_node); - let placed_on = ring.memory_node; + let placed_on = ring.memory_node(); let started = Instant::now(); let (consumer_refreshes, producer_refreshes) = thread::scope(|scope| { let shared = ˚ @@ -428,26 +582,6 @@ pub fn time_model_placed( /// processors that is not a matter of widening the mask; the call has no way to /// express the target at all. `SetThreadGroupAffinity` takes the group /// explicitly, and is the only way to pin across the whole machine. -/// Pin without stopping the run on failure. -/// -/// Separate from [pin_current_thread] because the two failures mean different -/// things. A measurement thread that cannot be pinned invalidates the run and -/// must stop; the page-touching thread only decides *where the memory lands*, -/// and a failure there is recorded as an unknown node rather than a lie. -fn try_pin_current_thread(cpu: (u16, u8)) -> bool { - let (group, number) = cpu; - if u32::from(number) >= usize::BITS { - return false; - } - let affinity = GROUP_AFFINITY { - Mask: 1_usize << number, - Group: group, - Reserved: [0; 3], - }; - // SAFETY: as in pin_current_thread. - unsafe { SetThreadGroupAffinity(GetCurrentThread(), &affinity, ptr::null_mut()) != 0 } -} - fn pin_current_thread(cpu: Option<(u16, u8)>) { let Some((group, number)) = cpu else { return; diff --git a/crates/windows-placement-probe/src/peer_index_cache/tests.rs b/crates/windows-placement-probe/src/peer_index_cache/tests.rs index c5aee701..30e04d75 100644 --- a/crates/windows-placement-probe/src/peer_index_cache/tests.rs +++ b/crates/windows-placement-probe/src/peer_index_cache/tests.rs @@ -8,11 +8,18 @@ //! 3 when it did not is worse than a row admitting it does not know, because //! nothing downstream can tell the difference. //! +//! **These replace tests that passed against a mechanism that never worked.** +//! The first implementation placed memory by first touch and asserted only that +//! the bookkeeping field held the requested node -- which it did, unconditionally, +//! because the pages had already been faulted in by the vector that allocated +//! them. Asserting the field agrees with the request cannot detect that; these +//! ask the operating system where the pages are instead. +//! //! Every case here runs on a single-node host, which is what every machine //! available to this workspace is. Node 0 exists everywhere Windows runs, and a //! node that cannot exist is the other half of the pair. -use super::{CAPACITY, Ring, first_processor_of_node}; +use super::{CAPACITY, Ring, Slots, observed_node}; /// A node id no machine will have. /// @@ -26,7 +33,8 @@ fn a_ring_asked_for_no_placement_records_none() { let ring = Ring::new_on(CAPACITY, None); assert_eq!( - ring.memory_node, None, + ring.memory_node(), + None, "a ring that was never placed claimed a node" ); } @@ -38,51 +46,84 @@ fn a_ring_placed_on_an_existing_node_records_it() { let ring = Ring::new_on(CAPACITY, Some(0)); assert_eq!( - ring.memory_node, + ring.memory_node(), Some(0), "a ring placed on node 0 did not record it" ); } #[test] -fn a_ring_that_could_not_be_placed_records_none_rather_than_the_node_it_wanted() { - // The defect this exists to prevent. The obvious implementation stores the - // requested node and never revisits it, so a failed placement produces a - // row that reads exactly like a successful one. `memory_node` must be what - // the run *achieved*, not what it asked for. +fn a_ring_never_records_a_node_it_could_not_be_placed_on() { + // The defect this exists to prevent, and the one that actually shipped: + // storing the requested node and never revisiting it produces a row that + // reads exactly like a successful placement. + // + // Asserting `None` here would be wrong, and finding that out is why this + // test earns its place. `VirtualAllocExNuma` is documented to reject an + // out-of-range node, and measured on this host it does not -- asking for + // `u32::MAX` returns pages on node 0. The ring genuinely is on node 0, so + // reporting node 0 is the truth and reporting `None` would discard it. The + // property that must hold is narrower and is the one that matters: the + // record never names the node that was merely *asked for*. let ring = Ring::new_on(CAPACITY, Some(ABSENT_NODE)); - assert_eq!( - ring.memory_node, None, + assert_ne!( + ring.memory_node(), + Some(ABSENT_NODE), "a ring recorded a node it could not be placed on" ); } #[test] -fn the_first_processor_of_an_absent_node_is_none() { +fn the_recorded_node_comes_from_the_pages_and_not_from_the_request() { + // The distinction the previous implementation could not make. Ask the + // operating system directly about the same allocation, and require the + // recorded value to be what it says -- so a `Slots` that stored its + // argument would fail here even when the argument happened to be right. + let slots = Slots::on_node(CAPACITY, Some(0)); + + let from_the_pages = observed_node(slots.ptr.cast()); + assert_eq!( - first_processor_of_node(ABSENT_NODE), - None, - "a node that cannot exist offered a processor" + slots.node, from_the_pages, + "the recorded node is not what the pages report" ); } #[test] -fn node_zero_offers_a_processor_to_touch_from() { - // If this ever fails, placement silently degrades to "unknown" on every row - // rather than erroring, so it is worth asserting the happy path exists at - // all rather than inferring it from the ring test above. - assert!( - first_processor_of_node(0).is_some(), - "node 0 offered no processor, so nothing can place memory on it" +fn an_impossible_request_still_yields_a_working_ring() { + // Whichever way the allocation goes -- honoured, quietly redirected, or + // refused into the heap fallback -- the storage must be usable and its + // node must be either unknown or genuinely observed. A ring that quietly + // lost its slots would fail far away from here. + let slots = Slots::on_node(CAPACITY, Some(ABSENT_NODE)); + + assert_eq!(slots.len(), CAPACITY, "the request lost the slots"); + assert_eq!( + slots.node, + observed_node(slots.ptr.cast()), + "the recorded node disagrees with the pages" + ); +} + +#[test] +fn an_unplaced_page_reports_no_node_rather_than_node_zero() { + // `observed_node` reads a bitfield in which an absent answer and node 0 + // are both all-zero bits, distinguished only by the `Valid` flag. Reading + // it wrong would report every unplaced page as node 0 -- a wrong answer + // that looks entirely reasonable. A null address is never resident. + assert_eq!( + observed_node(core::ptr::null_mut()), + None, + "an unqueryable address reported a node" ); } #[test] fn a_placed_ring_is_still_a_usable_ring() { - // Placement writes to every slot from another thread. That must leave the - // ring in its initial state and not, say, a half-full one -- a ring whose - // indices moved would time a shorter run and report it as a faster one. + // Placement writes to every slot. That must leave the ring in its initial + // state and not, say, a half-full one -- a ring whose indices moved would + // time a shorter run and report it as a faster one. let ring = Ring::new_on(CAPACITY, Some(0)); assert_eq!(ring.slots.len(), CAPACITY, "placement resized the ring"); @@ -97,3 +138,43 @@ fn a_placed_ring_is_still_a_usable_ring() { "placement advanced the tail" ); } + +#[test] +fn every_slot_of_every_path_is_readable_and_zero() { + // **Both paths, and that is the whole point of this test.** An earlier + // version checked only the placed ring, and the heap ring shipped a bug it + // could not see: its elements were built as `i32` by type inference while + // the pointer read them as `u64`, so the second half of every heap ring was + // memory belonging to something else. Reading it was undefined behaviour + // and writing it corrupted the heap -- and a check of `len()` alone, which + // is what the suite had, reports 1024 either way. + // + // `None` is the path the unpinned and by-placement measurements take, so it + // is the one that runs most often, not an edge case. + for node in [None, Some(0)] { + let ring = Ring::new_on(CAPACITY, node); + + for (index, slot) in ring.slots.iter().enumerate() { + // SAFETY: no other thread exists in this test, so nothing else + // holds a reference to any slot. + let value = unsafe { *slot.get() }; + assert_eq!(value, 0, "slot {index} of {node:?} was not initialised"); + } + } +} + +#[test] +fn dropping_a_placed_ring_releases_its_pages() { + // The NUMA path frees with `VirtualFree` and the heap path by rebuilding a + // `Box`; getting the pair the wrong way round corrupts the heap or leaks a + // region. Repeated allocation makes a leak of whole pages visible as a + // failure to allocate rather than as slow growth nobody notices. + for _ in 0..256 { + let ring = Ring::new_on(CAPACITY, Some(0)); + assert_eq!(ring.slots.len(), CAPACITY); + drop(ring); + } + for _ in 0..256 { + drop(Ring::new_on(CAPACITY, None)); + } +} From fe965454dbdd2222146cad143069314df5681a38 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 18:32:47 -0400 Subject: [PATCH 083/361] test(placement-probe): make the working-set bit layout falsifiable `observed_node` decodes a bitfield `windows-sys` exposes as an opaque `usize`, and nothing could tell whether it was decoding it correctly. Every page on a single-node host is on node 0, so a wrong `NODE_SHIFT` reads zero exactly like a right one, every test passes, and the error first appears as nonsense node numbers on the multi-socket machine this tool exists to borrow -- the one place where nobody can check the answer against anything. Offsets are now derived from field widths rather than written down, so a check of any field carries to the ones after it, and two pages with known properties pin the widths: - The privately allocated ring: resident, `PAGE_READWRITE`, not shared. - A code page, obtained by taking the address of the running test: resident, `PAGE_EXECUTE_READ`, and **shared**. The second exists because sabotage showed the first was not enough. Widening `PROTECTION_BITS` from 11 to 12 moves `Node` by a bit, and a private read-write page still decodes as `PAGE_READWRITE` because the bit swept in -- `Shared` -- is zero there. Every field above the protection is zero on a private page on a single-node host, and no arithmetic over zeros can detect a shift. A code page has `Shared` set, so the same error swallows that bit and decodes to `0x820`, which is not a protection constant at all. Sabotage of all three widths: `SHARE_COUNT_BITS` and `PROTECTION_BITS` are caught by the tests. `SHARED_BITS` is not, and cannot be -- it moves `Node` alone, which needs a page on a non-zero node to observe. That one gets a compile-time tripwire against the SDK's documented offset, commented as a tripwire rather than as verification, because it can only report that nobody changed the reading by accident. Splits the raw query out of `observed_node` so a test can read the flags directly, and gates the protection mask to test builds, since the crate never needs to decode a protection it chose itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/peer_index_cache.rs | 85 ++++++++++++++++--- .../src/peer_index_cache/tests.rs | 79 ++++++++++++++++- 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index 74005700..1519a15d 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -298,16 +298,67 @@ impl Ring { /// Bit layout of `PSAPI_WORKING_SET_EX_BLOCK`, which `windows-sys` exposes as /// an opaque `usize` because the SDK declares it as bitfields. /// -/// Changing any value here is a breaking change: they describe an operating +/// Changing any value here is a breaking change: these describe an operating /// system structure, not a choice this crate is free to make. +/// +/// # Widths are declared, offsets are derived +/// +/// Every offset is computed from the widths of the fields before it, rather +/// than written down. That is what makes the layout testable at all. `Node` +/// reads zero on every host available to this workspace whether or not its +/// offset is right, so a wrong `NODE_SHIFT` is invisible here and would first +/// show up as nonsense node numbers on a multi-socket machine -- the one +/// machine whose answer this tool exists to collect, and the one place nobody +/// can check the result against anything. +/// +/// `Win32Protection` sits in the same run of bits and holds a value the caller +/// chose, so a test can decode it and know whether it is right. Deriving +/// `NODE_SHIFT` from the same widths makes that check carry: any error in the +/// run of fields moves both, and the test sees it. mod working_set { + /// `Valid`, which is set when the page is resident. + const VALID_BITS: u32 = 1; + /// `ShareCount`. + const SHARE_COUNT_BITS: u32 = 3; + /// `Win32Protection`. + const PROTECTION_BITS: u32 = 11; + /// `Shared`, which sits between the protection and the node. + const SHARED_BITS: u32 = 1; + /// `Node`. + const NODE_BITS: u32 = 6; + /// Set when the page is resident, and so when the rest is meaningful. - pub const VALID: usize = 1; - /// Offset of the six-bit `Node` field: past `Valid`, `ShareCount`, - /// `Win32Protection` and `Shared` (1 + 3 + 11 + 1 bits). - pub const NODE_SHIFT: u32 = 16; + pub const VALID: usize = (1 << VALID_BITS) - 1; + /// Offset of the `Win32Protection` field. + pub const PROTECTION_SHIFT: u32 = VALID_BITS + SHARE_COUNT_BITS; + /// Width of the `Win32Protection` field, as a mask. + /// + /// Only the tests decode a protection -- the crate already knows what it + /// asked for -- so this exists solely to make the layout falsifiable. + #[cfg(test)] + pub const PROTECTION_MASK: usize = (1 << PROTECTION_BITS) - 1; + /// Offset of the `Shared` field, which is the bit immediately below + /// `Node` and so the thing that pins `Node`'s position. + pub const SHARED_SHIFT: u32 = PROTECTION_SHIFT + PROTECTION_BITS; + /// Offset of the `Node` field. + pub const NODE_SHIFT: u32 = SHARED_SHIFT + SHARED_BITS; /// Width of the `Node` field, as a mask. - pub const NODE_MASK: usize = 0x3F; + pub const NODE_MASK: usize = (1 << NODE_BITS) - 1; + + /// Where the SDK says `Node` begins. + /// + /// A tripwire, and deliberately not presented as verification. The tests + /// pin every width below `Node` against values the operating system + /// reports, but `SHARED_BITS` is invisible to them: widening it moves + /// `Node` alone, and detecting that needs a page on a **non-zero node**, + /// which no machine available to this workspace can produce. Sabotage + /// confirms the gap rather than assuming it. + /// + /// So this restates the documented total and fails the build if the derived + /// offset drifts from it. It cannot tell anyone whether the SDK is being + /// read correctly -- only that nobody has changed the reading by accident. + const DOCUMENTED_NODE_SHIFT: u32 = 16; + const _: () = assert!(NODE_SHIFT == DOCUMENTED_NODE_SHIFT); } /// The ring's slot storage, and the NUMA node its pages turned out to be on. @@ -479,6 +530,21 @@ impl core::ops::Deref for Slots { /// that reports a *request*. The page must already be resident, which is why /// the caller faults it in first. fn observed_node(address: *mut c_void) -> Option { + let flags = working_set_flags(address)?; + if flags & working_set::VALID == 0 { + // Not resident, so the node field means nothing. Unknown, not zero. + return None; + } + u32::try_from((flags >> working_set::NODE_SHIFT) & working_set::NODE_MASK).ok() +} + +/// The raw `PSAPI_WORKING_SET_EX_BLOCK` bits for the page at `address`. +/// +/// Separate from [`observed_node`] so a test can check the layout against a +/// field whose value it already knows, rather than against the node field, +/// which reads zero on this workspace's hardware whether or not the offsets are +/// right. +fn working_set_flags(address: *mut c_void) -> Option { let mut info = PSAPI_WORKING_SET_EX_INFORMATION { VirtualAddress: address, // SAFETY: an all-zero block is the documented input state; the call @@ -501,12 +567,7 @@ fn observed_node(address: *mut c_void) -> Option { } // SAFETY: `Flags` is the union's integer view of the same bits. - let flags = unsafe { info.VirtualAttributes.Flags }; - if flags & working_set::VALID == 0 { - // Not resident, so the node field means nothing. Unknown, not zero. - return None; - } - u32::try_from((flags >> working_set::NODE_SHIFT) & working_set::NODE_MASK).ok() + Some(unsafe { info.VirtualAttributes.Flags }) } fn time_model(strategy: Strategy) -> Sample { diff --git a/crates/windows-placement-probe/src/peer_index_cache/tests.rs b/crates/windows-placement-probe/src/peer_index_cache/tests.rs index 30e04d75..c37eaedb 100644 --- a/crates/windows-placement-probe/src/peer_index_cache/tests.rs +++ b/crates/windows-placement-probe/src/peer_index_cache/tests.rs @@ -19,7 +19,11 @@ //! available to this workspace is. Node 0 exists everywhere Windows runs, and a //! node that cannot exist is the other half of the pair. -use super::{CAPACITY, Ring, Slots, observed_node}; +use core::ffi::c_void; + +use windows_sys::Win32::System::Memory::{PAGE_EXECUTE_READ, PAGE_READWRITE}; + +use super::{CAPACITY, Ring, Slots, observed_node, working_set, working_set_flags}; /// A node id no machine will have. /// @@ -178,3 +182,76 @@ fn dropping_a_placed_ring_releases_its_pages() { drop(Ring::new_on(CAPACITY, None)); } } + +#[test] +fn the_working_set_bit_layout_is_read_correctly() { + // **The one assumption on this path that no other test can falsify.** + // Every page on a single-node host is on node 0, so a wrong `NODE_SHIFT` + // still reads zero and every other test here passes. The error would first + // appear as nonsense node numbers on a multi-socket machine -- which is the + // only machine whose answer this tool exists to collect, and the one place + // nobody can check the result against anything. + // + // So this checks the offsets against a neighbouring field whose value the + // caller chose. `Win32Protection` occupies the eleven bits immediately + // before `Node`, and these pages were committed `PAGE_READWRITE`. Decoding + // that correctly pins every offset up to where `Node` begins; getting it + // wrong means the shift is wrong by exactly the amount that matters. + let slots = Slots::on_node(CAPACITY, Some(0)); + let flags = working_set_flags(slots.ptr.cast()).expect("the page must be queryable"); + + assert_ne!( + flags & working_set::VALID, + 0, + "a page that was just written is not marked resident, so the layout is wrong" + ); + assert_eq!( + (flags >> working_set::PROTECTION_SHIFT) & working_set::PROTECTION_MASK, + PAGE_READWRITE as usize, + "Win32Protection did not decode to what the allocation asked for, \ + so the field offsets -- including Node's -- are wrong: flags {flags:#x}" + ); + assert_eq!( + (flags >> working_set::SHARED_SHIFT) & 1, + 0, + "a privately allocated page reported itself shared: flags {flags:#x}" + ); +} + +#[test] +fn the_node_offset_is_pinned_by_a_page_whose_upper_fields_are_not_zero() { + // The gap the private page above cannot close, found by sabotage: widening + // `PROTECTION_BITS` from 11 to 12 moves `Node` by a bit, and a private + // read-write page still decodes as `PAGE_READWRITE` because the bit swept + // in -- `Shared` -- is zero there. Every field above the protection is zero + // on a private page on a single-node host, and no arithmetic on zeros can + // detect a shift. + // + // A code page is the counter-example, and it costs nothing to obtain: it is + // mapped from the image, so it is shared, executable and read-only. Its + // `Shared` bit is set, which means a wrong protection width swallows that + // bit and decodes to a value that is not a protection constant at all. + // Together with the private case this pins `Valid`, `ShareCount`, + // `Win32Protection` and `Shared` -- and therefore where `Node` begins. + let code_page = + the_node_offset_is_pinned_by_a_page_whose_upper_fields_are_not_zero as *mut c_void; + let flags = working_set_flags(code_page).expect("the running code must be queryable"); + + assert_ne!( + flags & working_set::VALID, + 0, + "the page currently executing is not resident: flags {flags:#x}" + ); + assert_eq!( + (flags >> working_set::SHARED_SHIFT) & 1, + 1, + "an image-backed code page did not report itself shared, so the bit \ + below Node is not where it is thought to be: flags {flags:#x}" + ); + assert_eq!( + (flags >> working_set::PROTECTION_SHIFT) & working_set::PROTECTION_MASK, + PAGE_EXECUTE_READ as usize, + "a code page's protection did not decode to PAGE_EXECUTE_READ, so the \ + protection field's width is wrong and Node's offset with it: flags {flags:#x}" + ); +} From 1b2334230c84656e6ad58bca2a3f181caa3b2376 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 18:58:51 -0400 Subject: [PATCH 084/361] fix(docs): repair every broken intra-doc link the branch introduced CI's rustdoc job has failed on all three runs of pull request #56. `main` is green, so the branch broke it -- most likely the `mpsc` -> `slotwise_mpsc` rename, which moved every item these links named. Twelve sites across three crates. The job denies `broken_intra_doc_links`, `private_intra_doc_links` and `invalid_rust_codeblocks`, so eleven of these were errors rather than warnings. `MIN_CAPACITY` did not exist anywhere in the workspace: the public docs for `slotwise_mpsc::bounded` referred readers to a constant that was never written. The rule it was supposed to explain lives in the private `BOUNDS`, so the sentence now states the rule itself -- a capacity of one cannot distinguish "published" from "free" under the sequence protocol. The links into private items are delinked rather than repointed, because a public page cannot link to a page rustdoc does not generate. Where the private name is still useful to a maintainer it stays as inline code. Also corrects SH-2.4, which described these as pre-existing warnings to clear before publication. They were neither pre-existing nor warnings, and nothing was waiting on the release -- the release was waiting on them. Completed item: SH-2.4: Clear the eight rustdoc warnings in windows-waitable-queues before it is published Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 21 +++++++++++++++---- crates/windows-placement-probe/src/machine.rs | 3 ++- .../src/peer_index_cache.rs | 5 +++-- crates/windows-placement-probe/src/record.rs | 2 +- crates/windows-placement-probe/src/report.rs | 3 ++- .../windows-thread-ambient-sys/src/state.rs | 2 +- .../src/reserving_mpsc.rs | 4 ++-- .../src/slotwise_mpsc.rs | 14 +++++++------ crates/windows-waitable-queues/src/spsc.rs | 2 +- 9 files changed, 37 insertions(+), 19 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 4b230dbd..3b6a04f6 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -138,14 +138,27 @@ release-blocking rather than restating the decision itself. - `windows-platform-probes` -- never published, so its pin is inert. Update it with the others anyway rather than leaving a manifest that misstates what it was built against. -- [ ] **SH-2.4** -- Clear the **eight rustdoc warnings** in `windows-waitable-queues` before it is - published. They pre-date this branch and were found while doing SH-1.1: an unresolved link to - `MIN_CAPACITY`, six links from public documentation to private items (`Shared::len`, - `Doorbell::clear`, `Doorbell`, `BOUNDS`), and one redundant explicit link target. +- [x] **SH-2.4** -- Clear the **eight rustdoc warnings** in `windows-waitable-queues` before it is + published: an unresolved link to `MIN_CAPACITY`, six links from public documentation to private + items (`Shared::len`, `Doorbell::clear`, `Doorbell`, `BOUNDS`), and one redundant explicit link + target. Ordinarily out of scope for the item that found them, and in scope here for one reason: **docs.rs is the face of a first release.** A link that silently resolves to nothing in a workspace build renders as a dead or missing reference to the first person who ever reads these docs, and a link to a private item points at a page they cannot open. + **Correction: they did not pre-date this branch, and they were not warnings.** This item said so, on + the reasonable assumption that documentation nobody had touched could not have broken. `main` is + green and the branch is red, so the branch broke them -- most likely the `mpsc` -> `slotwise_mpsc` + rename, which moved every item these links named. And CI denies `broken_intra_doc_links` and + `private_intra_doc_links`, so they were **errors failing every run on the pull request**, not + warnings deferred until publication. Nothing here was blocked on the release; the release was + blocked on this. + **Done.** `MIN_CAPACITY` never existed anywhere -- the prose promised a constant that was never + written -- so that sentence now states the rule itself. The private-item links are delinked rather + than repointed, because a public page cannot link to a page that is not generated. Fixed alongside + five more in `windows-placement-probe` and one in `windows-thread-ambient-sys` that the same job was + failing on; the whole workspace now passes `cargo doc --workspace --all-features` under CI's exact + `RUSTDOCFLAGS`. - [ ] **SH-2.3** -- Dry-run both publishes (`cargo publish --dry-run`) from the merge commit, and read the packaged file list rather than only the exit code. A crate that builds in a workspace can still diff --git a/crates/windows-placement-probe/src/machine.rs b/crates/windows-placement-probe/src/machine.rs index c94db8a5..770bc529 100644 --- a/crates/windows-placement-probe/src/machine.rs +++ b/crates/windows-placement-probe/src/machine.rs @@ -56,7 +56,8 @@ pub enum VirtualisationHint { /// as having ruled out. #[default] NotDetected, - /// A firmware string names a known hypervisor. [`Self::name`] says which. + /// A firmware string names a known hypervisor. + /// [`MachineDescription::virtualisation_name`] says which. Detected, /// The question could not be asked -- the firmware strings were unreadable. Unknown, diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index 1519a15d..9da3cb20 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -33,7 +33,8 @@ //! there to warm the cache line -- that its value cannot be used, and the //! authoritative load still has to happen. That is a different mechanism with a //! different ceiling, so it is measured rather than argued about: -//! [`Strategy::Warmed`] issues a discarded relaxed load of the peer index and +//! [`Strategy::Warmed`](crate::peer_index_cache::Strategy::Warmed) issues a +//! discarded relaxed load of the peer index and //! then does exactly the work the baseline does. //! //! # Why this measures a model rather than the shipping queue @@ -109,7 +110,7 @@ impl Strategy { /// A stable identifier for a record. /// - /// Separate from [`Self::label`] on purpose, and not a duplicate of it. + /// Separate from the private `label` on purpose, and not a duplicate of it. /// The label is prose for a terminal table and may be reworded whenever the /// table reads better a different way; this is a token a stored record is /// keyed on, so rewording it would silently break every collector that ever diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index 77387ae0..c74b38da 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -95,7 +95,7 @@ pub struct SubmissionRecord { /// One measurement, flattened into the shape a record carries. /// /// Flattened rather than nested because the nesting in -/// [`Measurement`](crate::core_affinity::Measurement) serves the code, and a +/// [`Measurement`] serves the code, and a /// record is read by someone who does not have the code. #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index 170b9312..e02db2c5 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -10,7 +10,8 @@ //! its own table, a table that omitted the row its interpretation quoted, and a //! classification that silently merged two placements. //! -//! So the report is a function of the [`SubmissionRecord`], full stop. If the +//! So the report is a function of the +//! [`SubmissionRecord`](crate::record::SubmissionRecord), full stop. If the //! record is wrong the report is wrong in the same way, which is what makes the //! printed text worth reading before deciding whether to send the file. diff --git a/crates/windows-thread-ambient-sys/src/state.rs b/crates/windows-thread-ambient-sys/src/state.rs index 66a09a2d..e604c28d 100644 --- a/crates/windows-thread-ambient-sys/src/state.rs +++ b/crates/windows-thread-ambient-sys/src/state.rs @@ -330,7 +330,7 @@ impl AmbientState { /// /// Panics if the impersonation context cannot be restored. That semantics is /// inherited from - /// [`windows_impersonation_token_sys`](windows_impersonation_token_sys), + /// [`windows_impersonation_token_sys`], /// not chosen here: returning a shared worker to a pool under an unknown /// identity is a process-wide security failure, which is a different order /// of hazard from the other aspects. diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index cd4589e1..4b1742bc 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -59,7 +59,7 @@ //! //! Each read before the other's write, and the queue now owes a slot that does //! not exist. **Sequentially consistent fences do not close this**, unlike the -//! superficially similar hazard in [`Doorbell`](crate::doorbell::Doorbell): the +//! superficially similar hazard in the internal `Doorbell`: the //! Dekker argument needs store-then-load on both sides, and the pushing producer //! is load-then-store -- it *reads* the count and then *writes* the position. In //! a total order over the four operations, both sides missing each other is @@ -74,7 +74,7 @@ //! # What the packing costs, and what it does not //! //! Splitting a 64-bit word 32/32 caps this shape at -//! [`BOUNDS`]`.max` = 2^31 items, and that split is forced rather than chosen: +//! a maximum of 2^31 items, and that split is forced rather than chosen: //! a position of `b` bits keeps a wrapping difference unambiguous only up to //! `2^(b-1)`, and the count needs `b` bits because it can reach the capacity, so //! `b + b = 64` gives `b = 32`. There is no cleverer division of the word. diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index e3e6264a..bdf63418 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -126,9 +126,11 @@ const BOUNDS: Bounds = Bounds { /// /// `capacity` must be a power of two of at least two, and is the exact number /// of items the queue holds -- not a hint, and not rounded. See -/// [`CapacityError`] for why a rejection is preferred to rounding, and -/// [`MIN_CAPACITY`] for why one slot is not enough for this shape when it is -/// enough for [`spsc`](crate::spsc). +/// [`CapacityError`] for why a rejection is preferred to rounding. One slot is +/// not enough for this shape because its sequence protocol distinguishes +/// "published" from "free" by counting, and at `capacity == 1` those two states +/// are the same number; [`spsc`](crate::spsc) represents a one-item handoff +/// exactly. /// /// # Errors /// @@ -510,7 +512,7 @@ impl Producer { /// Items currently held, as a snapshot. /// /// Includes slots claimed by a producer that has not finished writing, so - /// it never under-reports. See [`Shared::len`]. + /// it never under-reports. Implemented by the internal `Shared::len`. #[must_use] pub fn len(&self) -> usize { self.shared.len() @@ -672,7 +674,7 @@ impl Consumer { /// Items currently held, as a snapshot. /// /// Includes slots claimed by a producer that has not finished writing, so - /// it never under-reports. See [`Shared::len`]. + /// it never under-reports. Implemented by the internal `Shared::len`. #[must_use] pub fn len(&self) -> usize { self.shared.len() @@ -770,7 +772,7 @@ impl Consumer { /// /// Clearing first splits every push into two cases, and this shape's /// division is **not** the one `spsc` uses -- the difference is why - /// [`Doorbell::clear`](crate::doorbell::Doorbell::clear) had to be + /// the internal `Doorbell::clear` had to be /// corrected before this shape was sound: /// /// - **A push that publishes at the head before the clear** is found by the diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 8dcb2e3b..791f15e6 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -755,7 +755,7 @@ impl Consumer { /// checking emptiness *second* is what makes a lost wakeup impossible: an /// item that arrives before the clear is found by the check, and an item /// that arrives after the clear signals a doorbell that - /// [`clear`](crate::doorbell::Doorbell::clear) has left able to ring. + /// the internal `clear` has left able to ring. /// Checking first would leave a window in which a push both signals and has /// its signal erased, and the consumer would sleep on a queue that is not /// empty and will never be signalled again. From 81cff8926a74952ecb81800d2d607fbcd4ca0a14 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 19:12:00 -0400 Subject: [PATCH 085/361] fix(queues): a recv_timeout over 49 days waited forever Three findings from the Copilot review on pull request #56. **`recv_timeout` could become unbounded.** The blocking loop clamped an oversized remaining duration to `u32::MAX`, which is the same value as `INFINITE` -- imported three lines above the clamp in the same file. A timeout longer than about 49.7 days therefore waited forever instead of timing out, and the loop that was supposed to re-check the deadline never regained control to do it. The existing comment reasoned carefully about clamping versus truncating and was right about everything except the one value it chose. The reviewer's suggested fix, clamping to `u32::MAX - 1`, is incomplete, and a boundary test written for it is what showed that: a duration of exactly `u32::MAX` milliseconds *converts* successfully, so the fallback never fires and `INFINITE` is returned by the conversion rather than by the clamp. Both guards are needed, and they cover different inputs. The clamp is now derived from `INFINITE` rather than written as a number, and the arithmetic is extracted so it can be tested -- the failing case takes 49 days to observe through the public API and so could never be a test of the loop. **A hook survived a panic.** `Hook::with` removed the installed hook with a statement after the body, which an unwind skips. A test that installs a hook and then fails an assertion -- the ordinary way for a test to fail -- left it installed to fire inside whatever ran next on that thread, in the facility this crate's central correctness argument rests on. Now removed by a guard, using `try_with` so teardown cannot replace an unwind already in progress. **Eighteen broken documentation links, from a report of two.** `../../` from `src/*.rs` resolves to `crates/`, which holds no `DESIGN-NOTES.md`; the crate's own notes are one level up. Sweeping the workspace found sixteen more of the same, in `spsc`, `traits`, `reserving_mpsc` and `slotwise_mpsc`. The sweep also caught its own repair damaging a correct link: `windows-file-watcher/src/contract.rs` used `../../../DESIGN-NOTES.md` to reach the *workspace* notes deliberately, and a blanket prefix replacement shortened it. Reverted, and every relative markdown link in every Rust source is now checked to resolve -- 36 of them, none broken. The review's fourth point, that `race_hooks` puts a thread-local lookup on production hot paths, does not hold: `mod race_hooks` and all four call sites are already `#[cfg(test)]`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-waitable-queues/src/blocking.rs | 48 ++++++++-- .../src/blocking/tests.rs | 73 +++++++++++++++ crates/windows-waitable-queues/src/metrics.rs | 2 +- .../windows-waitable-queues/src/race_hooks.rs | 26 +++++- .../src/race_hooks/tests.rs | 89 +++++++++++++++++++ .../src/reserving_mpsc.rs | 8 +- .../src/slotwise_mpsc.rs | 2 +- crates/windows-waitable-queues/src/spsc.rs | 6 +- crates/windows-waitable-queues/src/traits.rs | 14 +-- 9 files changed, 241 insertions(+), 27 deletions(-) create mode 100644 crates/windows-waitable-queues/src/blocking/tests.rs create mode 100644 crates/windows-waitable-queues/src/race_hooks/tests.rs diff --git a/crates/windows-waitable-queues/src/blocking.rs b/crates/windows-waitable-queues/src/blocking.rs index 20a28a23..8392558a 100644 --- a/crates/windows-waitable-queues/src/blocking.rs +++ b/crates/windows-waitable-queues/src/blocking.rs @@ -5,7 +5,7 @@ //! # Why this is not simply copied into each shape //! //! The loop below is not glue -- it *is* the arming protocol, the contract -//! recorded as [D-9](../../DESIGN-NOTES.md#d-9): drain, arm, and wait only if +//! recorded as [D-9](../DESIGN-NOTES.md#d-9): drain, arm, and wait only if //! arming blessed it, with the disconnection check placed between the arming //! and the wait so a producer that vanished cannot leave a consumer parked. //! Every step is load-bearing and the order is the whole correctness argument. @@ -37,6 +37,9 @@ use windows_sys::Win32::System::Threading::{INFINITE, WaitForSingleObject}; use crate::error::{RecvError, RecvTimeoutError}; +#[cfg(test)] +mod tests; + /// What a shape must offer for [`recv`] and [`recv_timeout`] to park on it. pub(crate) trait Parked { /// The item type the shape carries. @@ -132,16 +135,45 @@ pub(crate) fn recv_timeout( if remaining.is_zero() { return Err(RecvTimeoutError::Timeout); } - // Saturating rather than wrapping: a duration longer than a `u32` of - // milliseconds is roughly 49 days, and clamping it to that is a longer - // wait than any caller meant, where truncating it would be a far - // shorter one. The loop re-arms and waits again, so clamping costs an - // extra turn and nothing else. - let millis = u32::try_from(remaining.as_millis()).unwrap_or(u32::MAX); - wait(consumer.doorbell()?, millis)?; + wait(consumer.doorbell()?, wait_millis(remaining))?; } } +/// The longest finite wait `WaitForSingleObject` accepts, in milliseconds. +/// +/// **Derived from `INFINITE`, not written as a number, because it is exactly +/// one less than it.** `INFINITE` is `u32::MAX`, so a clamp to `u32::MAX` does +/// not mean "wait a very long time" -- it means *wait forever*, and the loop +/// that was supposed to re-check the deadline never regains control to do so. +const MAX_FINITE_WAIT_MILLIS: u32 = INFINITE - 1; + +/// How long to block for, given the time left on the caller's deadline. +/// +/// Saturating rather than wrapping: a duration longer than a `u32` of +/// milliseconds is roughly 49 days, and clamping it to that is a longer wait +/// than any caller meant, where truncating it would be a far shorter one. The +/// loop re-arms and waits again, so clamping costs an extra turn and nothing +/// else. +/// +/// **The clamp is to one below `INFINITE`.** An earlier version clamped to +/// `u32::MAX`, which is the same bit pattern as `INFINITE`: a `recv_timeout` +/// longer than about 49.7 days waited forever instead of timing out, silently +/// converting a bounded call into an unbounded one. The comment above was +/// already there and was right about everything except the one value it chose. +/// +/// **The `min` is not redundant with the `unwrap_or`**, and a boundary test is +/// what showed it. Changing only the fallback leaves the hole open from the +/// other side: a duration of exactly `u32::MAX` milliseconds *converts* +/// successfully, so the fallback never fires and `INFINITE` is returned by the +/// conversion itself. The two guards cover different inputs -- one the +/// durations too large to represent, the other the one that is representable +/// and still means forever. +fn wait_millis(remaining: Duration) -> u32 { + u32::try_from(remaining.as_millis()) + .unwrap_or(MAX_FINITE_WAIT_MILLIS) + .min(MAX_FINITE_WAIT_MILLIS) +} + /// Block on a doorbell handle, translating the Win32 result. fn wait(handle: BorrowedHandle<'_>, millis: u32) -> io::Result<()> { // SAFETY: a live event handle borrowed for the duration of the call. diff --git a/crates/windows-waitable-queues/src/blocking/tests.rs b/crates/windows-waitable-queues/src/blocking/tests.rs new file mode 100644 index 00000000..7e339d22 --- /dev/null +++ b/crates/windows-waitable-queues/src/blocking/tests.rs @@ -0,0 +1,73 @@ +// Copyright (c) Mike Grier. + +//! Tests for the blocking loop's timeout arithmetic. +//! +//! The loop itself is exercised through every shape's `recv_timeout`; what is +//! tested here is the one value that decides whether a bounded call stays +//! bounded, because the failing case takes 49 days to observe from the outside +//! and so can never be a test of the loop. + +use std::time::Duration; + +use windows_sys::Win32::System::Threading::INFINITE; + +use super::{MAX_FINITE_WAIT_MILLIS, wait_millis}; + +#[test] +fn an_ordinary_duration_is_passed_through_in_milliseconds() { + assert_eq!(wait_millis(Duration::from_millis(0)), 0); + assert_eq!(wait_millis(Duration::from_millis(1)), 1); + assert_eq!(wait_millis(Duration::from_millis(250)), 250); + assert_eq!(wait_millis(Duration::from_secs(1)), 1_000); + assert_eq!(wait_millis(Duration::from_secs(60)), 60_000); +} + +#[test] +fn a_duration_that_does_not_fit_is_clamped_rather_than_truncated() { + // Truncating would be the opposite failure: a caller who asked to wait a + // long time would be told "timed out" almost immediately. + let fifty_days = Duration::from_secs(50 * 24 * 60 * 60); + + assert_eq!(wait_millis(fifty_days), MAX_FINITE_WAIT_MILLIS); +} + +#[test] +fn the_clamp_is_never_the_value_that_means_wait_forever() { + // **The bug this file exists for.** `INFINITE` is `u32::MAX`, so clamping + // an oversized duration to `u32::MAX` does not mean "wait a very long + // time"; it means wait forever, and the loop that was supposed to re-check + // the deadline never runs again. A bounded call silently becomes unbounded. + // + // Every duration too large to fit lands on the clamp, so it is the clamp + // that has to be checked, not any particular duration. + assert_ne!( + MAX_FINITE_WAIT_MILLIS, INFINITE, + "the clamp is the value that means wait forever" + ); + + for excessive in [ + Duration::from_millis(u64::from(u32::MAX) + 1), + Duration::from_secs(50 * 24 * 60 * 60), + Duration::from_secs(u64::from(u32::MAX)), + Duration::MAX, + ] { + assert_ne!( + wait_millis(excessive), + INFINITE, + "a {excessive:?} timeout would have waited forever" + ); + } +} + +#[test] +fn the_largest_duration_that_still_fits_is_not_clamped() { + // The boundary, from the side that must not move. + let exact = Duration::from_millis(u64::from(MAX_FINITE_WAIT_MILLIS)); + + assert_eq!(wait_millis(exact), MAX_FINITE_WAIT_MILLIS); + assert_eq!( + wait_millis(exact + Duration::from_millis(1)), + MAX_FINITE_WAIT_MILLIS, + "one millisecond past the boundary must clamp, not wrap to zero" + ); +} diff --git a/crates/windows-waitable-queues/src/metrics.rs b/crates/windows-waitable-queues/src/metrics.rs index e0793c1f..23c7b72f 100644 --- a/crates/windows-waitable-queues/src/metrics.rs +++ b/crates/windows-waitable-queues/src/metrics.rs @@ -33,7 +33,7 @@ //! - **Peak depth** cannot be placed that way, because it must observe every //! change. It is therefore **opt-in**, and off by default; see //! [`Metrics::record_depth`] and -//! [D-23](../../DESIGN-NOTES.md#d-23). +//! [D-23](../DESIGN-NOTES.md#d-23). use core::fmt; use core::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; diff --git a/crates/windows-waitable-queues/src/race_hooks.rs b/crates/windows-waitable-queues/src/race_hooks.rs index c9b7a1a6..f783da83 100644 --- a/crates/windows-waitable-queues/src/race_hooks.rs +++ b/crates/windows-waitable-queues/src/race_hooks.rs @@ -36,6 +36,8 @@ use core::cell::RefCell; use std::thread::LocalKey; +mod tests; + type Slot = RefCell>>; thread_local! { @@ -70,11 +72,29 @@ impl Hook { } /// Installs a hook for the duration of a closure. + /// + /// **The removal is a guard rather than a statement after `body`**, so an + /// unwind takes the hook with it. A test that installs a hook and then + /// fails an assertion is the ordinary case, not an exotic one, and a hook + /// surviving that would fire inside whatever ran next on this thread -- + /// turning one failure into an unrelated second one, in a facility the + /// crate's central correctness argument rests on. pub(crate) fn with(&self, race: impl FnMut() + 'static, body: impl FnOnce() -> R) -> R { self.0 .with(|hook| *hook.borrow_mut() = Some(Box::new(race))); - let result = body(); - self.0.with(|hook| *hook.borrow_mut() = None); - result + let _installed = Installed(self.0); + body() + } +} + +/// Removes a hook when it goes out of scope, however that happens. +struct Installed(&'static LocalKey); + +impl Drop for Installed { + fn drop(&mut self) { + // `try_with`, not `with`: a hook can outlive its thread-local during + // thread teardown, and panicking there would replace whatever unwind is + // already in progress. + let _ = self.0.try_with(|hook| *hook.borrow_mut() = None); } } diff --git a/crates/windows-waitable-queues/src/race_hooks/tests.rs b/crates/windows-waitable-queues/src/race_hooks/tests.rs new file mode 100644 index 00000000..513c194e --- /dev/null +++ b/crates/windows-waitable-queues/src/race_hooks/tests.rs @@ -0,0 +1,89 @@ +// Copyright (c) Mike Grier. + +//! Tests for the hook facility itself. +//! +//! These test the *test infrastructure*, which is worth doing precisely because +//! everything else about the arming protocol is proved through it. A hook that +//! misbehaves does not fail loudly; it makes some other test fail for a reason +//! that has nothing to do with what that test is about. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::ARM; + +#[test] +fn a_hook_fires_while_installed_and_not_afterwards() { + // The static lives inside the test rather than at module scope: this + // workspace runs tests as threads in one process, so a module-scope counter + // could be moved by another test. + static FIRED: AtomicUsize = AtomicUsize::new(0); + + ARM.with( + || { + FIRED.fetch_add(1, Ordering::Relaxed); + }, + || { + ARM.run(); + ARM.run(); + }, + ); + assert_eq!(FIRED.load(Ordering::Relaxed), 2, "the hook did not fire"); + + ARM.run(); + assert_eq!( + FIRED.load(Ordering::Relaxed), + 2, + "the hook fired after it was removed" + ); +} + +#[test] +fn a_panic_inside_the_body_still_removes_the_hook() { + // The defect this guards. Removing the hook with a statement after `body` + // means an unwind skips it, and a test that installs a hook and then fails + // an assertion -- the ordinary way for a test to fail -- would leave it + // installed to fire inside whatever ran next on this thread. + static FIRED: AtomicUsize = AtomicUsize::new(0); + + let panicked = catch_unwind(AssertUnwindSafe(|| { + ARM.with( + || { + FIRED.fetch_add(1, Ordering::Relaxed); + }, + || panic!("the body fails, as a failing test does"), + ); + })); + assert!(panicked.is_err(), "the panic must reach the caller"); + + ARM.run(); + assert_eq!( + FIRED.load(Ordering::Relaxed), + 0, + "a hook survived an unwind and fired later" + ); +} + +#[test] +fn a_hook_that_re_enters_its_own_window_does_not_trip_the_refcell() { + // `run` takes the hook out for the call rather than holding the borrow + // across it. Without that, a hook whose body reaches the same window again + // would panic on a `RefCell` double borrow -- and the panic would look like + // a fault in the queue rather than in the harness. + static DEPTH: AtomicUsize = AtomicUsize::new(0); + + ARM.with( + || { + if DEPTH.fetch_add(1, Ordering::Relaxed) == 0 { + ARM.run(); + } + }, + || ARM.run(), + ); + + assert_eq!( + DEPTH.load(Ordering::Relaxed), + 1, + "re-entering the window should find the hook taken out, not re-run it" + ); +} diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 4b1742bc..3e61c41f 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -34,10 +34,10 @@ //! requires exactly that count -- which requires the consumer's position, on one //! line every thread in the system touches. //! -//! So the two ship as peers ([D-16](../../DESIGN-NOTES.md#d-16)): `slotwise_mpsc` for a +//! So the two ship as peers ([D-16](../DESIGN-NOTES.md#d-16)): `slotwise_mpsc` for a //! caller who wants the cheapest possible push and can treat a refusal as //! backpressure, this shape for a caller with a message it must not lose. That -//! is the narrow-trait argument from [D-2](../../DESIGN-NOTES.md#d-2) reaching +//! is the narrow-trait argument from [D-2](../DESIGN-NOTES.md#d-2) reaching //! its sharpest case -- `slotwise_mpsc` does not implement //! [`Reserving`](crate::Reserving) because it genuinely cannot, not because //! nobody got round to it. @@ -80,7 +80,7 @@ //! `b + b = 64` gives `b = 32`. There is no cleverer division of the word. //! //! **A 128-bit compare-and-swap would lift that cap and is deliberately not -//! used** ([D-18](../../DESIGN-NOTES.md#d-18)). It would not remove the cost that +//! used** ([D-18](../DESIGN-NOTES.md#d-18)). It would not remove the cost that //! matters -- the consumer's position still has to be read -- and 2^31 slots is //! a ring this shape allocates in full at construction. @@ -975,7 +975,7 @@ impl Consumer { /// missed. `false` means something arrived in the meantime. /// /// Clearing must come before the check, which is the reverse of the order - /// that reads naturally; see [D-9](../../DESIGN-NOTES.md#d-9). + /// that reads naturally; see [D-9](../DESIGN-NOTES.md#d-9). /// /// # Errors /// diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index bdf63418..3a454cad 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -52,7 +52,7 @@ //! spelling came first. This is that second shape, and it matches: `push` and //! `pop` take `&self`, the handles are split, and the error type is the shared //! one. The traits themselves therefore ship with this module -- see -//! [`crate::traits`] and [D-3](../../DESIGN-NOTES.md#d-3). +//! [`crate::traits`] and [D-3](../DESIGN-NOTES.md#d-3). //! //! Exactly one cell of `spsc`'s auto-trait table changes, which is what "the //! multi-producer shape relaxes exactly one cell" was written to predict: diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 791f15e6..543e81d2 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -11,7 +11,7 @@ //! //! This is the first shape written, so its method signatures become the ones a //! capability trait must be able to name. Written down before the type, per -//! [D-3](../../DESIGN-NOTES.md#d-3), because a second shape that spells the +//! [D-3](../DESIGN-NOTES.md#d-3), because a second shape that spells the //! same operation differently cannot later be unified without breaking one of //! them: //! @@ -37,7 +37,7 @@ //! //! **They have since shipped, and they kept those signatures.** //! [`slotwise_mpsc`](crate::slotwise_mpsc) was written against this sketch and matched it, which -//! is the validation [D-3](../../DESIGN-NOTES.md#d-3) demanded before any trait +//! is the validation [D-3](../DESIGN-NOTES.md#d-3) demanded before any trait //! was allowed to exist. The sketch is left here because it is the artefact //! that made the check possible: what [`crate::traits`] says now is what this //! comment said before either type existed. @@ -51,7 +51,7 @@ //! one that serves the widest is chosen. //! //! Cardinality is then carried by the auto traits instead, which is -//! [D-4](../../DESIGN-NOTES.md#d-4): +//! [D-4](../DESIGN-NOTES.md#d-4): //! //! | | [`Clone`] | [`Send`] | [`Sync`] | //! |---|---|---|---| diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs index b46b3429..3f25f48d 100644 --- a/crates/windows-waitable-queues/src/traits.rs +++ b/crates/windows-waitable-queues/src/traits.rs @@ -11,7 +11,7 @@ //! merely preferred, because a fat trait is *unimplementable* by shapes this //! crate plans to ship: a queue that is never waited on has no doorbell to //! return, and an unbounded one has no capacity to report. Recorded as -//! [D-2](../../DESIGN-NOTES.md#d-2). +//! [D-2](../DESIGN-NOTES.md#d-2). //! //! What that buys is a consumer generic over exactly what it needs. A drainer //! that parks on a queue asks for [`Consumer`] and [`Waitable`], and stays @@ -26,7 +26,7 @@ //! fixed in prose when `spsc` was written -- the signatures were spelled out in //! its module documentation before the type existed -- and the traits //! themselves waited for `slotwise_mpsc` to exist to be checked against. That is -//! [D-3](../../DESIGN-NOTES.md#d-3), and the check it demands is not rhetorical: +//! [D-3](../DESIGN-NOTES.md#d-3), and the check it demands is not rhetorical: //! `slotwise_mpsc` is a lock-free multi-producer array queue with no structural //! resemblance to `spsc` beyond its interface, so a signature that fitted only //! the first shape would have failed here rather than in a consumer's code. @@ -174,7 +174,7 @@ pub trait Bounded { /// /// [`slotwise_mpsc`](crate::slotwise_mpsc) deliberately does **not** implement this, and that is /// the clearest illustration of why the capability traits are narrow -/// ([D-2](../../DESIGN-NOTES.md#d-2)). Honouring a reservation means knowing how +/// ([D-2](../DESIGN-NOTES.md#d-2)). Honouring a reservation means knowing how /// many slots remain, which costs a producer a read of the consumer's position /// on every push -- a single line every thread touches. `slotwise_mpsc`'s push avoids /// that read by design, so it cannot answer the question, and @@ -191,7 +191,7 @@ pub trait Reserving { /// /// **Generic over a lifetime because the two shapes genuinely differ**, and /// that difference is the trait being validated by two implementations - /// rather than shaped around one ([D-3](../../DESIGN-NOTES.md#d-3)). + /// rather than shaped around one ([D-3](../DESIGN-NOTES.md#d-3)). /// [`reserving_mpsc`](crate::reserving_mpsc) hands out an owned, [`Send`] /// reservation, because its use case is to claim a slot when an operation is /// submitted and redeem it from whichever thread the completion arrives on. @@ -230,7 +230,7 @@ pub trait Reserving { /// /// # Why depth is not here /// -/// [D-2](../../DESIGN-NOTES.md#d-2)'s sketch of this trait listed "depth, +/// [D-2](../DESIGN-NOTES.md#d-2)'s sketch of this trait listed "depth, /// high-water, doorbells actually rung", and depth has been left off /// deliberately. [`Bounded::len`] already reports it, computed on demand from /// positions the queue keeps anyway. Naming it again here would give one number @@ -251,7 +251,7 @@ pub trait Observable { /// coalesced loss latch that generalises: a latch can only coalesce losses /// that are *idempotent*, which is a property of the payload rather than of /// the queue, but counting them needs nothing of the payload at all. See - /// [D-19](../../DESIGN-NOTES.md#d-19). + /// [D-19](../DESIGN-NOTES.md#d-19). /// /// Counts refusals for **room** only. A push refused because every consumer /// is gone is the end of the stream rather than a loss, and folding the two @@ -326,7 +326,7 @@ pub trait Waitable { /// /// **Waiting without arming is a permanent hang, not an occasional missed /// wakeup.** The full argument is in - /// [D-9](../../DESIGN-NOTES.md#d-9); the short form is that clearing must come + /// [D-9](../DESIGN-NOTES.md#d-9); the short form is that clearing must come /// before the emptiness check, which is the reverse of the order that reads /// naturally. /// From 0b96b14746bb13918cd4f9a9ca04fe8ed1191780 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 19:24:02 -0400 Subject: [PATCH 086/361] fix(queues): read the depth before publishing, not after CI caught a real concurrency defect in `slotwise_mpsc`, unrelated to the review it was running against: `high_water_survives_contention_from_many_producers` failed with a peak of 18446744073709551545. That is not a large number, it is a small negative one -- `-71` in wrapped unsigned arithmetic. The producer computed its depth as `position - head + 1` *after* the release store that publishes the slot. Publication is exactly what lets the consumer take the item, so between those two statements the consumer is free to drain past `position`, `head` overtakes it, and the subtraction wraps. `fetch_max` then keeps that value forever, so a single race poisons the metric for the lifetime of the queue. Locally the wrapped value was `usize::MAX`, i.e. `-1`; CI's was `-71`. Both are what the arithmetic produces, which is how the diagnosis was confirmed rather than guessed. The fix is to move the read above the publication. It is not a clamp: before the store, the consumer *cannot* have consumed `position`, because the slot's sequence does not yet say so, therefore `head <= position` and the subtraction cannot go negative. A stale `head` remains possible in the harmless direction -- it can only be older, which over-reports by the number of items drained since, and that is still bounded by the capacity. `reserving_mpsc` already did it this way, and its comment says why: the producer has read `head` already to decide there was room. The two shapes now agree. On the evidence, stated honestly. The structural argument above is the proof. The timing measurement is weak and is only corroboration: 60 full-suite runs failed once with the old order and zero times with the new one, and a further 60 runs of the old order with the new assertion in place also failed zero times, so no claim is made that the assertion raises the detection rate. A once-in-sixty witness cannot establish much either way. The `debug_assert` is kept regardless, because it states the invariant where it can fail instead of only in prose: it names the offending depth at the push that raced, in every test that tracks high water, rather than surfacing as one inexplicable number at the end of a single test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/slotwise_mpsc.rs | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index 3a454cad..e579501c 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -461,13 +461,6 @@ impl Producer { (*slot.value.get()).write(item); } - // Release, and this is the publication: it must come after the write, - // and this is what forbids the compiler and the processor from moving - // it earlier. Until it lands, the consumer sees the slot as - // claimed-but-empty and skips it. - slot.sequence - .store(position.wrapping_add(1), Ordering::Release); - // **Guarded, and this branch is the whole reason high-water is a // switch.** This shape's producer never reads `head`, which is what // keeps its push off the one line every thread touches. Depth cannot be @@ -482,13 +475,47 @@ impl Producer { // // Off, the cost is one predictable branch on a field written once at // construction, so the line is shared but read-only -- the cheap kind. + // + // **Before the publication below, and that placement is load-bearing.** + // The subtraction is only non-negative while the consumer cannot have + // passed `position`, and what holds it back is precisely that + // `position` is not published yet. Taken afterwards, the consumer is + // free to drain past it between the two statements, `position - head` + // goes negative, and the wrapping turns it into a vast unsigned number + // that `fetch_max` then keeps forever -- a peak the queue never reached + // and could not reach. Measured before this moved: about one run in + // thirty reported a high-water mark of `usize::MAX`. + // + // A stale `head` is harmless in the other direction: it can only be + // older, which over-reports the depth by the number of items drained + // since, and that is still bounded by the capacity. if self.shared.metrics.tracks_high_water() { let head = self.shared.head.0.load(Ordering::Acquire); - self.shared - .metrics - .record_depth(position.wrapping_sub(head).wrapping_add(1)); + let depth = position.wrapping_sub(head).wrapping_add(1); + // States the invariant that the placement above buys, and states it + // where it can fail rather than only in prose. A depth cannot + // exceed the capacity, so anything larger is the wrapped + // subtraction -- and without this the only witness is a + // `high_water` assertion at the end of one test, which caught the + // real defect about once in sixty runs. Here it fires in whichever + // push raced, in every test that tracks high water, with the + // offending value in hand. + debug_assert!( + depth <= self.shared.capacity, + "depth {depth} exceeds capacity {}: the head was read after the \ + publication and the consumer drained past this position", + self.shared.capacity + ); + self.shared.metrics.record_depth(depth); } + // Release, and this is the publication: it must come after the write, + // and this is what forbids the compiler and the processor from moving + // it earlier. Until it lands, the consumer sees the slot as + // claimed-but-empty and skips it. + slot.sequence + .store(position.wrapping_add(1), Ordering::Release); + // After the publication, never before: the doorbell says "there is // something to take", and that must not become true before the item is // actually takeable. A consumer woken early would find nothing, clear From dd2e9c573230041054ee3e5266f8853a75c50ae1 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 19:39:38 -0400 Subject: [PATCH 087/361] fix(placement-probe): the build stamp was six commits stale Five findings from the Copilot reviews on pull request #56. These arrived only in review *bodies* rather than as inline threads, so the thread query used last time could not see them. **The build stamp did not update on commit.** `build.rs` watched `../../.git/HEAD`, and on a branch that file holds `ref: refs/heads/` -- a line that does not change when you commit. Since a `rerun-if-changed` directive replaces cargo's default of watching the whole package, the script never re-ran and the binary kept whatever commit it was first built with. Measured rather than reasoned about: `.git/HEAD` had not been touched in 21 hours while fourteen commits landed, and a freshly rebuilt binary reported `b6f23ec9f3bc` against a `HEAD` of `0b96b14746bb` -- six behind. CI hides this completely, because there the commit arrives through `PLACEMENT_PROBE_COMMIT`, so the stamp was wrong only on local builds, which are exactly the ones whose commit is their only traceability. The whole submission record rests on this field. Now resolves what `HEAD` points at and watches that too, handling a detached `HEAD` (which changes on its own), a packed ref (where `packed-refs` changes instead), and a `.git` file redirecting to a worktree. Paths are emitted only when they exist, since a missing path makes cargo re-run the script on every build. **`recv_timeout` busy-waited below a millisecond.** A remainder under 1 ms truncates to zero, and a zero wait returns at once, so the loop re-armed and re-waited without sleeping. Arming clears the doorbell, which is a `ResetEvent` syscall, so this was a syscall storm rather than merely a hot loop. Clamped to a millisecond: overshooting a blocking deadline by less than a timer tick is the right trade, and sub-millisecond precision is not available from a blocking wait at any price. Also: the `Options` doctest labelled `rx.len()` as "the peak" when it is the current depth; the `metrics` module described a ring counter that lives on `Doorbell` without saying so; and `request_cost` passed `0x8000_0000`, `1` and `3` to an open request instead of `GENERIC_READ`, `FILE_SHARE_READ` and `OPEN_EXISTING`, which this repository's conventions forbid. The three constants were checked against `windows-sys` rather than assumed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/build.rs | 76 ++++++++++++++++++- .../src/request_cost.rs | 8 +- .../windows-waitable-queues/src/blocking.rs | 22 +++++- .../src/blocking/tests.rs | 45 ++++++++++- crates/windows-waitable-queues/src/metrics.rs | 8 +- crates/windows-waitable-queues/src/options.rs | 2 +- 6 files changed, 152 insertions(+), 9 deletions(-) diff --git a/crates/windows-placement-probe/build.rs b/crates/windows-placement-probe/build.rs index d2989ac6..814e9e02 100644 --- a/crates/windows-placement-probe/build.rs +++ b/crates/windows-placement-probe/build.rs @@ -15,6 +15,8 @@ //! `Provenance::Synthetic` being `Default` one layer down: forgetting, or being //! unable to tell, must be the safe direction. +use std::fs; +use std::path::{Path, PathBuf}; use std::process::Command; /// What CI sets so the build does not have to shell out to `git`. @@ -29,7 +31,7 @@ fn main() { // route. println!("cargo::rerun-if-env-changed={COMMIT_ENV}"); println!("cargo::rerun-if-env-changed={SOURCE_ENV}"); - println!("cargo::rerun-if-changed=../../.git/HEAD"); + watch_git_head(Path::new("../../.git")); let (commit, dirty) = match std::env::var(COMMIT_ENV) { // CI knows the commit it checked out, and a CI checkout is clean by @@ -59,6 +61,78 @@ fn main() { println!("cargo::rustc-env=PLACEMENT_PROBE_SOURCE_OUT={source}"); } +/// Ask cargo to re-run this script whenever the checked-out commit changes. +/// +/// # Watching `HEAD` alone does not work, and the failure is silent +/// +/// On a branch, `.git/HEAD` holds `ref: refs/heads/` -- a line that +/// does not change when you commit. What git rewrites is the *ref* file the +/// line names. Since a `rerun-if-changed` directive replaces cargo's default of +/// watching the whole package, watching only `HEAD` meant the script never re- +/// ran after a commit and the binary kept whatever commit it was first built +/// with. +/// +/// Measured on this repository rather than reasoned about: `.git/HEAD` had not +/// been touched in 21 hours while fourteen commits landed, and a freshly built +/// binary reported a commit six behind `HEAD`. CI hides this entirely, because +/// there the commit arrives through `PLACEMENT_PROBE_COMMIT` -- so the stamp +/// was wrong only on local builds, which are exactly the ones whose commit is +/// their only traceability. +/// +/// `HEAD` is still watched, because switching branches or detaching does change +/// it. +fn watch_git_head(git_dir: &Path) { + // A `.git` *file* rather than a directory means a worktree or submodule, + // and names the real directory. Not watched further: the redirect is enough + // to find the ref, and a tarball with no `.git` at all is the case this + // whole file is built to survive. + let git_dir = match fs::read_to_string(git_dir.join("HEAD")) { + Ok(_) => git_dir.to_path_buf(), + Err(_) => match fs::read_to_string(git_dir) { + Ok(redirect) => match redirect.trim().strip_prefix("gitdir:") { + Some(path) => PathBuf::from(path.trim()), + None => return, + }, + // No repository. The stamp is "unknown", which is the honest answer + // and needs no watching. + Err(_) => return, + }, + }; + + let head = git_dir.join("HEAD"); + let Ok(contents) = fs::read_to_string(&head) else { + return; + }; + watch(&head); + + // A detached HEAD holds the sha itself, so the file already changes with + // the commit and there is nothing further to watch. + let Some(reference) = contents.trim().strip_prefix("ref:") else { + return; + }; + + // A loose ref is rewritten on every commit. A ref that has been packed does + // not exist as a file, and `packed-refs` is what changes instead -- so + // whichever of the two is present is the one to watch. Emitting a path that + // does not exist would make cargo re-run this script on every single build. + let loose = git_dir.join(reference.trim()); + if loose.exists() { + watch(&loose); + } else { + let packed = git_dir.join("packed-refs"); + if packed.exists() { + watch(&packed); + } + } +} + +/// Emit one `rerun-if-changed`, if the path can be named. +fn watch(path: &Path) { + if let Some(path) = path.to_str() { + println!("cargo::rerun-if-changed={path}"); + } +} + /// The first twelve characters, which is unambiguous in practice and short /// enough to sit in a printed line. fn shorten(sha: &str) -> String { diff --git a/crates/windows-platform-probes/src/request_cost.rs b/crates/windows-platform-probes/src/request_cost.rs index c6f1b1cf..4e158765 100644 --- a/crates/windows-platform-probes/src/request_cost.rs +++ b/crates/windows-platform-probes/src/request_cost.rs @@ -72,6 +72,8 @@ use std::time::Instant; use wtf_string::Wtf16String; use windows_namespace_request_sys::{CapturedHandle, OpenFile, prepare}; +use windows_sys::Win32::Foundation::GENERIC_READ; +use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, OPEN_EXISTING}; /// Nanoseconds per operation for one timed loop. #[derive(Debug, Clone, Copy, PartialEq)] @@ -151,9 +153,9 @@ pub fn measure() -> Observation { timings.push(time_loop("build_open_request", ITERATIONS, || { let path = prepare(&short).expect("an absolute path prepares"); OpenFile::new(path) - .with_desired_access(0x8000_0000) - .with_share_mode(1) - .with_creation_disposition(3) + .with_desired_access(GENERIC_READ) + .with_share_mode(FILE_SHARE_READ) + .with_creation_disposition(OPEN_EXISTING) })); // Cloning the prepared path alone, which is what a request-recycling scheme diff --git a/crates/windows-waitable-queues/src/blocking.rs b/crates/windows-waitable-queues/src/blocking.rs index 8392558a..82816873 100644 --- a/crates/windows-waitable-queues/src/blocking.rs +++ b/crates/windows-waitable-queues/src/blocking.rs @@ -147,6 +147,13 @@ pub(crate) fn recv_timeout( /// that was supposed to re-check the deadline never regains control to do so. const MAX_FINITE_WAIT_MILLIS: u32 = INFINITE - 1; +/// The shortest wait worth asking for, in milliseconds. +/// +/// Zero is a poll, not a wait, and the loop that calls this treats a return as +/// "check again" -- so a zero would busy-poll the doorbell rather than sleep on +/// it. +const MIN_WAIT_MILLIS: u32 = 1; + /// How long to block for, given the time left on the caller's deadline. /// /// Saturating rather than wrapping: a duration longer than a `u32` of @@ -168,10 +175,23 @@ const MAX_FINITE_WAIT_MILLIS: u32 = INFINITE - 1; /// conversion itself. The two guards cover different inputs -- one the /// durations too large to represent, the other the one that is representable /// and still means forever. +/// **And never zero, which is the other end of the same argument.** The caller +/// has already returned `Timeout` if nothing remains, so every duration +/// reaching here is non-zero -- but anything under a millisecond truncates to +/// `0`, and a zero wait returns at once. The loop would then re-arm and re-wait +/// without sleeping, which is not merely a spin: arming clears the doorbell, +/// so it is a `ResetEvent` syscall per turn for the last fraction of the +/// budget. +/// +/// Waiting a whole millisecond can overshoot the deadline, and that is the +/// right trade for a blocking call. The timer granularity is coarser than a +/// millisecond anyway, so a caller needing sub-millisecond precision cannot get +/// it from a blocking wait at any price -- what they would get instead is a +/// burning core. fn wait_millis(remaining: Duration) -> u32 { u32::try_from(remaining.as_millis()) .unwrap_or(MAX_FINITE_WAIT_MILLIS) - .min(MAX_FINITE_WAIT_MILLIS) + .clamp(MIN_WAIT_MILLIS, MAX_FINITE_WAIT_MILLIS) } /// Block on a doorbell handle, translating the Win32 result. diff --git a/crates/windows-waitable-queues/src/blocking/tests.rs b/crates/windows-waitable-queues/src/blocking/tests.rs index 7e339d22..7b2b0739 100644 --- a/crates/windows-waitable-queues/src/blocking/tests.rs +++ b/crates/windows-waitable-queues/src/blocking/tests.rs @@ -11,11 +11,10 @@ use std::time::Duration; use windows_sys::Win32::System::Threading::INFINITE; -use super::{MAX_FINITE_WAIT_MILLIS, wait_millis}; +use super::{MAX_FINITE_WAIT_MILLIS, MIN_WAIT_MILLIS, wait_millis}; #[test] fn an_ordinary_duration_is_passed_through_in_milliseconds() { - assert_eq!(wait_millis(Duration::from_millis(0)), 0); assert_eq!(wait_millis(Duration::from_millis(1)), 1); assert_eq!(wait_millis(Duration::from_millis(250)), 250); assert_eq!(wait_millis(Duration::from_secs(1)), 1_000); @@ -71,3 +70,45 @@ fn the_largest_duration_that_still_fits_is_not_clamped() { "one millisecond past the boundary must clamp, not wrap to zero" ); } + +#[test] +fn a_sub_millisecond_remainder_still_sleeps() { + // The busy-wait. Anything under a millisecond truncates to zero, and a zero + // wait returns immediately -- so the loop would re-arm and re-wait without + // sleeping for the last fraction of the budget. Arming clears the doorbell, + // which is a `ResetEvent` syscall, so the spin is a syscall storm rather + // than merely a hot loop. + for tiny in [ + Duration::from_nanos(1), + Duration::from_micros(1), + Duration::from_micros(999), + ] { + assert_eq!( + wait_millis(tiny), + MIN_WAIT_MILLIS, + "a {tiny:?} remainder would have polled instead of waiting" + ); + } +} + +#[test] +fn no_duration_ever_produces_a_zero_wait() { + // The property behind the case above, stated over the boundary values + // rather than over three samples. Zero is a poll; the caller has already + // returned `Timeout` when nothing remains, so a poll here is never what was + // wanted. + for remaining in [ + Duration::ZERO, + Duration::from_nanos(1), + Duration::from_micros(500), + Duration::from_millis(1), + Duration::from_secs(1), + Duration::MAX, + ] { + assert_ne!( + wait_millis(remaining), + 0, + "a {remaining:?} remainder produced a poll rather than a wait" + ); + } +} diff --git a/crates/windows-waitable-queues/src/metrics.rs b/crates/windows-waitable-queues/src/metrics.rs index 23c7b72f..137eeb0d 100644 --- a/crates/windows-waitable-queues/src/metrics.rs +++ b/crates/windows-waitable-queues/src/metrics.rs @@ -10,6 +10,9 @@ //! - **Refusals**, so backpressure is *measured* rather than inferred from a //! caller's error handling. //! - **Doorbell rings**, so the skip rule is measurable rather than assumed. +//! Counted by [`Doorbell`](crate::doorbell::Doorbell) rather than by +//! [`Metrics`], for the reason given below; a reader after the ring count +//! will not find it on this type. //! - **Peak depth**, so a bound can be chosen from evidence. //! //! **Depth itself is not here**, and its absence is a decision. `Bounded::len` @@ -27,7 +30,10 @@ //! success path entirely. //! - **Rings** increment only when the doorbell actually calls `SetEvent`, //! which is a syscall measured at ~81 ns against ~7 ns for an uncontended -//! atomic. The skipped signals -- the hot ones -- are deliberately *not* +//! atomic. That increment happens inside the doorbell, so the counter lives +//! on [`Doorbell`](crate::doorbell::Doorbell) rather than on [`Metrics`]: +//! keeping it here would mean reaching across to a line this type does not +//! own. The skipped signals -- the hot ones -- are deliberately *not* //! counted, because that increment would land on exactly the path the skip //! exists to cheapen. //! - **Peak depth** cannot be placed that way, because it must observe every diff --git a/crates/windows-waitable-queues/src/options.rs b/crates/windows-waitable-queues/src/options.rs index b59c4381..aaf1d380 100644 --- a/crates/windows-waitable-queues/src/options.rs +++ b/crates/windows-waitable-queues/src/options.rs @@ -44,7 +44,7 @@ use crate::disposal::Disposal; /// tx.push(2).expect("a fresh queue has room"); /// assert_eq!(rx.pop(), Some(1)); /// -/// // The peak, not the depth right now. +/// // `len` is the depth right now; `high_water` is the peak it reached. /// assert_eq!(rx.len(), 1); /// assert_eq!(rx.high_water(), Some(2)); /// # Ok::<(), windows_waitable_queues::CapacityError>(()) From 7f69b4ae7bb507707fa1d11a1917b1d35e9146f7 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 20:06:17 -0400 Subject: [PATCH 088/361] fix(placement-probe): compare the full processor identity, not the number Eleven of the eighteen findings from review 5072622803. The seven left are the output-abstraction cluster, which is a refactor across seven binaries and is called out separately rather than smuggled in here. **A cross-group pair could be discarded entirely.** `representative_pairs` guards against measuring a processor against itself, and wrote that guard as `producer.number == consumer.number`. A number is unique only within its processor group, so on a machine with more than 64 logical processors the guard also threw away every pair whose two processors merely *share* a number in different groups. This is the same defect M1B removed from `classify` and the selection maps; this comparison was missed. The test is deliberately minimal -- two processors, one per group, both numbered zero -- because that is the only shape where the loss is visible. A larger fixture hides it: another cross-group pair with differing numbers lands in the same placement category and fills it, so the selection looks complete. That is why this survived the group work, and a first attempt at the test passed against the bug for exactly that reason. With the guard reverted, the selection now comes back empty. **The ceiling was documented wrongly in four places.** `usize::MAX / 2` is `2^63 - 1`, so the largest power-of-two capacity is `2^62`; the crate docs, the README and two design-note sections all claimed `2^63`. The crate already contradicted itself -- `error.rs` says plainly that "the nearest power of two below `usize::MAX` is 2^63, which exceeds the largest representable capacity" -- and prose cannot notice that it disagrees with prose, so the ceiling is now a test over every power of two rather than a number repeated in four documents. Three further mentions of `2^63` were checked and left: they are correct in context, making the same point. **Processor identity in the probe tables.** Columns printed a bare number, so two distinct processors rendered as the same `cpu5`. Now `g{group}/cpu{number}` everywhere, with the columns widened to match. The NUMA hop table in the same file also still read `a <-> b`; the probe crate's own report was corrected for direction and this second renderer of the same data was not. **The dirty flag has a second staleness path**, which the commit fix did not close: naming any watched path replaces cargo's package-wide default, so editing this crate and rebuilding kept the previous answer. `src`, `Cargo.toml` and `build.rs` are now watched. What remains -- an uncommitted edit in another crate -- is disclosed on the field rather than papered over. **The release job could not be re-run.** `gh release create` both creates and uploads, so a failed upload left a release that made every retry fail with "already exists". Now creates only when absent and uploads with `--clobber`. Also: the crate status omitted `reserving_mpsc` while shipping it; `request_cost` printed a ratio against a doorbell constant from the development machine as though both halves were local, which hosted runners make wrong; a completed multi-line checklist item moved to the archive behind a stub; and an archived checklist name made clickable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-placement-probe.yml | 25 ++++++- CHECKLIST.md | 16 +--- COMPLETED-CHECKLIST.md | 19 +++++ COMPLETED-PLANS.md | 2 +- crates/windows-placement-probe/build.rs | 16 ++++ .../src/build_identity.rs | 9 +++ .../src/core_affinity.rs | 11 ++- .../src/core_affinity/tests.rs | 40 ++++++++++ .../src/bin/core_affinity.rs | 43 ++++++----- .../src/bin/request_cost.rs | 11 ++- .../windows-waitable-queues/DESIGN-NOTES.md | 4 +- crates/windows-waitable-queues/README.md | 2 +- .../windows-waitable-queues/src/capacity.rs | 3 + .../src/capacity/tests.rs | 75 +++++++++++++++++++ crates/windows-waitable-queues/src/lib.rs | 5 +- 15 files changed, 237 insertions(+), 44 deletions(-) create mode 100644 crates/windows-waitable-queues/src/capacity/tests.rs diff --git a/.github/workflows/release-placement-probe.yml b/.github/workflows/release-placement-probe.yml index 57591e45..8f340728 100644 --- a/.github/workflows/release-placement-probe.yml +++ b/.github/workflows/release-placement-probe.yml @@ -193,12 +193,31 @@ jobs: every host measured so far has exactly one. NOTES + # Written to be re-runnable, because the failure it guards against leaves + # the job in exactly the state that blocks a retry. `gh release create` + # both creates the release and uploads the assets; if it creates the + # release and then an upload fails -- a network blip, a throttled API -- + # the tag now has a release, so a re-run fails immediately with "release + # already exists" and the only way forward is to delete the release by + # hand. So: create it only if it is absent, then upload separately with + # `--clobber` so a partial upload can be completed rather than restarted. - name: Publish the release env: GH_TOKEN: ${{ github.token }} run: | - gh release create "${GITHUB_REF_NAME}" \ + if gh release view "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + echo "Release ${GITHUB_REF_NAME} already exists; updating its notes and assets." + gh release edit "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "windows-placement-probe ${GITHUB_REF_NAME##*-v}" \ + --notes-file notes.md + else + gh release create "${GITHUB_REF_NAME}" \ + --repo "${GITHUB_REPOSITORY}" \ + --title "windows-placement-probe ${GITHUB_REF_NAME##*-v}" \ + --notes-file notes.md + fi + gh release upload "${GITHUB_REF_NAME}" \ --repo "${GITHUB_REPOSITORY}" \ - --title "windows-placement-probe ${GITHUB_REF_NAME##*-v}" \ - --notes-file notes.md \ + --clobber \ artifacts/placement-probe-*.exe diff --git a/CHECKLIST.md b/CHECKLIST.md index f9382abf..afde24d2 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -112,21 +112,7 @@ Numbered M34 rather than M22 because the three root-level checklists share one m [CHECKLIST.md](CHECKLIST.md) holds M19-M21, [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. -- [x] **M34.1** -- Promote the ad-hoc sabotage harness into a reusable tool. **Done.** - [tools/run-sabotage.ps1](tools/run-sabotage.ps1) plus - [tools/README-sabotage.md](tools/README-sabotage.md), driven by a `sabotage.json` kept beside the - code it patches; the first is - [crates/windows-waitable-queues/sabotage.json](crates/windows-waitable-queues/sabotage.json), whose - nine entries reproduce the M30.4/M30.5 sweep exactly through the promoted tool. - Six of the tool's own guards were verified by making each one fire: a name filter matching nothing, - a missing file, a dirty target, a pattern matching 14 sites instead of 1, a patch that changes - nothing, and a deliberately red baseline. A harness whose guards are untested is the thing it exists - to warn about. - Two subtleties are recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> `Sabotage sweeps` rather than - left in the script: a **survived** sabotage may be a defect in the *sabotage* rather than a hole in - the tests, which is why the patch is now printed on every unexpected result; and a **too-short - timeout manufactures a false "caught"**, crediting tests with catching a defect they never ran - against, so the bound errs generous. +- [x] **M34.1** -- Promote the ad-hoc sabotage harness into a reusable tool. -> [completed 2026-08-31](COMPLETED-CHECKLIST.md#m341) ## M-inf -- Parked diff --git a/COMPLETED-CHECKLIST.md b/COMPLETED-CHECKLIST.md index 3ada5e67..46ebab7f 100644 --- a/COMPLETED-CHECKLIST.md +++ b/COMPLETED-CHECKLIST.md @@ -1685,3 +1685,22 @@ Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is wh is what the caller asked for and cannot be mistaken for a measurement. Feeding one to `measure()` would produce genuine timings under fabricated node ids, because a synthetic topology's processor *numbers* are still valid on the real host and every pin would succeed. + +## Moved 2026-08-31 -- the sabotage harness became a tool + +### M34.1 -- Promote the ad-hoc sabotage harness into a reusable tool. *(completed 2026-08-31 20:03:57 -04:00)* +- [x] **M34.1** -- Promote the ad-hoc sabotage harness into a reusable tool. **Done.** + [tools/run-sabotage.ps1](tools/run-sabotage.ps1) plus + [tools/README-sabotage.md](tools/README-sabotage.md), driven by a `sabotage.json` kept beside the + code it patches; the first is + [crates/windows-waitable-queues/sabotage.json](crates/windows-waitable-queues/sabotage.json), whose + nine entries reproduce the M30.4/M30.5 sweep exactly through the promoted tool. + Six of the tool's own guards were verified by making each one fire: a name filter matching nothing, + a missing file, a dirty target, a pattern matching 14 sites instead of 1, a patch that changes + nothing, and a deliberately red baseline. A harness whose guards are untested is the thing it exists + to warn about. + Two subtleties are recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> `Sabotage sweeps` rather than + left in the script: a **survived** sabotage may be a defect in the *sabotage* rather than a hole in + the tests, which is why the patch is now printed on every unexpected result; and a **too-short + timeout manufactures a false "caught"**, crediting tests with catching a defect they never ran + against, so the bound errs generous. \ No newline at end of file diff --git a/COMPLETED-PLANS.md b/COMPLETED-PLANS.md index 92435130..47955ec2 100644 --- a/COMPLETED-PLANS.md +++ b/COMPLETED-PLANS.md @@ -8,7 +8,7 @@ and in [crates/windows-threadpool-sys/COMPLETED-CHECKLIST.md](crates/windows-thr | Path to CHECKLIST.md | Completion Date | Brief description | Design Notes | |---|---|---|---| -| CHECKLIST-topology-provenance.md (archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md)) | 2026-08-31 | Topology content carries its own provenance. `Topology` is documented as constructible by hand and by deserializing a description written for "a machine you do not have", and nothing distinguished either from `discover()`. `Provenance` is `Synthetic` by `Default`, so forgetting is safe and claiming is deliberate; only `discover` yields `Measured`; deserialization can only downgrade, so a file cannot assert it is this machine. The marker renders *inside* the canonical fingerprint string, because a marker beside it would let a fabricated host compare equal to a real one. A pure `places_from_topology` seam was added while `measure()` was deliberately left without one -- a seam that only moves data is safe, one that lets fabricated labels reach real hardware is not -- which closed an unverifiable NUMA mapping: hardcoding the node to 0 passed the entire suite beforehand and fails three tests now. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) `D-12`, [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | +| [CHECKLIST-topology-provenance.md, archived](COMPLETED-CHECKLIST.md) | 2026-08-31 | Topology content carries its own provenance. `Topology` is documented as constructible by hand and by deserializing a description written for "a machine you do not have", and nothing distinguished either from `discover()`. `Provenance` is `Synthetic` by `Default`, so forgetting is safe and claiming is deliberate; only `discover` yields `Measured`; deserialization can only downgrade, so a file cannot assert it is this machine. The marker renders *inside* the canonical fingerprint string, because a marker beside it would let a fabricated host compare equal to a real one. A pure `places_from_topology` seam was added while `measure()` was deliberately left without one -- a seam that only moves data is safe, one that lets fabricated labels reach real hardware is not -- which closed an unverifiable NUMA mapping: hardcoding the node to 0 passed the entire suite beforehand and fails three tests now. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) `D-12`, [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST.md](CHECKLIST.md) | 2026-08-17 | Workspace metadata, release automation, name reservation, shared cross-crate invariants, generation-stamped operation identities so a retained `OperationId` cannot alias a recycled operation, and six rounds of review hardening: typed wait provenance, teardown-gated re-arming, borrow-checked callback environments, reusable cleanup groups, `stop_and_drain`, borrow-checked exclusivity for the blocking backend, a documented wait-overlap contract, and rejection of values the Win32 fields cannot honour across every adapter. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | 2026-08-17 | Overlapped-I/O foundation complete: endpoints/provenance, operation storage, raw IOCP and blocking backends, cancellation/rundown, submission seam, safe per-family adapters for file read/write plus scatter/gather (`fs`) and sockets on both backends (`socket`), and a buffer-owning but `unsafe` raw-control-code `DeviceIoControl` seam (`device`). | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | | [crates/windows-threadpool-sys/CHECKLIST.md](crates/windows-threadpool-sys/CHECKLIST.md) | 2026-08-17 | Thread pool complete: callback environment, private pools, cleanup groups, work, one-shot and periodic timers as distinct types, waits that own a handle of proven provenance, and the `TP_IO` backend over the shared seam, with examples, documentation, and an opt-in timer stress suite. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | diff --git a/crates/windows-placement-probe/build.rs b/crates/windows-placement-probe/build.rs index 814e9e02..69e912cb 100644 --- a/crates/windows-placement-probe/build.rs +++ b/crates/windows-placement-probe/build.rs @@ -33,6 +33,22 @@ fn main() { println!("cargo::rerun-if-env-changed={SOURCE_ENV}"); watch_git_head(Path::new("../../.git")); + // **The dirty flag has a second way to go stale, and these close the common + // case rather than all of it.** Emitting any `rerun-if-changed` replaces + // cargo's default of watching the whole package, so without naming them, + // editing this crate and rebuilding would recompile the binary while + // leaving the previous run's clean/dirty answer stamped into it. + // + // What remains: an uncommitted edit in *another* crate of the workspace + // rebuilds this one without re-running this script, so the flag can still + // report a tree cleaner than it is. That is disclosed on the record's + // `dirty` field rather than papered over, and it only ever affects builds + // already marked `!!UNOFFICIAL!!` and `[LOCAL]` -- a CI build takes its + // answer from the environment instead. + println!("cargo::rerun-if-changed=src"); + println!("cargo::rerun-if-changed=Cargo.toml"); + println!("cargo::rerun-if-changed=build.rs"); + let (commit, dirty) = match std::env::var(COMMIT_ENV) { // CI knows the commit it checked out, and a CI checkout is clean by // construction, so no `git` call is needed or wanted there. diff --git a/crates/windows-placement-probe/src/build_identity.rs b/crates/windows-placement-probe/src/build_identity.rs index 2318f7ad..ce90b534 100644 --- a/crates/windows-placement-probe/src/build_identity.rs +++ b/crates/windows-placement-probe/src/build_identity.rs @@ -65,6 +65,15 @@ pub struct BuildIdentity { /// `None` means the question could not be asked -- no repository, or no /// `git` -- which is a different fact from "clean" and is kept distinct /// from it. + /// + /// **On a local build this can report a tree cleaner than it was**, and the + /// limit is disclosed rather than hidden. The answer is taken by a build + /// script, and cargo re-runs that script only when something it declared an + /// interest in changes; an uncommitted edit in another crate of the + /// workspace rebuilds this one without re-running it. A CI build is + /// unaffected, because there the answer comes from the environment and the + /// checkout is clean by construction -- and a local build is already marked + /// `!!UNOFFICIAL!!`, which is the stronger caveat. pub dirty: Option, /// Where the binary came from. pub source: BuildSource, diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index 68e8dde1..32425f39 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -415,11 +415,20 @@ pub fn representative_pairs( let mut chosen = BTreeMap::new(); for producer in places { for consumer in places { - if producer.number == consumer.number { + if producer.id() == consumer.id() { // One processor cannot be both ends: that measures the // scheduler time-slicing a thread against itself. Two // processors on one *core* are a different matter entirely and // are measured, as `Placement::SameCoreSiblings`. + // + // **The full identity, not the number.** A number is unique + // only within its processor group, so comparing numbers alone + // treats group 0's processor 5 and group 1's processor 5 as one + // processor and skips the pair -- discarding a real, + // cross-group placement on exactly the large machines this tool + // exists to measure. That is the same defect M1B removed from + // `classify` and the selection maps; this comparison was + // missed. continue; } chosen diff --git a/crates/windows-placement-probe/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs index cfbd8a53..67194731 100644 --- a/crates/windows-placement-probe/src/core_affinity/tests.rs +++ b/crates/windows-placement-probe/src/core_affinity/tests.rs @@ -809,6 +809,46 @@ mod processor_groups { ); } + #[test] + fn a_pair_sharing_a_number_across_groups_is_still_a_pair() { + // The self-pair guard exists to stop a processor being measured against + // itself. Written as `producer.number == consumer.number` it also + // discards every pair whose two processors merely *share* a number in + // different groups -- which on a two-group machine is a large fraction + // of the cross-group pairs, and on a machine with more groups, more. + // + // Checked through `representative_pairs` rather than on the predicate, + // because the predicate is private and the selection is what the run + // consumes. + // Two processors, one per group, both numbered 0 -- so the machine's + // only possible pair is one the number-only guard would discard, and + // the whole selection comes back empty. Deliberately minimal: on a + // larger fixture the loss hides, because another cross-group pair with + // differing numbers lands in the same category and fills it. That is + // why this is not tested through `two_groups`, and why the defect + // survived the group work: it is invisible unless the discarded pair is + // the only representative of its placement. + let mut here = place(0, 0, Some(0)); + here.numa_node = 0; + let mut there = in_group(place(0, 0, Some(1)), 1); + there.numa_node = 1; + + let pairs = representative_pairs(&[here, there]); + + assert_eq!( + pairs.len(), + 1, + "the only pair this machine can express was discarded: {pairs:?}" + ); + let (producer, consumer) = pairs[&Placement::CrossNumaNode]; + assert_ne!( + producer.id(), + consumer.id(), + "a placement measures one processor against itself" + ); + assert_ne!(producer.group, consumer.group); + } + #[test] fn a_group_is_part_of_the_rendered_identity() { // The slice string is how a measurement's provenance travels into a diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index 5ef3e767..c8f73d2a 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -13,13 +13,16 @@ fn main() -> std::io::Result<()> { println!("processors, as discovered:"); println!( - " {:>4} {:>16} {:>13}", + " {:>8} {:>16} {:>13}", "cpu", "efficiency class", "cache domain" ); for place in &observation.processors { + // Group and number together: a number is unique only within its group, + // so two distinct processors on a machine with more than 64 of them + // would otherwise both render as `cpu5`. println!( - " {:>4} {:>16} {:>13}", - place.number, + " {:>8} {:>16} {:>13}", + format!("g{}/cpu{}", place.group, place.number), place.efficiency_class, place .cache_domain @@ -55,7 +58,7 @@ fn main() -> std::io::Result<()> { if !observation.by_class.is_empty() { println!("\n-- the same handoff, within each efficiency class --"); println!( - "{:<12} {:>4} {:>4} {:>12} {:>12} {:>10}", + "{:<12} {:>8} {:>8} {:>12} {:>12} {:>10}", "class", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" ); let mut classes: Vec = observation @@ -76,10 +79,10 @@ fn main() -> std::io::Result<()> { .find(|m| m.producer.efficiency_class == class && m.strategy == Strategy::Cached); if let (Some(base), Some(cached)) = (base, cached) { println!( - "{:<12} {:>4} {:>4} {:>12.1} {:>12.1} {:>10.1}", + "{:<12} {:>8} {:>8} {:>12.1} {:>12.1} {:>10.1}", format!("class {class}"), - base.producer.number, - base.consumer.number, + format!("g{}/cpu{}", base.producer.group, base.producer.number), + format!("g{}/cpu{}", base.consumer.group, base.consumer.number), base.nanos_per_item, cached.nanos_per_item, cached.consumer_batch @@ -94,7 +97,7 @@ fn main() -> std::io::Result<()> { println!("\n-- the handoff, by placement --"); println!( - "{:<26} {:>4} {:>4} {:>12} {:>12} {:>10} {:>10}", + "{:<26} {:>8} {:>8} {:>12} {:>12} {:>10} {:>10}", "placement", "prod", "cons", "base ns/it", "cached ns/it", "base depth", "cach depth" ); @@ -119,7 +122,7 @@ fn main() -> std::io::Result<()> { // Absent is a finding, not a gap: it means this machine cannot // express the placement at all. println!( - "{:<26} {:>4} {:>4} {:>12} {:>12} {:>10} {:>10}", + "{:<26} {:>8} {:>8} {:>12} {:>12} {:>10} {:>10}", placement.label(), "-", "-", @@ -131,10 +134,10 @@ fn main() -> std::io::Result<()> { continue; }; println!( - "{:<26} {:>4} {:>4} {:>12.1} {:>12.1} {:>10.1} {:>10.1}", + "{:<26} {:>8} {:>8} {:>12.1} {:>12.1} {:>10.1} {:>10.1}", placement.label(), - base.producer.number, - base.consumer.number, + format!("g{}/cpu{}", base.producer.group, base.producer.number), + format!("g{}/cpu{}", base.consumer.group, base.consumer.number), base.nanos_per_item, cached.nanos_per_item, base.consumer_batch, @@ -344,8 +347,8 @@ fn print_node_distances(observation: &Observation) { println!("\n-- the handoff, by NUMA node pair --"); println!( - "{:<14} {:>4} {:>4} {:>12} {:>12} {:>10}", - "node pair", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" + "{:<14} {:>8} {:>8} {:>12} {:>12} {:>10}", + "prod -> cons", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" ); let mut slowest: Option<(f64, (u32, u32))> = None; @@ -359,10 +362,14 @@ fn print_node_distances(observation: &Observation) { continue; }; println!( - "{:<14} {:>4} {:>4} {:>12.1} {:>12.1} {:>10.1}", - format!("{} <-> {}", pair.0, pair.1), - base.producer.number, - base.consumer.number, + "{:<14} {:>8} {:>8} {:>12.1} {:>12.1} {:>10.1}", + // `->`, not `<->`: hops are directed, because the producer writes + // and the consumer reads. The probe crate's own report was + // corrected for this and this second renderer of the same data was + // not, which is how two views of one measurement drift apart. + format!("{} -> {}", pair.0, pair.1), + format!("g{}/cpu{}", base.producer.group, base.producer.number), + format!("g{}/cpu{}", base.consumer.group, base.consumer.number), base.nanos_per_item, cached.nanos_per_item, cached.consumer_batch diff --git a/crates/windows-platform-probes/src/bin/request_cost.rs b/crates/windows-platform-probes/src/bin/request_cost.rs index f70a9574..690f216c 100644 --- a/crates/windows-platform-probes/src/bin/request_cost.rs +++ b/crates/windows-platform-probes/src/bin/request_cost.rs @@ -48,11 +48,20 @@ fn main() { let capture = observation.get("capture_handle"); if let Some(build) = build { + // The ratio names its own reference, because the two numbers do not + // come from the same machine. `build` was measured on this host just + // now; the doorbell figure is a constant captured once on the + // development machine. This probe now runs on hosted CI runners, which + // are a heterogeneous fleet, so a ratio printed as though both halves + // were local can be wrong even when the measurement is sound. println!( " building a pathed request costs {build:.0} ns, which is {:.1}x one", build / DOORBELL_NS_REFERENCE ); - println!(" doorbell."); + println!( + " doorbell AS MEASURED ON THE DEVELOPMENT MACHINE ({DOORBELL_NS_REFERENCE:.1} ns)," + ); + println!(" not on this one. Run probe-doorbell-cost here to make the ratio local."); println!(); println!(" SCOPE, because this is easy to over-read: that is a statement about"); println!(" ONE OPERATION TYPE, not about the queue. A namespace open is the"); diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 34f49f73..fd997395 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -566,7 +566,7 @@ keep both halves full width. The answer has one decisive part and three supporti **It does not remove the cost that matters.** The expense in this shape is the shared read of the consumer's position, and free space is `capacity - (position - head) - reserved`. `head` belongs to the consumer; no width of *producer-side* compare-and-swap makes it appear in the producer's word. So a -double-width exchange buys exactly one thing: lifting the ceiling from 2^31 to 2^63, on a ring that is +double-width exchange buys exactly one thing: lifting the ceiling from 2^31 to 2^62, on a ring that is allocated in full at construction. The supporting reasons: @@ -1087,7 +1087,7 @@ What the crate owes a caller instead is honesty and equipment: Two justifications are available and both are refused, because a rationale that evaporates on inspection is worse than none: -- **Not capacity.** `slotwise_mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, and that difference is +- **Not capacity.** `slotwise_mpsc` reaches 2^62 slots and `reserving_mpsc` 2^31, and that difference is unreachable: it counts slots allocated at construction, not items ever pushed, and 2^31 slots is tens of gigabytes before the ring holds anything useful. See [D-17](#d-17) for why the packing forces it. - **Not `slotwise_mpsc` being faster somewhere.** Its one measured advantage is a single producer with a live diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 363b6063..07d75981 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -195,7 +195,7 @@ can run that measurement on your hardware instead of inheriting ours. Two things that look like reasons to choose and are not: -- **Capacity.** `slotwise_mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, but that +- **Capacity.** `slotwise_mpsc` reaches 2^62 slots and `reserving_mpsc` 2^31, but that counts slots allocated up front, not items ever pushed. A ring of 2^31 slots is tens of gigabytes before it holds anything useful. - **`slotwise_mpsc` winning at one producer.** True in one regime, and at one producer diff --git a/crates/windows-waitable-queues/src/capacity.rs b/crates/windows-waitable-queues/src/capacity.rs index 533b69e0..fed138b6 100644 --- a/crates/windows-waitable-queues/src/capacity.rs +++ b/crates/windows-waitable-queues/src/capacity.rs @@ -47,6 +47,9 @@ use crate::error::CapacityError; /// shape may be. pub(crate) const WRAPPING_MAX_CAPACITY: usize = usize::MAX / 2; +#[cfg(test)] +mod tests; + /// What one shape will accept as a capacity. /// /// A named pair rather than two loose arguments, so neither a call site nor a diff --git a/crates/windows-waitable-queues/src/capacity/tests.rs b/crates/windows-waitable-queues/src/capacity/tests.rs new file mode 100644 index 00000000..d99d4939 --- /dev/null +++ b/crates/windows-waitable-queues/src/capacity/tests.rs @@ -0,0 +1,75 @@ +// Copyright (c) Mike Grier. + +//! Tests for the capacity bounds themselves. +//! +//! These exist because the ceiling was **documented wrongly in four places at +//! once**: the crate docs, the README and two design-note sections all said the +//! widest shape reaches `2^63` slots. It does not, and `error.rs` said so +//! correctly in the same crate the whole time -- "the nearest power of two +//! below `usize::MAX` is 2^63, which exceeds the largest representable +//! capacity". Prose cannot notice that it disagrees with prose; a test can. +//! +//! No queue is constructed here. Validation is a pure function of the request +//! and the bounds, so the ceiling can be checked without asking an allocator +//! for exabytes. + +use super::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; + +/// The bounds of a shape that stops where wrapping positions stop being +/// unambiguous, which is the widest any shape in this crate goes. +const WIDEST: Bounds = Bounds { + min: 1, + max: WRAPPING_MAX_CAPACITY, +}; + +#[test] +fn the_wrapping_ceiling_is_one_below_a_power_of_two() { + // The fact every other assertion here rests on, stated so a reader does not + // have to do the arithmetic: `usize::MAX / 2` is odd, so it is not itself a + // capacity any shape accepts. + assert_eq!(WRAPPING_MAX_CAPACITY, (1_usize << 63) - 1); + assert!(!WRAPPING_MAX_CAPACITY.is_power_of_two()); +} + +#[test] +fn the_largest_accepted_capacity_is_two_to_the_sixty_two() { + // The documented number. `2^62` fits under `usize::MAX / 2`; `2^63` is one + // larger than the bound and is refused, which is what four documents used + // to claim was reachable. + validate_capacity(1_usize << 62, WIDEST).expect("2^62 is within the wrapping bound"); + + validate_capacity(1_usize << 63, WIDEST) + .expect_err("2^63 exceeds the wrapping bound and must be refused"); +} + +#[test] +fn every_power_of_two_up_to_the_ceiling_is_accepted() { + // A property rather than the two boundary samples above, so a bound that + // moved for some other reason cannot pass by coincidence. + for shift in 0..63_u32 { + let capacity = 1_usize << shift; + assert!( + validate_capacity(capacity, WIDEST).is_ok(), + "2^{shift} should be accepted" + ); + } + for shift in 63..usize::BITS { + let capacity = 1_usize << shift; + assert!( + validate_capacity(capacity, WIDEST).is_err(), + "2^{shift} should be refused" + ); + } +} + +#[test] +fn a_capacity_that_is_not_a_power_of_two_is_refused_whatever_its_size() { + // Guards the other half of the rule, so a fix to the ceiling cannot be made + // by loosening the shape of what is accepted. + for capacity in [3_usize, 6, 100, (1 << 62) - 1, (1 << 62) + 1] { + assert!( + validate_capacity(capacity, WIDEST).is_err(), + "{capacity} is not a power of two and must be refused" + ); + } +} diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 0312eeab..00904621 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -171,7 +171,7 @@ //! these hosts. //! //! Two things that look like reasons to choose and are not. **Capacity**: -//! `slotwise_mpsc` reaches 2^63 slots and `reserving_mpsc` 2^31, but that counts slots +//! `slotwise_mpsc` reaches 2^62 slots and `reserving_mpsc` 2^31, but that counts slots //! allocated up front rather than items ever pushed, and 2^31 slots is tens of //! gigabytes before the ring holds anything useful. **`slotwise_mpsc` winning at one //! producer**: true in one regime, and at one producer you want [`spsc`]. @@ -193,7 +193,8 @@ //! //! # Status //! -//! [`spsc`] and [`slotwise_mpsc`] are implemented, both with their doorbell: either can +//! [`spsc`], [`slotwise_mpsc`] and [`reserving_mpsc`] are implemented, each with its +//! doorbell: any of them can //! be polled with no kernel object at all, blocked on directly, or waited on //! alongside other handles. The remaining shapes land in the milestones tracked //! by `CHECKLIST-io-domains.md` at the workspace root; the decisions they are From 4b6b5fd1f287f0b669cef470ae2322315697e101 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 20:09:36 -0400 Subject: [PATCH 089/361] docs: queue the output-sink refactor the review asked for Seven of review 5072622803's eighteen findings are one refactor: every probe binary calls `println!` from many sites, which this repository's own rules forbid outright -- an output abstraction is required at the first occurrence. They were deferred from that pull request for sequencing, not doubt: it is a single refactor across seven binaries, and landing it inside a 90-commit branch already under review would mix a large mechanical diff with unrelated correctness work. A deferral that lives only in a pull request comment is exactly what this repository forbids, so it is queued as M34.2 instead, with the seven binaries named. The item carries the condition that matters: the rule exists so output becomes testable, so an abstraction introduced without a capture-based test spends the cost and skips the benefit. It is not to be checked off on the refactor alone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/CHECKLIST.md b/CHECKLIST.md index afde24d2..d7668486 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -114,6 +114,29 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. - [x] **M34.1** -- Promote the ad-hoc sabotage harness into a reusable tool. -> [completed 2026-08-31](COMPLETED-CHECKLIST.md#m341) +- [ ] **M34.2** -- **Route every tool's output through one sink, per the repository's own rule**: never + call `println!`/`eprintln!` from more than one site in a tool; introduce a writer trait, sink or + formatter at the first occurrence and route everything through it. Seven binaries violate this today + and were flagged individually in review 5072622803 on pull request #56: + [placement_probe.rs](crates/windows-placement-probe/src/bin/placement_probe.rs), + [doorbell_cost.rs](crates/windows-platform-probes/src/bin/doorbell_cost.rs), + [queue_contention.rs](crates/windows-platform-probes/src/bin/queue_contention.rs), + [request_cost.rs](crates/windows-platform-probes/src/bin/request_cost.rs), + [topology.rs](crates/windows-platform-probes/src/bin/topology.rs), + [core_affinity.rs](crates/windows-platform-probes/src/bin/core_affinity.rs) and + [peer_index_cache.rs](crates/windows-platform-probes/src/bin/peer_index_cache.rs). The banner + helpers that hardcode stdout are part of it, not an exception to it. + **Deferred from that pull request deliberately, and the reason is sequencing rather than doubt.** It + is one refactor across seven binaries; done properly it means choosing the seam once and applying it + uniformly, which is a large diff touching every probe's output. Landing it inside a 90-commit branch + already under review would mix it with unrelated correctness work. + **The point of the rule is that output becomes testable, so the conversion is not done until + something tests it.** An abstraction introduced without a capture-based test spends the cost and + skips the benefit -- do not check this item off on the refactor alone. + Start with `placement_probe`: its output is a published artifact that strangers paste into a + discussion thread, so "can this be captured and asserted end to end?" has real value there rather + than being architectural tidiness. + ## M-inf -- Parked Ungated work with no identified predecessor deliverable. From 975f8370d828bf0208cdb1ca8a18f8c6c1b4d455 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 20:26:47 -0400 Subject: [PATCH 090/361] fix(probes): a renamed shape left two report columns rendering as dashes Six findings from review 5072735803, three of them in code this branch added. **The rename broke a stringly-typed lookup.** `queue_contention` records runs under `slotwise_mpsc` since the `mpsc` rename, while the reporting binary went on asking for `"mpsc"`. Every lookup returned `None`, so the scaling column and every drained ratio rendered as `--` -- silently, because a wrong shape name is not a compile error. Fixed at the cause rather than the symptom: the names are now `queue_contention::shapes` constants that both the recorder and the reporter read, so the next rename cannot separate them. Confirmed by running the probe; the columns carry numbers again. **A region is not a page.** `Slots::on_numa_node` asked `QueryWorkingSetEx` about the base address and labelled the whole allocation with the answer. The ring is 8 KiB at the default capacity, so it spans several pages, and `VirtualAllocExNuma` expresses a preference that is applied per page as each faults. Now every page is queried; pages that disagree yield `None`, because a region split across nodes has no node, and a guess dressed as a measurement is what this field exists to prevent. **A lookup silently dropped half the hops.** M1C made each directed pair produce two rows per strategy, one per ring placement, but `Observation::node_pair` kept matching on pair and strategy alone and returning the first hit. The renderer therefore showed one arbitrary placement -- and could pair a baseline taken with the ring on the producer's node against a cached run taken with it on the consumer's. The lookup now takes the placement as part of its key, a plural `node_pair_rows` returns all of them, and the table gained a `ring on` column. **A ratio that could not isolate what it claimed.** The drained comparison enabled high-water tracking on the reserving shape and nothing else, which adds a load of the consumer's position to that shape's push path -- exactly the shared line the other shape's push avoids touching. The ratio measured reservation plus a handicap. Nothing consumed the high-water figure either, so it was paying for a number nobody read. Both shapes now use defaults; pricing that switch deserves its own row with both sides tracking. **`--help` was an error.** It returned through the parse-failure path, so it printed to stderr and exited non-zero: `placement-probe --help | less` showed nothing and any script running it saw failure. Now a distinct control path to stdout with a success exit. Verified both ways: help exits 0 with output on stdout, an unknown argument exits 1 with nothing on stdout. **A tag could publish a binary that disagrees with it.** Any `placement-probe-v*` tag releases, and nothing compared the tag against the version the binary reports -- so a mistyped or stale tag would publish a release titled one version containing a binary calling itself another, and the record embeds that version. The existing identity check now also compares the tag, guarded to tag pushes. The two findings not fixed here are queued: M34.2 (the output-sink refactor, seven binaries) and M34.3 (twelve completed bodies still sitting in CHECKLIST-io-domains.md). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-placement-probe.yml | 21 ++++++ CHECKLIST.md | 12 ++++ .../src/bin/placement_probe.rs | 16 ++++- .../src/core_affinity.rs | 37 ++++++++++- .../src/peer_index_cache.rs | 49 +++++++++++++- .../src/bin/core_affinity.rs | 64 +++++++++++-------- .../src/bin/queue_contention.rs | 18 +++--- .../src/queue_contention.rs | 49 ++++++++++---- 8 files changed, 213 insertions(+), 53 deletions(-) diff --git a/.github/workflows/release-placement-probe.yml b/.github/workflows/release-placement-probe.yml index 8f340728..84f25fb5 100644 --- a/.github/workflows/release-placement-probe.yml +++ b/.github/workflows/release-placement-probe.yml @@ -108,6 +108,27 @@ jobs: exit 1 ;; esac + # And the *right version*. Any `placement-probe-v*` tag triggers this + # workflow, and nothing else compares the tag against the crate + # version the binary reports. A mistyped or stale tag would otherwise + # publish a release titled one version containing a binary that calls + # itself another -- and since the record embeds the crate version, + # every submission from it would disagree with the release it came + # from. + # + # Only on a tag: a pull request run has no version to check against. + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + tag_version="${GITHUB_REF_NAME##*-v}" + case "${identity}" in + *"v${tag_version} "*) ;; + *) + echo "::error::tag ${GITHUB_REF_NAME} claims version ${tag_version}," >&2 + echo "::error::but the binary reports: ${identity}" >&2 + exit 1 + ;; + esac + echo "tag version ${tag_version} matches the binary" + fi - name: Verify an unstamped build reports itself unofficial # The negative case, and the one that would otherwise never be watched. diff --git a/CHECKLIST.md b/CHECKLIST.md index d7668486..c559b871 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -114,6 +114,18 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. - [x] **M34.1** -- Promote the ad-hoc sabotage harness into a reusable tool. -> [completed 2026-08-31](COMPLETED-CHECKLIST.md#m341) +- [ ] **M34.3** -- **Archive the completed bodies in + [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md)**, which holds twelve checked items still + carrying their full write-ups. The completed-item rule moves a large one to + [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md) immediately and leaves a one-line anchored stub, so + the active file stays a list of what is *left*. Raised in review 5072735803 on pull request #56, + where it was noted that the problem recurs throughout that file rather than at the one line cited. + [M34.1](COMPLETED-CHECKLIST.md#m341) is the worked example of the shape: `### ` in + the archive under a dated group, a stub with a completion link in its place. + Bookkeeping with no bearing on correctness, which is why it is queued rather than folded into a + branch already under review -- but it is 757 lines of checklist that a reader currently has to scan + past to find the open work, so it is not cosmetic either. + - [ ] **M34.2** -- **Route every tool's output through one sink, per the repository's own rule**: never call `println!`/`eprintln!` from more than one site in a tool; introduce a writer trait, sink or formatter at the first occurrence and route everything through it. Seven binaries violate this today diff --git a/crates/windows-placement-probe/src/bin/placement_probe.rs b/crates/windows-placement-probe/src/bin/placement_probe.rs index 18be5d71..3608fcc7 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe.rs @@ -22,6 +22,13 @@ struct Options { suppress_model: bool, /// Skip writing the backup file. no_file: bool, + /// Print the usage message and exit successfully. + /// + /// A separate field from a parse error on purpose: asking for help is a + /// request the tool can satisfy, not a mistake. Returned as Err it went + /// to stderr and exited non-zero, so placement-probe --help | less showed + /// nothing and any script running it reported failure. + help: bool, /// Print the build identity and exit. version: bool, } @@ -35,6 +42,12 @@ fn main() -> ExitCode { } }; + if options.help { + // stdout and success: help was asked for and was given. + println!("{}", help()); + return ExitCode::SUCCESS; + } + if options.version { // Deliberately the whole identity rather than just a version number. // CI asserts on this line that a released artifact reports itself @@ -195,6 +208,7 @@ fn parse_arguments() -> Result { suppress_model: false, no_file: false, version: false, + help: false, }; for argument in std::env::args().skip(1) { @@ -203,7 +217,7 @@ fn parse_arguments() -> Result { "--no-cpu-model" => options.suppress_model = true, "--no-file" => options.no_file = true, "--version" | "-V" => options.version = true, - "--help" | "-h" => return Err(help()), + "--help" | "-h" => options.help = true, other => { return Err(format!("unrecognised argument {other:?}\n\n{}", help())); } diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index 32425f39..e0182e8d 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -692,15 +692,46 @@ impl Observation { seen } - /// The measurement for one node pair and strategy, if it was taken. + /// Every measurement for one directed node pair and strategy. + /// + /// **Plural, and that is a correction rather than a preference.** A pair and + /// a strategy no longer identify one measurement: each hop is measured once + /// per ring placement, so there are two. The singular version of this + /// returned the first match, which silently discarded half the rows and + /// handed back whichever placement happened to be pushed first -- so a + /// caller comparing baseline against cached could unknowingly compare a + /// producer-local run with a consumer-local one. + /// + /// Ordered as measured, which is the producer's node first. #[must_use] - pub fn node_pair(&self, pair: (u32, u32), strategy: Strategy) -> Option { + pub fn node_pair_rows(&self, pair: (u32, u32), strategy: Strategy) -> Vec { self.by_node_pair .iter() - .find(|m| { + .filter(|m| { (m.producer.numa_node, m.consumer.numa_node) == pair && m.strategy == strategy }) .cloned() + .collect() + } + + /// The measurement for one node pair, strategy and ring placement. + /// + /// The full key. `memory_node` is what the singular lookup used to omit. + #[must_use] + pub fn node_pair( + &self, + pair: (u32, u32), + strategy: Strategy, + memory_node: Option, + ) -> Option { + self.by_node_pair + .iter() + .find(|m| { + (m.producer.numa_node, m.consumer.numa_node) == pair + && m.strategy == strategy + && m.memory_node == memory_node + }) + .cloned() } /// Which placements this machine could express. diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index 9da3cb20..9685574c 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -65,7 +65,7 @@ use windows_sys::Win32::System::Memory::{ use windows_sys::Win32::System::ProcessStatus::{ PSAPI_WORKING_SET_EX_INFORMATION, QueryWorkingSetEx, }; -use windows_sys::Win32::System::SystemInformation::GROUP_AFFINITY; +use windows_sys::Win32::System::SystemInformation::{GROUP_AFFINITY, GetSystemInfo, SYSTEM_INFO}; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetCurrentThread, SetThreadGroupAffinity, }; @@ -492,7 +492,7 @@ impl Slots { ptr: slots, len: capacity, origin: Origin::Numa, - node: observed_node(base), + node: observed_node_of_region(base, bytes), }) } } @@ -525,6 +525,51 @@ impl core::ops::Deref for Slots { } } +/// Which NUMA node an entire region is on, or `None` if it is not on one node. +/// +/// **A region is not a page, and this is the difference.** `VirtualAllocExNuma` +/// expresses a *preference*, and physical pages are drawn one at a time as they +/// fault; the ring is 8 KiB at the default capacity, so it spans several pages +/// and the node is only guaranteed uniform if it is checked to be. Asking about +/// the base address alone would describe the first page and label the whole +/// measurement with it. +/// +/// A region whose pages disagree has no single node, so it reports `None` +/// rather than picking one. That is the same rule the rest of this field +/// follows: unknown is a real answer, and a guess dressed as a measurement is +/// not. +fn observed_node_of_region(base: *mut c_void, bytes: usize) -> Option { + let page = page_size(); + let mut node = None; + let mut offset = 0; + while offset < bytes { + // SAFETY: `offset < bytes`, and the region spans `bytes` from `base`. + let page_node = observed_node(unsafe { base.byte_add(offset) })?; + match node { + None => node = Some(page_node), + Some(first) if first == page_node => {} + // Split across nodes. Not an error -- the ring works -- but it is + // not a measurement of a hop to any one node either. + Some(_) => return None, + } + offset += page; + } + node +} + +/// The system's page granularity, which is what a NUMA node is assigned by. +fn page_size() -> usize { + // SAFETY: writes a fully owned `SYSTEM_INFO`, which is plain data. + let mut info = unsafe { core::mem::zeroed::() }; + unsafe { GetSystemInfo(&raw mut info) }; + // Zero would loop forever below; the call cannot return it, but the loop + // must not depend on that. + match usize::try_from(info.dwPageSize) { + Ok(size) if size > 0 => size, + _ => 4096, + } +} + /// Which NUMA node the page at `address` is actually on. /// /// This is the difference between a record that reports a placement and one diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index c8f73d2a..9df07d0c 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -346,40 +346,50 @@ fn print_node_distances(observation: &Observation) { } println!("\n-- the handoff, by NUMA node pair --"); + // A ring-placement column, because a pair and a strategy no longer identify + // one row: every hop is measured once with the ring on the producer's node + // and once on the consumer's. Rendering one of them would drop half the + // measurements and, worse, could pair a baseline taken at one placement + // against a cached run taken at the other. println!( - "{:<14} {:>8} {:>8} {:>12} {:>12} {:>10}", - "prod -> cons", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" + "{:<14} {:>8} {:>8} {:>8} {:>12} {:>12} {:>10}", + "prod -> cons", "ring on", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" ); let mut slowest: Option<(f64, (u32, u32))> = None; let mut fastest: Option<(f64, (u32, u32))> = None; for pair in &pairs { - let (Some(base), Some(cached)) = ( - observation.node_pair(*pair, Strategy::Baseline), - observation.node_pair(*pair, Strategy::Cached), - ) else { - continue; - }; - println!( - "{:<14} {:>8} {:>8} {:>12.1} {:>12.1} {:>10.1}", - // `->`, not `<->`: hops are directed, because the producer writes - // and the consumer reads. The probe crate's own report was - // corrected for this and this second renderer of the same data was - // not, which is how two views of one measurement drift apart. - format!("{} -> {}", pair.0, pair.1), - format!("g{}/cpu{}", base.producer.group, base.producer.number), - format!("g{}/cpu{}", base.consumer.group, base.consumer.number), - base.nanos_per_item, - cached.nanos_per_item, - cached.consumer_batch - ); - let seen = (base.nanos_per_item, *pair); - if slowest.is_none_or(|(worst, _)| seen.0 > worst) { - slowest = Some(seen); - } - if fastest.is_none_or(|(best, _)| seen.0 < best) { - fastest = Some(seen); + for base in observation.node_pair_rows(*pair, Strategy::Baseline) { + // Matched on the ring placement as well, so the two columns + // describe the same configuration. + let Some(cached) = observation.node_pair(*pair, Strategy::Cached, base.memory_node) + else { + continue; + }; + println!( + "{:<14} {:>8} {:>8} {:>8} {:>12.1} {:>12.1} {:>10.1}", + // `->`, not `<->`: hops are directed, because the producer + // writes and the consumer reads. The probe crate's own report + // was corrected for this and this second renderer of the same + // data was not, which is how two views of one measurement drift + // apart. + format!("{} -> {}", pair.0, pair.1), + base.memory_node + .map_or_else(|| "unknown".to_owned(), |node| format!("node {node}")), + format!("g{}/cpu{}", base.producer.group, base.producer.number), + format!("g{}/cpu{}", base.consumer.group, base.consumer.number), + base.nanos_per_item, + cached.nanos_per_item, + cached.consumer_batch + ); + let seen = (base.nanos_per_item, *pair); + if slowest.is_none_or(|(worst, _)| seen.0 > worst) { + slowest = Some(seen); + } + if fastest.is_none_or(|(best, _)| seen.0 < best) { + fastest = Some(seen); + } } } diff --git a/crates/windows-platform-probes/src/bin/queue_contention.rs b/crates/windows-platform-probes/src/bin/queue_contention.rs index d770d3a0..28ee2a2e 100644 --- a/crates/windows-platform-probes/src/bin/queue_contention.rs +++ b/crates/windows-platform-probes/src/bin/queue_contention.rs @@ -10,7 +10,7 @@ //! linked and sharded MPSC shapes are ever needed, and whether `mpsc` and //! `reserving_mpsc` should merge. See `queue_contention`'s module docs. -use windows_platform_probes::queue_contention::{PRODUCER_COUNTS, Run, measure}; +use windows_platform_probes::queue_contention::{PRODUCER_COUNTS, Run, measure, shapes}; fn main() { windows_placement_probe::fingerprint::print_banner(); @@ -34,12 +34,14 @@ fn main() { println!(" 1. tail-claim contention (isolated regime)\n"); println!( " {:<18} {:>12} {:>12} {:>14}", - "producers", "mpsc x1thr", "reserving", "atomic floor" + "producers", "slotwise x1thr", "reserving", "atomic floor" ); for &producers in PRODUCER_COUNTS { - let mpsc = observation.scaling(&observation.isolated, "mpsc", producers); - let reserving = observation.scaling(&observation.isolated, "reserving_mpsc", producers); - let floor = observation.scaling(&observation.isolated, "baseline_fetch_add", producers); + let mpsc = observation.scaling(&observation.isolated, shapes::SLOTWISE_MPSC, producers); + let reserving = + observation.scaling(&observation.isolated, shapes::RESERVING_MPSC, producers); + let floor = + observation.scaling(&observation.isolated, shapes::BASELINE_FETCH_ADD, producers); println!( " {producers:<18} {:>12} {:>12} {:>14}", format_scaling(mpsc), @@ -57,11 +59,11 @@ fn main() { println!("\n 2. the price of reservation (drained regime, where `head` is written)\n"); println!( " {:<18} {:>14} {:>14} {:>10}", - "producers", "mpsc ns/push", "reserving", "ratio" + "producers", "slotwise ns/pu", "reserving", "ratio" ); for &producers in PRODUCER_COUNTS { - let plain = observation.find(&observation.drained, "mpsc", producers); - let reserving = observation.find(&observation.drained, "reserving_mpsc", producers); + let plain = observation.find(&observation.drained, shapes::SLOTWISE_MPSC, producers); + let reserving = observation.find(&observation.drained, shapes::RESERVING_MPSC, producers); let ratio = match (plain, reserving) { (Some(plain), Some(reserving)) if plain.nanos_per_push > 0.0 => { format!("{:.2}x", reserving.nanos_per_push / plain.nanos_per_push) diff --git a/crates/windows-platform-probes/src/queue_contention.rs b/crates/windows-platform-probes/src/queue_contention.rs index 1420d865..39f5de00 100644 --- a/crates/windows-platform-probes/src/queue_contention.rs +++ b/crates/windows-platform-probes/src/queue_contention.rs @@ -51,7 +51,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::thread; use std::time::Instant; -use windows_waitable_queues::{Options, reserving_mpsc, slotwise_mpsc}; +use windows_waitable_queues::{reserving_mpsc, slotwise_mpsc}; /// How many pushes each producer thread performs in one timed run. const PUSHES_PER_PRODUCER: usize = 50_000; @@ -70,6 +70,23 @@ const REPETITIONS: usize = 5; /// alongside, since the interesting region is around and beyond it. pub const PRODUCER_COUNTS: &[usize] = &[1, 2, 4, 8, 16, 32]; +/// The names a run is filed under. +/// +/// **Named once because a lookup by string literal is a rename waiting to +/// fail, and this one already did.** The `mpsc` -> `slotwise_mpsc` rename +/// updated the recording side and not the reporting binary, which went on +/// asking for `"mpsc"`; every lookup returned `None` and two entire columns of +/// the report rendered as `--` without anything erroring. A wrong shape name is +/// not a compile error, so the only defence is that both sides read the same +/// definition. +pub mod shapes { + /// The bounded-array MPSC. + pub const SLOTWISE_MPSC: &str = "slotwise_mpsc"; + /// The reservation-based MPSC. + pub const RESERVING_MPSC: &str = "reserving_mpsc"; + /// The uncontended-atomic floor the queues are measured against. + pub const BASELINE_FETCH_ADD: &str = "baseline_fetch_add"; +} /// One configuration's result. #[derive(Debug, Clone, Copy, PartialEq)] pub struct Run { @@ -130,20 +147,20 @@ pub fn measure() -> Observation { let mut drained = Vec::new(); for &producers in PRODUCER_COUNTS { - isolated.push(median_run("baseline_fetch_add", producers, |count| { + isolated.push(median_run(shapes::BASELINE_FETCH_ADD, producers, |count| { time_contended_atomic(count) })); - isolated.push(median_run("slotwise_mpsc", producers, |count| { + isolated.push(median_run(shapes::SLOTWISE_MPSC, producers, |count| { time_isolated_mpsc(count) })); - isolated.push(median_run("reserving_mpsc", producers, |count| { + isolated.push(median_run(shapes::RESERVING_MPSC, producers, |count| { time_isolated_reserving(count) })); - drained.push(median_run("slotwise_mpsc", producers, |count| { + drained.push(median_run(shapes::SLOTWISE_MPSC, producers, |count| { time_drained_mpsc(count) })); - drained.push(median_run("reserving_mpsc", producers, |count| { + drained.push(median_run(shapes::RESERVING_MPSC, producers, |count| { time_drained_reserving(count) })); } @@ -303,12 +320,20 @@ fn time_drained_mpsc(producers: usize) -> Repetition { } fn time_drained_reserving(producers: usize) -> Repetition { - let (tx, rx) = reserving_mpsc::bounded_with::( - DRAINED_CAPACITY, - // Tracking on, so this row also prices the switch M31.4 made opt-in. - Options::new().tracking_high_water(), - ) - .expect("a valid capacity"); + // **Defaults on both sides, and that is a correction.** This row previously + // enabled high-water tracking here and nowhere else, to "also price the + // switch M31.4 made opt-in". But the number it feeds is presented as the + // cost of *reservation*, and tracking adds an unrelated operation to this + // shape's push path alone -- a load of the consumer's position, which is + // exactly the shared line the other shape's push is built to avoid + // touching. The ratio therefore measured reservation plus a handicap, with + // no way for a reader to separate them. + // + // Nothing consumes the high-water figure here either, so the tracking was + // paying a cost to produce a number nobody read. Pricing that switch is a + // worthwhile measurement and needs its own row, with both shapes tracking, + // rather than being folded into this comparison. + let (tx, rx) = reserving_mpsc::bounded::(DRAINED_CAPACITY).expect("a valid capacity"); let done = Arc::new(AtomicBool::new(false)); let consumer_done = Arc::clone(&done); From c95d58fd6c01bb636046b7bd1ee8a5ed10418cef Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 20:29:45 -0400 Subject: [PATCH 091/361] fix(ci): build ARM64 when a dependency changes, and make file references clickable Four further findings from review 5072735803. **The ARM64 build could be skipped for the changes most likely to break it.** This workflow is the only place in the repository that builds `aarch64-pc-windows-msvc`, and its pull-request filter listed only this crate's own directory. But `windows-placement-probe` compiles both path dependencies into itself, so a change to `windows-topology-sys` or `windows-waitable-queues` was exercised on x64 by `ci.yml` and on no other architecture at all -- and an architecture-specific regression would first appear after a release tag was pushed, turning a build failure into a broken release. That is precisely what the pull-request trigger exists to prevent, so the filter now includes both dependencies, the workspace manifest and the toolchain pin. **Twenty repository-file references were inline code rather than links**, across the two new checklists and this crate's design notes -- in documents whose neighbouring references already used the relative-link form, so they were inconsistent with themselves as well as with the convention. One of them could not simply be linked: `CHECKLIST-io-domains.md` recorded work "done as `src/mpsc.rs`", and that file no longer exists because the rename made it `slotwise_mpsc.rs`. Linking the old path would have created a broken link, and silently renaming the record would have falsified what was done at the time, so the sentence now keeps the original name and points at where the code lives today. Every relative link in the affected documents is verified to resolve: 189 checked, none broken. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-placement-probe.yml | 13 +++++++++++ CHECKLIST-io-domains.md | 23 +++++++++++-------- CHECKLIST-placement-tool.md | 4 ++-- .../windows-waitable-queues/DESIGN-NOTES.md | 6 ++--- 4 files changed, 31 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release-placement-probe.yml b/.github/workflows/release-placement-probe.yml index 84f25fb5..c1f68c82 100644 --- a/.github/workflows/release-placement-probe.yml +++ b/.github/workflows/release-placement-probe.yml @@ -26,10 +26,23 @@ on: # builds. The release job below is guarded on the tag ref, so nothing is # published from a pull request no matter what runs here. pull_request: + # **The dependencies are in this filter for a reason specific to ARM64.** + # This is the only workflow in the repository that builds + # `aarch64-pc-windows-msvc`, and `windows-placement-probe` compiles both + # path dependencies into itself. Filtering on this crate's own directory + # alone meant a change to either dependency was exercised on x64 by `ci.yml` + # and on no other architecture at all, so an architecture-specific + # regression in one of them would first surface *after* a release tag was + # pushed -- turning a build failure into a broken release, which is exactly + # what the pull-request trigger exists to prevent. paths: - 'crates/windows-placement-probe/**' + - 'crates/windows-topology-sys/**' + - 'crates/windows-waitable-queues/**' - '.github/workflows/release-placement-probe.yml' + - 'Cargo.toml' - 'Cargo.lock' + - 'rust-toolchain.toml' # Kept for a re-run after merge, when the file does live on the default # branch. Inherently build-and-verify-only for the same reason as above: an # earlier revision declared a `dry_run` input and never read it, which would diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 2a3560cd..818472fe 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -54,7 +54,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m and the runtime's name may carry `io` because that crate genuinely is about I/O. - [x] **M30.2** -- Create the crate with `publish = true` (the engineer's decision: this is general-purpose - and worth publishing, unlike `windows-guard-alloc`), and write its `DESIGN-NOTES.md` with the decisions + and worth publishing, unlike `windows-guard-alloc`), and write its [DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) with the decisions the session already reached: the shape menu and which shapes ship now, the concrete-types-plus-optional-trait rule, the overflow policy, and the doorbell invariant. This is the Tier-1 transcription of Tier-3 session content -- design notes are not a work queue, so a decision that @@ -77,8 +77,10 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m a trait written against one type designs in a vacuum, since every signature that type happens to have looks like a requirement. The trait *shape* is fixed now because it constrains M30.3; the traits themselves land with M31.1. - Crate created with `DESIGN-NOTES.md` (D-1..D-8), `README.md`, `PLANS.md` pointing back at this file, and - registration in the workspace members, `release-please-config.json`, and `.release-please-manifest.json` + Crate created with [DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) (D-1..D-8), + [README.md](crates/windows-waitable-queues/README.md), [PLANS.md](PLANS.md) pointing back at this file, and + registration in the workspace members, [release-please-config.json](release-please-config.json), and + [.release-please-manifest.json](.release-please-manifest.json) -- the last two because `publish = true` makes it release-managed, and omitting them would have left it silently unreleasable. **One earlier position reversed with its reason recorded** ([D-7](crates/windows-waitable-queues/DESIGN-NOTES.md#d-7)): @@ -99,7 +101,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m match, the `WaitableQueue` trait becomes a breaking change to one of them rather than an addition. Verify it the cheap way: write the trait's method signatures down as a comment before writing the type, and confirm the type satisfies them. - **Done, and the signatures are written down in `spsc.rs`'s module documentation before the type**, as + **Done, and the signatures are written down in [spsc.rs](crates/windows-waitable-queues/src/spsc.rs)'s module documentation before the type**, as the item asked. `push`/`pop` take **`&self`**, not `&mut self`: the latter would also make single-producer sound and is what several SPSC crates use, but it cannot generalize to a shape where several threads push through a shared handle, and one spelling has to serve every shape. @@ -136,7 +138,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m builds with `-D warnings`, so M30.4 cannot compile on its own. Recorded as an acknowledged structuring defect rather than worked around by widening the type's visibility to silence the lint -- making an API public to dodge a warning is a real design decision taken for a fake reason. - Delivered as `src/doorbell.rs`: lazily created (a poll-only consumer allocates no kernel object, + Delivered as [src/doorbell.rs](crates/windows-waitable-queues/src/doorbell.rs): lazily created (a poll-only consumer allocates no kernel object, asserted, not assumed), manual-reset, with `handle` / `owned` / `signal` / `clear`. The redundant signal is skipped through an `AtomicBool` mirroring the event, which is sound in exactly one direction -- see the done-note on M30.5 for the asymmetry that permits it. @@ -183,7 +185,8 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m forward, writes, then publishes by storing the slot's sequence. Lock-free rather than wait-free, bounded by construction so backpressure is free, and no allocation anywhere. Pad the head and tail onto separate cache lines and say so in a comment, because the padding is load-bearing and looks like waste. - **Done as `src/mpsc.rs`**, with the padding commented at *both* positions rather than once, since a + **Done as `src/mpsc.rs`, since renamed to + [slotwise_mpsc.rs](crates/windows-waitable-queues/src/slotwise_mpsc.rs)**, with the padding commented at *both* positions rather than once, since a reader arriving at either field is the one who might delete it. Recorded as [D-10](crates/windows-waitable-queues/DESIGN-NOTES.md#d-10). **The traits landed here too, because M30.2 scheduled them here** ("the traits themselves land with @@ -206,8 +209,8 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m loop *is* the arming protocol (D-9), not glue around it, so a second spelling of it would have been a second copy of a rule -- the exact mistake M30.5 already paid for, where a lost-wakeup proof exercised a hand-written duplicate of `arm` and could not have noticed the real `arm` being reversed. It now lives - in `blocking.rs` with shapes binding to it, and the `ARM_RACE` hook is shared for the same reason - ([D-13](crates/windows-waitable-queues/DESIGN-NOTES.md#d-13)). The capacity rule moved to `capacity.rs` + in [blocking.rs](crates/windows-waitable-queues/src/blocking.rs) with shapes binding to it, and the `ARM_RACE` hook is shared for the same reason + ([D-13](crates/windows-waitable-queues/DESIGN-NOTES.md#d-13)). The capacity rule moved to [capacity.rs](crates/windows-waitable-queues/src/capacity.rs) on the weaker version of the same argument. **One question the checklist did not anticipate: what "empty" means for arming.** `len` and "would `pop` find something" disagree over a slot a producer has claimed but not published, and arming on `len` is @@ -355,7 +358,7 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m `bounded_with_disposal`. As constructors that is four per shape and twelve in the crate, with every future switch doubling it. The crate is unreleased, so the replacement cost nothing. **One consequence is worth naming because it inverts something already written down** - ([D-24](crates/windows-waitable-queues/DESIGN-NOTES.md#d-24)). `sabotage.json` carried a *control* that + ([D-24](crates/windows-waitable-queues/DESIGN-NOTES.md#d-24)). [sabotage.json](crates/windows-waitable-queues/sabotage.json) carried a *control* that removed the skip optimisation expecting `survives` -- and it had earned its place, by proving the suite asserted the contract rather than the implementation. Counting the rings makes the skip observable, so the same patch now has to be **caught**, and the entry changed sides. That is R9 working rather than a @@ -564,7 +567,7 @@ the runtime's shape, so all three land before M33+ begins. > **-> CROSS-COMPONENT HANDOFF:** M33+ below spans `crates/windows-thread-ambient-sys`, > `crates/windows-namespace-request-sys`, and `crates/windows-ioring-sys`. Each has its own -> `CHECKLIST.md`; the items are held here until M32 settles, then move to the component that owns them. +> [CHECKLIST.md](CHECKLIST.md); the items are held here until M32 settles, then move to the component that owns them. ## M33+ -- The domain runtime (gated on M32) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index dcbd678f..72543d5a 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -285,7 +285,7 @@ that carries them is written. tree, an unknown commit -- must be visibly marked so a result that arrives from one is not silently pooled with the rest. Default to the untrusted reading when the answer cannot be established, for the same reason `Provenance::Synthetic` is `Default`: forgetting must be safe. - A `build.rs` reads the commit from an environment variable when CI sets one, falls back to `git` + A [build.rs](crates/windows-placement-probe/build.rs) reads the commit from an environment variable when CI sets one, falls back to `git` when there is a repository, and records *unknown* otherwise -- which is exactly what a `cargo install` from a crates.io tarball will produce, and is the honest answer there. @@ -429,7 +429,7 @@ build" distinction meaningful rather than decorative. **The pull request verifies it, which is better than the dispatch this originally called for.** The workflow now also triggers on a pull request touching the tool, building and verifying both targets without releasing. Two things made that the right answer rather than a convenience: - - **Nothing else in this repository builds the ARM64 target.** `ci.yml` cross-compiles only + - **Nothing else in this repository builds the ARM64 target.** [ci.yml](.github/workflows/ci.yml) cross-compiles only `thumbv7em` for `wtf-string`, so without this a tag would be the first time `aarch64` was ever attempted -- turning a build failure into a broken release. - **`workflow_dispatch` could not have done it.** GitHub only offers dispatch for workflows already diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index fd997395..bae13209 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -36,7 +36,7 @@ preferred. | D-10 | **The multi-producer shape is Vyukov's bounded array queue: a sequence number per slot, claimed by a compare-and-swap on the tail and published by a release store.** The sequence is what lets the consumer tell a *claimed* slot from a *written* one, which a plain fetch-and-add cannot. Lock-free rather than wait-free, bounded by construction, and no allocation after the constructor. | | D-11 | **The capability traits shipped with this second shape, and the signatures `spsc` wrote down in advance held unchanged.** That is [D-3](#d-3)'s check actually being run rather than assumed. The load-bearing choice was `push(&self)`: `&mut self` would have been sound for one producer and would have made the trait unimplementable by this one. | | D-12 | **A shape's *minimum* capacity belongs to the shape, not to the crate, and `slotwise_mpsc`'s is two.** One slot cannot encode three states when the lap stride is the capacity, so "published at `p`" and "free again at `p + capacity`" collide. Reported through `CapacityError` rather than worked around, because every available workaround puts a load back on the producer's hot path for every queue in order to serve a capacity of one. | -| D-13 | **The arming protocol is written once, in `blocking.rs`, and a shape binds to it by implementing a crate-private `Parked` trait.** The blocking receive loop *is* [D-9](#d-9), not glue around it; a second shape spelling it out again would be a second copy of a rule -- the exact mistake this crate has already paid for once. | +| D-13 | **The arming protocol is written once, in [`blocking.rs`](src/blocking.rs), and a shape binds to it by implementing a crate-private `Parked` trait.** The blocking receive loop *is* [D-9](#d-9), not glue around it; a second shape spelling it out again would be a second copy of a rule -- the exact mistake this crate has already paid for once. | | D-14 | **`slotwise_mpsc`'s arming asks "would `pop` find something", not "is `len` zero".** The two disagree over a slot a producer has claimed but not published, and only the first answer lets the consumer park on it instead of spinning until that producer is rescheduled. | | D-15 | **`Doorbell::clear` resets the event *before* clearing the flag that mirrors it, and the original order was a lost wakeup.** A producer signalling between the two lines set the flag and issued a real `SetEvent`; the `ResetEvent` that followed erased the signal and left the flag set, wedging the doorbell dark while it claimed to be lit. **Amends [D-9](#d-9)**, whose "there is no third case" holds only for a queue whose emptiness is one position comparison. | | D-16 | **Its cost premise is falsified by [D-26](#d-26); the conclusion stands on capability instead -- see [D-29](#d-29).** Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `slotwise_mpsc` rather than replacing it. Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `slotwise_mpsc`'s push deliberately never reads. The original rationale added that this made reserving the *more expensive* shape and that both should ship rather than charge every caller for it; measurement reversed that, and the split is now justified by the capability alone. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. | @@ -231,7 +231,7 @@ code generator plus a particular processor -- rather than to the ordering primit trap that rule exists to name. **No test can catch this, and that is a property of the hazard.** Removing either fence leaves the -whole suite green, and no entry in `sabotage.json` can express it, because the defect is a fact about +whole suite green, and no entry in [`sabotage.json`](sabotage.json) can express it, because the defect is a fact about the memory model rather than an interleaving a scheduler can be coaxed into producing. It is the named target of the `loom` work in [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) item M31.6. @@ -737,7 +737,7 @@ regardless -- which is asserted by a concurrent test rather than argued. R9 asks for the ring count so that "disabling the skip must change the number". Following that literally has a consequence worth naming, because it inverts something this crate had already written down. -`sabotage.json` carried an entry that removed the skip optimisation, expecting **`survives`**. It was a +[`sabotage.json`](sabotage.json) carried an entry that removed the skip optimisation, expecting **`survives`**. It was a *control*: skipping a redundant `SetEvent` changed no observable behaviour, so a suite that went red on its removal would have been asserting the implementation instead of the contract -- and [D-9](#d-9) records that the control earned its place by proving exactly that. From e135469f8ae93dae15b1494a6ab678d50b577434 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 21:01:48 -0400 Subject: [PATCH 092/361] fix(placement-probe): the backup file could destroy the record it was backing up Seven findings from review 5072856310, three of them in code added in the last few hours. **A guarantee the code did not keep.** `file_name` documents that the timestamp means a second run does not overwrite a first, but the stamp has one-second resolution and `write_backup` used `fs::write`, which truncates. A run finishes in well under a second, so two of them could land in the same second and the second would silently destroy the first -- worst for exactly the person most likely to hit it, someone re-running the tool because they doubted the first result. Now `File::create_new`, which refuses rather than truncates, with a numbered suffix before the extension so the pattern that git-ignores these still matches. That path is unreachable by running the tool twice from a shell -- process startup alone puts the runs in different seconds -- which is why it survived. It is now tested directly, and testing it required moving the binary to the `src/bin/placement_probe/main.rs` layout: a bin root is a crate root, so `mod tests;` resolved against `src/bin/` and could not see its own sibling. Sabotage confirms four of the five tests fail if the truncating write returns. **A test about `usize` that could not build on a 32-bit `usize`.** The capacity bounds tests, added an hour earlier, hard-coded `1_usize << 63`. The shifts are now derived from `usize::BITS`, which is what the bound itself is expressed in. **A policy could report zero domains.** `domain_counts` subtracted memoryless NUMA domains from all of them; a host reporting no NUMA relationships has both at zero, yielding zero domains, while the execution-domain contract requires every policy to yield at least one -- and the cache policy beside it already clamped. Also made saturating: these are two independent counts from the operating system, and an unsigned subtraction that trusts their relationship panics rather than reports. **A documented average that could be `NaN`.** `measure_park_and_wake(0)` ran no handshake and divided zero elapsed nanoseconds by zero rounds, returning `Some(NaN)` from an API documenting `Some` as meaningful. An empty sample now returns `None`. **A probe that assumed Windows is on `C:`.** `request_cost` opened a literal `C:\Windows\System32\kernel32.dll` and panicked on a valid installation elsewhere. Now resolved once with `GetSystemDirectoryW` and used for both the prepared request and the real open, so the two cannot diverge. **A stale rationale on `RunPlan`**, which the directed-hop work invalidated: it still described the hop matrix as growing `n*(n-1)/2`, understating the dominant term by a factor of four once both directions and both ring placements are counted -- 6 rather than 24 on a four-node host. The same comment also still promised a "range" after the estimate became an upper bound. **A security contract described as someone else's.** `with_applied` said its panic-on-failed-restore was inherited from `windows_impersonation_token_sys` and "not chosen here", which leaves a security property of a public API resting on a dependency's implementation detail -- the framing this repository's design autonomy rule exists to forbid. The guarantee is now stated as this crate's own, with the dependency named as satisfying it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/Cargo.toml | 2 +- .../main.rs} | 66 ++++++++-- .../src/bin/placement_probe/tests.rs | 123 ++++++++++++++++++ .../src/core_affinity.rs | 12 +- crates/windows-platform-probes/Cargo.toml | 3 + .../src/doorbell_cost.rs | 12 +- .../src/request_cost.rs | 31 ++++- .../windows-platform-probes/src/topology.rs | 12 +- .../windows-thread-ambient-sys/src/state.rs | 21 ++- .../src/capacity/tests.rs | 39 ++++-- 10 files changed, 286 insertions(+), 35 deletions(-) rename crates/windows-placement-probe/src/bin/{placement_probe.rs => placement_probe/main.rs} (77%) create mode 100644 crates/windows-placement-probe/src/bin/placement_probe/tests.rs diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index 376ff490..86df0e06 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -40,7 +40,7 @@ path = "src/lib.rs" # stay separate because a development loop wants the opposite. [[bin]] name = "placement-probe" -path = "src/bin/placement_probe.rs" +path = "src/bin/placement_probe/main.rs" [features] # The submission record is the product, so serialization is not optional the way diff --git a/crates/windows-placement-probe/src/bin/placement_probe.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs similarity index 77% rename from crates/windows-placement-probe/src/bin/placement_probe.rs rename to crates/windows-placement-probe/src/bin/placement_probe/main.rs index 3608fcc7..9f4d84e2 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -7,6 +7,9 @@ use std::process::ExitCode; +#[cfg(test)] +mod tests; + use windows_placement_probe::build_identity::BuildIdentity; use windows_placement_probe::core_affinity::{self, RunPlan}; use windows_placement_probe::fingerprint::{Fingerprint, discover_places}; @@ -188,18 +191,65 @@ fn print_plan(plan: &RunPlan) { /// A failure here is reported and does not fail the run: the submission is the /// text on screen, and losing the backup copy costs nothing that matters. fn write_backup(record: &SubmissionRecord) { - let name = submission::file_name(record); // The same layout as the printed record, so the backup a runner attaches // and the text they paste are byte-identical. - match windows_placement_probe::paste_json::to_paste_json(record) { - Ok(json) => match std::fs::write(&name, json) { - Ok(()) => println!("(a copy of the record was also written to {name})"), - Err(error) => { - println!("(could not write {name}: {error} -- paste the text below instead)") + let json = match windows_placement_probe::paste_json::to_paste_json(record) { + Ok(json) => json, + Err(error) => { + println!("(could not serialize the record to a file: {error})"); + return; + } + }; + + match write_backup_to_new_file(&submission::file_name(record), &json) { + Ok(name) => println!("(a copy of the record was also written to {name})"), + Err(error) => println!("(could not write the backup: {error} -- paste the text below)"), + } +} + +/// Write `json` to a file that did not already exist, and return its name. +/// +/// **`create_new`, not `write`, and this is a correction.** The name carries a +/// timestamp so that a second run does not overwrite a first, but the stamp has +/// one-second resolution and `fs::write` truncates whatever it finds. A run +/// takes well under a second on a small machine, so two of them could land in +/// the same second and the second would silently destroy the first -- exactly +/// the loss the naming scheme promised to prevent, and worst for someone +/// re-running the tool because they were unsure the first result was good. +/// +/// Exclusive creation makes the collision visible instead of silent, and a +/// suffix resolves it. The suffix is only reached on a real collision, so the +/// ordinary name stays the predictable one. +fn write_backup_to_new_file(name: &str, json: &str) -> std::io::Result { + /// Enough to outlast any plausible burst of same-second runs; past this, + /// failing is better than looping while a caller waits. + const MAX_ATTEMPTS: u32 = 100; + + for attempt in 0..MAX_ATTEMPTS { + let candidate = if attempt == 0 { + name.to_owned() + } else { + match name.strip_suffix(".json") { + Some(stem) => format!("{stem}-{attempt}.json"), + None => format!("{name}-{attempt}"), } - }, - Err(error) => println!("(could not serialize the record to a file: {error})"), + }; + + match std::fs::File::create_new(&candidate) { + Ok(mut file) => { + std::io::Write::write_all(&mut file, json.as_bytes())?; + return Ok(candidate); + } + // Someone else has this name. Not a failure yet: try the next. + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(error) => return Err(error), + } } + + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("{MAX_ATTEMPTS} names starting from {name} were all taken"), + )) } fn parse_arguments() -> Result { diff --git a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs new file mode 100644 index 00000000..d7b4d9a3 --- /dev/null +++ b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs @@ -0,0 +1,123 @@ +// Copyright (c) Mike Grier. + +//! Tests for the backup file's overwrite protection. +//! +//! The collision this guards is not reachable by running the tool twice from a +//! shell -- process startup alone puts the two runs in different seconds -- so +//! it has to be tested directly. That is exactly why it survived: the failing +//! case is the one nobody trips over by hand. + +use std::io::Write as _; + +use super::write_backup_to_new_file; + +/// A directory of this test's own, so a failure cannot be caused by, or blamed +/// on, another test's files. +fn scratch(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("placement-probe-backup-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a scratch directory"); + dir +} + +#[test] +fn the_first_write_uses_the_name_it_was_given() { + let dir = scratch("first"); + let name = dir.join("record.json"); + + let written = + write_backup_to_new_file(name.to_str().expect("utf-8 path"), "{}").expect("must write"); + + assert_eq!(written, name.to_str().expect("utf-8 path")); + assert_eq!(std::fs::read_to_string(&name).expect("readable"), "{}"); +} + +#[test] +fn a_second_write_in_the_same_second_does_not_destroy_the_first() { + // The defect. The name carries a one-second timestamp and the previous + // implementation used `fs::write`, which truncates -- so two runs landing + // in the same second silently lost the first result, which is the worst + // case for someone re-running the tool because they doubted the first. + let dir = scratch("collision"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + let first = write_backup_to_new_file(name, "FIRST").expect("must write"); + let second = write_backup_to_new_file(name, "SECOND").expect("must write"); + + assert_ne!(first, second, "the second write reused the first name"); + assert_eq!( + std::fs::read_to_string(&first).expect("readable"), + "FIRST", + "the first record was overwritten" + ); + assert_eq!( + std::fs::read_to_string(&second).expect("readable"), + "SECOND" + ); +} + +#[test] +fn the_suffix_goes_before_the_extension() { + // So a collection of records still sorts and filters as `*.json`, and the + // gitignore pattern that keeps these out of commits keeps matching. + let dir = scratch("suffix"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + write_backup_to_new_file(name, "a").expect("must write"); + let second = write_backup_to_new_file(name, "b").expect("must write"); + + assert!(second.ends_with(".json"), "got {second}"); + assert!(second.contains("record-1"), "got {second}"); +} + +#[test] +fn many_collisions_keep_producing_distinct_files() { + // Each retry must advance rather than fight over one alternative name. + let dir = scratch("many"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + let mut written = Vec::new(); + for index in 0..10 { + written.push(write_backup_to_new_file(name, &index.to_string()).expect("must write")); + } + + let mut unique = written.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), written.len(), "names repeated: {written:?}"); + + for (index, path) in written.iter().enumerate() { + assert_eq!( + std::fs::read_to_string(path).expect("readable"), + index.to_string(), + "{path} does not hold what was written to it" + ); + } +} + +#[test] +fn a_write_that_cannot_be_placed_reports_rather_than_loops() { + // The exhaustion path. A caller waiting on the tool must not wait forever, + // and the error has to name the problem rather than surface as a mystery. + let dir = scratch("exhausted"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + // Occupy every name the helper will try. + for attempt in 0..100 { + let candidate = if attempt == 0 { + name.to_owned() + } else { + format!("{}-{attempt}.json", name.trim_end_matches(".json")) + }; + let mut file = std::fs::File::create(&candidate).expect("a placeholder"); + file.write_all(b"taken").expect("writable"); + } + + let error = write_backup_to_new_file(name, "{}").expect_err("every name is taken"); + + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); +} diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index e0182e8d..d61c8b7f 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -127,10 +127,14 @@ fn efficiency_classes(places: &[ProcessorPlace]) -> Vec { /// # Why this is computed rather than estimated /// /// A person is being asked to give up minutes of their machine as a favour, and -/// on a large multi-socket host the hop matrix alone grows as `n*(n-1)/2`. The -/// *counts* here are exact -- they come from the same selection the run will -/// use -- so only the per-run duration is approximate, and it is presented as a -/// range rather than a single confident number. +/// on a large multi-socket host the hop work alone grows as `2*n*(n-1)`: every +/// *ordered* pair of nodes, because a hop is directed, and each of those +/// measured at both ring placements. An earlier version of this note said +/// `n*(n-1)/2`, the undirected count, which understated the dominant term by a +/// factor of four -- 6 hops rather than 24 on a four-node host. +/// The *counts* here are exact -- they come from the same selection the run +/// will use -- so only the per-run duration is approximate, and it is presented +/// as an upper bound rather than a single confident number. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct RunPlan { /// Placements this machine can express. diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index db17613e..82c319b5 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -101,6 +101,9 @@ features = [ "Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", + # GetSystemDirectoryW, so the request probe measures the real system + # directory instead of assuming Windows is installed on C:. + "Win32_System_SystemInformation", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Pipes", diff --git a/crates/windows-platform-probes/src/doorbell_cost.rs b/crates/windows-platform-probes/src/doorbell_cost.rs index 2ab7ab33..da0322ab 100644 --- a/crates/windows-platform-probes/src/doorbell_cost.rs +++ b/crates/windows-platform-probes/src/doorbell_cost.rs @@ -215,11 +215,21 @@ pub fn measure() -> Observation { /// timeout turns it into a reported anomaly instead. /// /// Returns `None` if the handshake ever timed out, because a partial run's -/// average would be meaningless. +/// average would be meaningless -- and for the same reason if `rounds` is zero, +/// which has no average at all rather than an average of nothing. #[must_use] pub fn measure_park_and_wake(rounds: u32) -> Option { const WAIT_TIMEOUT_MS: u32 = 5_000; + // An empty sample has no average, and the arithmetic below would not say + // so: no round runs, so the elapsed time is zero, and `0.0 / 0.0` is `NaN` + // wrapped in the `Some` this function documents as a meaningful number. A + // caller comparing that against a threshold gets `false` from every + // comparison and no indication why. + if rounds == 0 { + return None; + } + // SAFETY: two auto-reset, initially-unsignalled, unnamed events. let ping: HANDLE = unsafe { CreateEventW(std::ptr::null(), 0, 0, std::ptr::null()) }; let pong: HANDLE = unsafe { CreateEventW(std::ptr::null(), 0, 0, std::ptr::null()) }; diff --git a/crates/windows-platform-probes/src/request_cost.rs b/crates/windows-platform-probes/src/request_cost.rs index 4e158765..01fb9b9d 100644 --- a/crates/windows-platform-probes/src/request_cost.rs +++ b/crates/windows-platform-probes/src/request_cost.rs @@ -74,6 +74,7 @@ use wtf_string::Wtf16String; use windows_namespace_request_sys::{CapturedHandle, OpenFile, prepare}; use windows_sys::Win32::Foundation::GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, OPEN_EXISTING}; +use windows_sys::Win32::System::SystemInformation::GetSystemDirectoryW; /// Nanoseconds per operation for one timed loop. #[derive(Debug, Clone, Copy, PartialEq)] @@ -133,7 +134,16 @@ pub fn measure() -> Observation { const ITERATIONS: u32 = 100_000; const HANDLE_ITERATIONS: u32 = 50_000; - let short = Wtf16String::from(r"C:\Windows\System32\kernel32.dll"); + // Resolved, not assumed. Windows is not always on `C:` -- a valid + // installation can sit on any volume -- and hard-coding it made this probe + // panic on such a machine rather than measure it. The same path is used for + // the prepared request and the real open below, so the two stay consistent. + let system_dll = system_directory().join("kernel32.dll"); + let short = Wtf16String::from( + system_dll + .to_str() + .expect("the system directory is representable"), + ); let long_text = format!(r"C:\{}\file.txt", vec!["directory"; 24].join("\\")); let long = Wtf16String::from(long_text.as_str()); @@ -169,8 +179,7 @@ pub fn measure() -> Observation { // The kernel transition a captured handle costs. Measured against a handle // this process already owns, so nothing here depends on the filesystem. - let file = - std::fs::File::open(r"C:\Windows\System32\kernel32.dll").expect("kernel32.dll is readable"); + let file = std::fs::File::open(&system_dll).expect("kernel32.dll is readable"); let borrowed = std::os::windows::io::AsHandle::as_handle(&file); timings.push(time_loop("capture_handle", HANDLE_ITERATIONS, || { CapturedHandle::capture(borrowed).expect("duplicating an owned handle") @@ -178,3 +187,19 @@ pub fn measure() -> Observation { Observation { timings } } + +/// Where Windows is actually installed, rather than where it usually is. +/// +/// Falls back to the conventional path only when the system will not say, which +/// keeps the probe running on a machine that answers and keeps the failure +/// visible on one that does not. +fn system_directory() -> std::path::PathBuf { + let mut buffer = [0_u16; 260]; + // SAFETY: writes at most `buffer.len()` units into a buffer of that size. + let written = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; + let written = written as usize; + if written == 0 || written > buffer.len() { + return std::path::PathBuf::from(r"C:\Windows\System32"); + } + std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..written])) +} diff --git a/crates/windows-platform-probes/src/topology.rs b/crates/windows-platform-probes/src/topology.rs index 880792b7..61e173d5 100644 --- a/crates/windows-platform-probes/src/topology.rs +++ b/crates/windows-platform-probes/src/topology.rs @@ -125,7 +125,17 @@ impl Observation { ("by-package", self.packages), ( "by-numa-domain-with-processors", - self.numa_domains - self.memoryless_numa_domains, + // Clamped to one, as the cache policy beside it already is. A + // host that reports no NUMA relationships leaves both counters + // at zero, and a policy that yields zero domains contradicts + // the execution-domain contract: there is always at least one + // domain, because the machine exists. Saturating for the same + // reason -- these are two independent counts from the + // operating system, and an unsigned subtraction that trusts + // their relationship would panic rather than report. + self.numa_domains + .saturating_sub(self.memoryless_numa_domains) + .max(1), ), ( "by-outermost-partitioning-cache", diff --git a/crates/windows-thread-ambient-sys/src/state.rs b/crates/windows-thread-ambient-sys/src/state.rs index e604c28d..9a33072f 100644 --- a/crates/windows-thread-ambient-sys/src/state.rs +++ b/crates/windows-thread-ambient-sys/src/state.rs @@ -328,12 +328,21 @@ impl AmbientState { /// /// # Panics /// - /// Panics if the impersonation context cannot be restored. That semantics is - /// inherited from - /// [`windows_impersonation_token_sys`], - /// not chosen here: returning a shared worker to a pool under an unknown - /// identity is a process-wide security failure, which is a different order - /// of hazard from the other aspects. + /// **Panics if the impersonation context cannot be restored, and that is + /// this crate's guarantee rather than a detail of its dependencies.** + /// Returning a shared worker to a pool under an unknown identity is a + /// process-wide security failure: every later task on that thread would run + /// as whoever the failed restore left behind, and no caller could detect it + /// from a returned error. Failing fast is the only response that cannot be + /// ignored, which is a different order of hazard from the other aspects + /// here, and they are reported rather than fatal. + /// + /// [`windows_impersonation_token_sys`] is used because its behaviour + /// already satisfies that guarantee. If it ever stopped doing so, the + /// dependency would be wrong and this contract would not change -- an + /// earlier version of this note described the semantics as *inherited* from + /// that crate and "not chosen here", which left a security property of this + /// public API resting on someone else's implementation detail. pub fn with_applied(&self, operation: F) -> Result, ApplyError> where F: FnOnce() -> T, diff --git a/crates/windows-waitable-queues/src/capacity/tests.rs b/crates/windows-waitable-queues/src/capacity/tests.rs index d99d4939..8b5cc8e2 100644 --- a/crates/windows-waitable-queues/src/capacity/tests.rs +++ b/crates/windows-waitable-queues/src/capacity/tests.rs @@ -22,38 +22,54 @@ const WIDEST: Bounds = Bounds { max: WRAPPING_MAX_CAPACITY, }; +/// The largest power-of-two capacity the wrapping bound admits, as a shift. +/// +/// **Derived from `usize::BITS`, not written as 62.** The bound is +/// `usize::MAX / 2`, which is `2^(BITS-1) - 1`, so the largest power of two +/// under it is `2^(BITS-2)`. Hard-coding the 64-bit answer made these tests +/// unbuildable on a 32-bit target -- `1_usize << 63` does not fit in a 32-bit +/// `usize` -- a strange way for a test *about* `usize` bounds to fail. +const LARGEST_ACCEPTED_SHIFT: u32 = usize::BITS - 2; + +/// One past it: the smallest power of two the bound refuses. +const SMALLEST_REFUSED_SHIFT: u32 = usize::BITS - 1; + #[test] fn the_wrapping_ceiling_is_one_below_a_power_of_two() { // The fact every other assertion here rests on, stated so a reader does not // have to do the arithmetic: `usize::MAX / 2` is odd, so it is not itself a // capacity any shape accepts. - assert_eq!(WRAPPING_MAX_CAPACITY, (1_usize << 63) - 1); + assert_eq!( + WRAPPING_MAX_CAPACITY, + (1_usize << SMALLEST_REFUSED_SHIFT) - 1 + ); assert!(!WRAPPING_MAX_CAPACITY.is_power_of_two()); } #[test] -fn the_largest_accepted_capacity_is_two_to_the_sixty_two() { - // The documented number. `2^62` fits under `usize::MAX / 2`; `2^63` is one - // larger than the bound and is refused, which is what four documents used - // to claim was reachable. - validate_capacity(1_usize << 62, WIDEST).expect("2^62 is within the wrapping bound"); +fn the_largest_accepted_capacity_is_two_below_the_word_size() { + // On 64-bit that is 2^62 accepted and 2^63 refused, which is what four + // documents used to claim was the other way round. Expressed as shifts so + // the same assertion holds on a narrower word. + validate_capacity(1_usize << LARGEST_ACCEPTED_SHIFT, WIDEST) + .expect("the largest power of two under the bound is within it"); - validate_capacity(1_usize << 63, WIDEST) - .expect_err("2^63 exceeds the wrapping bound and must be refused"); + validate_capacity(1_usize << SMALLEST_REFUSED_SHIFT, WIDEST) + .expect_err("one power of two past the bound must be refused"); } #[test] fn every_power_of_two_up_to_the_ceiling_is_accepted() { // A property rather than the two boundary samples above, so a bound that // moved for some other reason cannot pass by coincidence. - for shift in 0..63_u32 { + for shift in 0..=LARGEST_ACCEPTED_SHIFT { let capacity = 1_usize << shift; assert!( validate_capacity(capacity, WIDEST).is_ok(), "2^{shift} should be accepted" ); } - for shift in 63..usize::BITS { + for shift in SMALLEST_REFUSED_SHIFT..usize::BITS { let capacity = 1_usize << shift; assert!( validate_capacity(capacity, WIDEST).is_err(), @@ -66,7 +82,8 @@ fn every_power_of_two_up_to_the_ceiling_is_accepted() { fn a_capacity_that_is_not_a_power_of_two_is_refused_whatever_its_size() { // Guards the other half of the rule, so a fix to the ceiling cannot be made // by loosening the shape of what is accepted. - for capacity in [3_usize, 6, 100, (1 << 62) - 1, (1 << 62) + 1] { + let largest = 1_usize << LARGEST_ACCEPTED_SHIFT; + for capacity in [3_usize, 6, 100, largest - 1, largest + 1] { assert!( validate_capacity(capacity, WIDEST).is_err(), "{capacity} is not a power of two and must be refused" From e02ba370a837ee823cec3cdc8796d5c0868009b8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 21:27:34 -0400 Subject: [PATCH 093/361] feat(placement-probe): name the backup with milliseconds, from the same clock reading The backup name now carries the millisecond within its second, so two runs in one second no longer want the same file. The record's own timestamp is untouched at second resolution, which is all a measurement taken over several seconds can honestly claim. **The milliseconds come from the same clock reading as `recorded_at`, not a fresh one.** Taking a new timestamp when the name is built would have been the obvious implementation and would have introduced a quieter defect than the one it fixed: the file's name would state a different instant from the record inside it, in a tool whose value rests on its outputs agreeing with each other. `SubmissionRecord::new` now splits one reading into the parts each consumer needs. **The field is `serde(skip)`, so the published schema does not move.** Precision a file name wants is not a promise a record makes, and a v3 bump would have changed the shape of every stored record to buy a naming nicety. Three independent guards catch its escape -- the archived schema golden, the field-order test, and an explicit test that says why -- verified by removing the attribute and watching all three fail. **This does not replace the exclusive-creation fix, and is not the guarantee.** Two runs can still begin in the same millisecond; finer resolution only makes a collision unlikely. `File::create_new` and the numbered suffix remain what makes the collision survivable rather than silent, and this keeps the suffix from being reached in practice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/src/record.rs | 23 ++++++++- .../src/record/tests.rs | 1 + .../windows-placement-probe/src/submission.rs | 29 +++++++++-- .../src/submission/tests.rs | 48 +++++++++++++++++++ 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index c74b38da..ed75baaf 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -67,6 +67,21 @@ pub struct SubmissionRecord { /// to parse prose to sort records, and because it survives any later change /// to how the string is rendered. pub recorded_at_epoch_seconds: u64, + /// Milliseconds past that second, for naming a file and nothing else. + /// + /// **Deliberately not serialized, and deliberately not a second clock + /// reading.** The backup file's name needs finer resolution than the record + /// does: two runs in one second would otherwise want the same file. Taking + /// a fresh timestamp when the name is built would solve that while creating + /// a worse problem -- the name would state a different instant from the + /// record inside it -- so this is the same reading as the two fields above, + /// just the part of it they discard. + /// + /// `serde(skip)` keeps it out of the schema: precision a *file name* wants + /// is not a change to what a *record* promises, and the archived shape + /// stays as published. + #[cfg_attr(feature = "serde", serde(skip))] + pub recorded_at_subsecond_millis: u32, /// Which build measured. pub build: BuildIdentity, /// What the machine was, beyond its measurable shape. @@ -162,14 +177,18 @@ impl SubmissionRecord { /// Assemble a record from a completed run. #[must_use] pub fn new(observation: &Observation, host: Fingerprint, machine: MachineDescription) -> Self { - let now = SystemTime::now() + // One reading, split into the parts each consumer needs, so the record + // and the file named after it can never describe different instants. + let since_epoch = SystemTime::now() .duration_since(UNIX_EPOCH) - .map_or(0, |since| since.as_secs()); + .unwrap_or_default(); + let now = since_epoch.as_secs(); Self { schema_version: SCHEMA_VERSION, recorded_at: iso8601_utc(now), recorded_at_epoch_seconds: now, + recorded_at_subsecond_millis: since_epoch.subsec_millis(), build: BuildIdentity::current(), machine, topology_provenance: host.provenance, diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index e6f833a8..a58a19d8 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -43,6 +43,7 @@ pub(crate) fn fully_populated() -> SubmissionRecord { schema_version: SCHEMA_VERSION, recorded_at: "2026-08-31T12:00:00Z".to_owned(), recorded_at_epoch_seconds: 1_788_177_600, + recorded_at_subsecond_millis: 250, build: BuildIdentity { crate_version: "0.1.0", commit: Some("abcdef123456"), diff --git a/crates/windows-placement-probe/src/submission.rs b/crates/windows-placement-probe/src/submission.rs index 37585a2c..ee29defd 100644 --- a/crates/windows-placement-probe/src/submission.rs +++ b/crates/windows-placement-probe/src/submission.rs @@ -94,9 +94,27 @@ pub fn render_submission(record: &SubmissionRecord) -> Result String { let stamp: String = record @@ -104,7 +122,10 @@ pub fn file_name(record: &SubmissionRecord) -> String { .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect(); - format!("placement-probe-v{}-{}.json", record.schema_version, stamp) + format!( + "placement-probe-v{}-{}-{:03}.json", + record.schema_version, stamp, record.recorded_at_subsecond_millis + ) } #[cfg(test)] diff --git a/crates/windows-placement-probe/src/submission/tests.rs b/crates/windows-placement-probe/src/submission/tests.rs index 73cf0e47..b706592d 100644 --- a/crates/windows-placement-probe/src/submission/tests.rs +++ b/crates/windows-placement-probe/src/submission/tests.rs @@ -212,3 +212,51 @@ fn two_runs_do_not_collide_on_one_file_name() { assert_ne!(file_name(&first), file_name(&second)); } + +#[test] +fn two_records_in_the_same_second_get_different_names() { + // The collision the name is meant to avoid. Both records carry the same + // `recorded_at`, because a run finishes in well under a second; only the + // sub-second part separates them. + let mut first = fully_populated(); + first.recorded_at_subsecond_millis = 120; + let mut second = fully_populated(); + second.recorded_at_subsecond_millis = 890; + + assert_eq!( + first.recorded_at, second.recorded_at, + "the fixture must share a second for this test to mean anything" + ); + assert_ne!(file_name(&first), file_name(&second)); +} + +#[test] +fn the_millisecond_is_zero_padded_so_names_sort_chronologically() { + // Without padding, `-90` sorts after `-100` and a directory listing stops + // being a timeline. + let mut early = fully_populated(); + early.recorded_at_subsecond_millis = 90; + let mut late = fully_populated(); + late.recorded_at_subsecond_millis = 100; + + assert!( + file_name(&early) < file_name(&late), + "{} should sort before {}", + file_name(&early), + file_name(&late) + ); +} + +#[test] +fn the_sub_second_precision_never_reaches_the_record() { + // **The property that keeps this out of the schema.** The field exists so a + // *file name* can be finer-grained than a *record*; if it ever serialized, + // it would silently change the published shape and every stored record with + // it. The archived schema guard would catch that too, but this says why. + let json = serde_json::to_string(&fully_populated()).expect("must serialize"); + + assert!( + !json.contains("subsecond"), + "the sub-second field reached the record: {json}" + ); +} From 626667365b6201d2d622f8d28f2da4593a36d834 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 21:45:11 -0400 Subject: [PATCH 094/361] fix(placement-probe): stop leaking thread affinity between samples Seven findings from two reviews. The first biased the measurements themselves. **Pinning never came undone.** `pin_current_thread` passed a null previous- affinity pointer, with a comment asserting the previous affinity "is not wanted". It was: the consumer runs on the *calling* thread, so every sample left that thread confined to the processor it had just measured. The next sample then allocated its ring while still pinned there, which quietly placed the memory of the rows the report describes as "ring left where it fell" -- and on a NUMA machine that means placing it on the previous sample's consumer node. The public timing helpers also re-affinitised their caller permanently, which is not something a measurement function may do to the program that called it. Now captured and restored by a guard, so an unwind restores it too -- this crate's pinning failure path panics by design, so that is a reachable path rather than a formality. The guard is `#[must_use]`, because an unbound one drops at the end of its own statement: that would unpin the thread immediately and measure the scheduler's choice while the row claimed a processor, which is a worse and quieter bug than the one being fixed. Tested by reading the affinity back from the operating system rather than trusting the value the guard stored, so a guard that recorded the right thing and restored nothing still fails. **Four public contracts described a shape the code stopped having.** `by_node_pair` promised one entry per distinct node pair; it is now one per directed pair, ring placement and strategy, so a two-node machine yields eight rows rather than one. `node_pairs_measured` promised canonical `(low, high)` pairs while returning directed ones, so a caller trusting it would deduplicate away half the hops. `node_hops` in the record omitted the ring-placement dimension entirely, inviting a collector to treat the producer-local and consumer-local rows as duplicates and average away the asymmetry they exist to expose. And the record's `Display` labelled row counts as "node hops", reporting eight hops for two on a two-node machine. **The inherited-contract correction had not been swept.** Last round the method doc was corrected to own the fail-fast restore; the module's own "blast radius" section still said the opposite four screens above it, so a consumer got opposite contracts depending on which doc they read. Sweeping the crate found four more statements of it -- the module header, two in `impersonation.rs`, the design note and the README -- not the one reported. All now state the guarantee as this crate's, with the dependency named as satisfying it. **A public error message printed Rust's vocabulary**: "the nearest valid capacities are Some(64) and None", which is cluttered when both exist and wrong when one does not. Each case is now spelled out in terms of a number the caller can actually pass. **A release-managed crate could not be published.** `windows-waitable-queues` is registered with release-please but was missing from `publish-crate.yml`, so its tag would have been created and nothing would have published it -- a release that looks complete everywhere except crates.io. Added to the tag list and to the dispatch choices, the second so the manual escape hatch could recover from the first failure. That defect existed because two files had to agree and nothing compared them, so `tools/check-publishable.ps1` now asserts every release-managed crate has a publish trigger, as its own CI job. Deliberately one-directional: a crate may be publishable without being release-managed, which is what `windows-placement- probe` is today. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 16 ++++ .github/workflows/publish-crate.yml | 2 + CHECKLIST-ship-topology-and-queues.md | 12 ++- .../src/core_affinity.rs | 19 +++- .../src/peer_index_cache.rs | 93 +++++++++++++++++-- .../src/peer_index_cache/tests.rs | 52 ++++++++++- crates/windows-placement-probe/src/record.rs | 12 ++- .../DESIGN-NOTES.md | 10 +- crates/windows-thread-ambient-sys/README.md | 2 +- .../src/impersonation.rs | 13 +-- .../windows-thread-ambient-sys/src/state.rs | 11 ++- crates/windows-waitable-queues/src/error.rs | 36 +++++-- tools/check-publishable.ps1 | 61 ++++++++++++ 13 files changed, 305 insertions(+), 34 deletions(-) create mode 100644 tools/check-publishable.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7190335..81ba68d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,22 @@ jobs: shell: pwsh run: ./tools/check-encoding.ps1 + # release-please decides which crates are versioned and tagged; + # publish-crate.yml decides which tags publish. Nothing connected the two, so + # a crate could be release-managed and unpublishable at once -- and the + # failure is silent in the worst way: the release PR merges, the tag is + # created, the workflow never runs, and the crate is released everywhere + # except crates.io. That happened to `windows-waitable-queues` and was caught + # by review rather than by anything here. + publishable: + name: every release-managed crate can publish + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + - name: Run check-publishable.ps1 + shell: pwsh + run: ./tools/check-publishable.ps1 + # The edition, MSRV, and pinned channel are declared once in Cargo.toml and # rust-toolchain.toml, then restated a dozen times -- in this file's `msrv` # job below, in README/DEVELOPMENT prose, and in the instruction files a code diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index 5af11e09..ee4ede19 100644 --- a/.github/workflows/publish-crate.yml +++ b/.github/workflows/publish-crate.yml @@ -14,6 +14,7 @@ on: - 'windows-thread-ambient-sys-v*' - 'windows-threadpool-sys-v*' - 'windows-topology-sys-v*' + - 'windows-waitable-queues-v*' - 'wtf-string-v*' # Manual escape hatch for a tag whose commit cannot publish. `cargo publish # --locked` refuses when Cargo.lock disagrees with the manifests, and the @@ -38,6 +39,7 @@ on: - windows-thread-ambient-sys - windows-threadpool-sys - windows-topology-sys + - windows-waitable-queues - wtf-string permissions: diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 3b6a04f6..2cc3869b 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -115,12 +115,22 @@ release-blocking rather than restating the decision itself. ## M2: repair the release plumbing before relying on it -- [ ] **SH-2.1** -- **Add `windows-waitable-queues-v*` to the tag trigger list in +- [x] **SH-2.1** -- **Add `windows-waitable-queues-v*` to the tag trigger list in [.github/workflows/publish-crate.yml](.github/workflows/publish-crate.yml).** It is missing. The crate *is* registered with release-please, so release-please will happily raise the release PR and push the tag -- and then nothing will publish it, with no error, because no workflow matches the tag. **This is a silent failure, which is why it is its own item**: the symptom is a tag that exists, a changelog that looks right, and a crate that never appears on crates.io. + **Done, in both the tag trigger and the `workflow_dispatch` choices** -- the second matters because + without it the manual escape hatch could not publish the crate either, so there would have been no + way to recover from the first failure by hand. + **And the drift is now checked rather than remembered.** This defect existed because two files had + to agree and nothing compared them; it was found by a reviewer, not by CI. + [tools/check-publishable.ps1](tools/check-publishable.ps1) asserts that every crate release-please + manages has a publish trigger, and runs as its own CI job. The comparison is deliberately + one-directional -- a crate may be publishable without being release-managed, which is what + `windows-placement-probe` is today. Verified by removing the trigger again and watching the check + fail with the crate named. - [ ] **SH-2.2** -- Plan the **`windows-topology-sys` 0.2.0 ripple**. `windows-ioring-sys` is published and pins `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating that dependency and diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index d61c8b7f..b6b9643e 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -361,7 +361,17 @@ pub struct Observation { /// rather than silently skipped, because "this host cannot test that" and /// "that made no difference" are opposite findings. pub measurements: Vec, - /// One measurement per *distinct pair of NUMA nodes*. + /// One measurement per *directed* node pair, ring placement and strategy. + /// + /// **Three dimensions, not one, and the count is their product.** A hop is + /// directed, because the producer writes and the consumer reads, so `(a, b)` + /// and `(b, a)` are different measurements; each of those is measured with + /// the ring on each endpoint's node; and each of those under each strategy. + /// A two-node machine therefore contributes eight rows here, not one. An + /// earlier version of this note promised one entry per distinct pair, which + /// would lead a consumer to treat these rows as unique hops and to collapse + /// the producer-local and consumer-local measurements into each other -- + /// the two quantities the ring placement exists to separate. /// /// Separate from [`Self::measurements`] for the same reason [`Self::by_class`] /// is: the placement categories collapse every node crossing into a single @@ -683,7 +693,12 @@ impl Observation { .cloned() } - /// Every node pair measured, in canonical `(low, high)` order. + /// Every node pair measured, as *directed* `(producer, consumer)` pairs. + /// + /// Both `(0, 1)` and `(1, 0)` appear, because they are different + /// measurements rather than two spellings of one. This said "canonical + /// `(low, high)` order" while returning the directed pairs, so a caller + /// trusting the documentation would have deduplicated away half the hops. #[must_use] pub fn node_pairs_measured(&self) -> Vec<(u32, u32)> { let mut seen: Vec<(u32, u32)> = self diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index 9685574c..aa2e60a0 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -66,6 +66,8 @@ use windows_sys::Win32::System::ProcessStatus::{ PSAPI_WORKING_SET_EX_INFORMATION, QueryWorkingSetEx, }; use windows_sys::Win32::System::SystemInformation::{GROUP_AFFINITY, GetSystemInfo, SYSTEM_INFO}; +#[cfg(test)] +use windows_sys::Win32::System::Threading::GetThreadGroupAffinity; use windows_sys::Win32::System::Threading::{ GetCurrentProcess, GetCurrentThread, SetThreadGroupAffinity, }; @@ -659,10 +661,14 @@ pub fn time_model_placed( let (consumer_refreshes, producer_refreshes) = thread::scope(|scope| { let shared = ˚ let producer = scope.spawn(move || { - pin_current_thread(producer_cpu); + // Bound, not discarded: an unbound guard drops at the end of its + // own statement, which would unpin the thread immediately and + // measure the scheduler's choice while claiming to measure this + // processor. `#[must_use]` makes that mistake a warning. + let _pinned = pin_current_thread(producer_cpu); produce(shared, strategy) }); - pin_current_thread(consumer_cpu); + let _pinned = pin_current_thread(consumer_cpu); let consumer_refreshes = consume(&ring, strategy); let producer_refreshes = producer.join().expect("the producer must not panic"); (consumer_refreshes, producer_refreshes) @@ -677,6 +683,11 @@ pub fn time_model_placed( /// Confine the calling thread to one logical processor, named by group. /// +/// **The returned guard restores the previous affinity and must be held for as +/// long as the pinning is wanted.** Dropping it immediately -- by discarding +/// the return value -- unpins the thread at once, so the work that follows +/// measures wherever the scheduler puts it while the row claims a processor. +/// /// Panics rather than warns on failure. A silently unpinned thread would turn /// a placement experiment into a measurement of the scheduler's preferences, /// and the run would still print a confident number -- the same failure mode as @@ -689,9 +700,9 @@ pub fn time_model_placed( /// processors that is not a matter of widening the mask; the call has no way to /// express the target at all. `SetThreadGroupAffinity` takes the group /// explicitly, and is the only way to pin across the whole machine. -fn pin_current_thread(cpu: Option<(u16, u8)>) { +fn pin_current_thread(cpu: Option<(u16, u8)>) -> AffinityGuard { let Some((group, number)) = cpu else { - return; + return AffinityGuard { previous: None }; }; assert!( u32::from(number) < usize::BITS, @@ -703,10 +714,24 @@ fn pin_current_thread(cpu: Option<(u16, u8)>) { Group: group, Reserved: [0; 3], }; + // The previous affinity is captured, not discarded. Passing null here left + // the calling thread pinned after the sample finished, and the next sample + // then allocated its ring while still confined to the *previous* sample's + // consumer -- so the rows that report "ring left where it fell" were + // quietly biased toward whichever processor was last measured. It also made + // the public timing helpers permanently re-affinitise their caller, which + // is not a thing a measurement function may do to the program that called + // it. + // // SAFETY: `affinity` is a fully initialised `GROUP_AFFINITY` naming one - // processor the caller took from the discovered topology, and the previous - // affinity is not wanted, so a null pointer is passed for it. - let ok = unsafe { SetThreadGroupAffinity(GetCurrentThread(), &affinity, ptr::null_mut()) }; + // processor the caller took from the discovered topology, and `previous` is + // a writable `GROUP_AFFINITY` this call fills in. + let mut previous = GROUP_AFFINITY { + Mask: 0, + Group: 0, + Reserved: [0; 3], + }; + let ok = unsafe { SetThreadGroupAffinity(GetCurrentThread(), &affinity, &raw mut previous) }; // A raw string rather than an escaped-continuation one: `cargo fmt` // reindents a multi-line string literal and the backslash continuations // then swallow the blank lines, which turns a carefully laid-out message @@ -734,6 +759,60 @@ Reporting this is genuinely useful: please include this message. ", error = std::io::Error::last_os_error() ); + + AffinityGuard { + previous: Some(previous), + } +} + +/// This thread's group affinity as the system currently reports it. +/// +/// Used by the tests that check the pin is undone. Reading it back from the +/// operating system rather than trusting the value the guard stored is the +/// point: a guard that recorded the right thing and restored nothing would +/// otherwise pass. +#[cfg(test)] +fn current_affinity() -> Option { + let mut affinity = GROUP_AFFINITY { + Mask: 0, + Group: 0, + Reserved: [0; 3], + }; + // SAFETY: writes one fully owned `GROUP_AFFINITY`. + let ok = unsafe { GetThreadGroupAffinity(GetCurrentThread(), &raw mut affinity) }; + (ok != 0).then_some(affinity) +} + +/// Puts the calling thread's affinity back when it goes out of scope./// +/// A guard rather than a call at the end of the timed section, so an unwind +/// restores it too: a panic between pinning and restoring would otherwise leave +/// the thread confined for the rest of the process, and this crate's pinning +/// failure path panics by design. +#[must_use = "the thread is unpinned as soon as this guard is dropped"] +struct AffinityGuard { + /// What to restore, or `None` when nothing was changed. + previous: Option, +} + +impl Drop for AffinityGuard { + fn drop(&mut self) { + let Some(previous) = self.previous else { + return; + }; + // SAFETY: `previous` is the affinity this thread had, as reported by + // the call that replaced it. + let ok = unsafe { SetThreadGroupAffinity(GetCurrentThread(), &previous, ptr::null_mut()) }; + + // Failing to restore silently is the defect this type exists to remove, + // so it is not swallowed -- but panicking while already unwinding would + // abort the process and destroy the original failure's message, which + // is the one worth reading. + assert!( + ok != 0 || std::thread::panicking(), + "could not restore the thread's affinity: {}", + std::io::Error::last_os_error() + ); + } } /// Fills the ring, returning how many times it read the consumer's position. diff --git a/crates/windows-placement-probe/src/peer_index_cache/tests.rs b/crates/windows-placement-probe/src/peer_index_cache/tests.rs index c37eaedb..848c87f1 100644 --- a/crates/windows-placement-probe/src/peer_index_cache/tests.rs +++ b/crates/windows-placement-probe/src/peer_index_cache/tests.rs @@ -23,7 +23,10 @@ use core::ffi::c_void; use windows_sys::Win32::System::Memory::{PAGE_EXECUTE_READ, PAGE_READWRITE}; -use super::{CAPACITY, Ring, Slots, observed_node, working_set, working_set_flags}; +use super::{ + CAPACITY, Ring, Slots, current_affinity, observed_node, pin_current_thread, working_set, + working_set_flags, +}; /// A node id no machine will have. /// @@ -255,3 +258,50 @@ fn the_node_offset_is_pinned_by_a_page_whose_upper_fields_are_not_zero() { protection field's width is wrong and Node's offset with it: flags {flags:#x}" ); } + +#[test] +fn pinning_a_thread_restores_its_affinity_afterwards() { + // **The leak this guards biased the measurements themselves.** The pin used + // to discard the previous affinity, so a thread stayed confined after its + // sample finished -- and the next sample allocated its ring while still on + // the last sample's consumer, quietly placing memory that the report + // describes as "left where it fell". The public timing helpers also left + // their caller permanently re-affinitised. + // + // Run on a thread of this test's own, so a failure cannot disturb the rest + // of the suite through the very leak it is checking for. + std::thread::spawn(|| { + let before = current_affinity().expect("the thread has an affinity"); + + { + let _pinned = pin_current_thread(Some((0, 0))); + let during = current_affinity().expect("still has an affinity"); + assert_eq!(during.Mask, 1, "the pin did not take effect"); + assert_eq!(during.Group, 0); + } + + let after = current_affinity().expect("the thread has an affinity"); + assert_eq!( + (after.Mask, after.Group), + (before.Mask, before.Group), + "the affinity was not restored" + ); + }) + .join() + .expect("the pinning thread must not panic"); +} + +#[test] +fn asking_for_no_pin_leaves_the_affinity_alone() { + std::thread::spawn(|| { + let before = current_affinity().expect("the thread has an affinity"); + let guard = pin_current_thread(None); + let during = current_affinity().expect("the thread has an affinity"); + assert_eq!((during.Mask, during.Group), (before.Mask, before.Group)); + drop(guard); + let after = current_affinity().expect("the thread has an affinity"); + assert_eq!((after.Mask, after.Group), (before.Mask, before.Group)); + }) + .join() + .expect("must not panic"); +} diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index ed75baaf..2cd42ab9 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -96,7 +96,15 @@ pub struct SubmissionRecord { pub topology_provenance: Provenance, /// One entry per placement this machine could express, per strategy. pub placements: Vec, - /// One entry per distinct pair of NUMA nodes, per strategy. + /// One entry per *directed* node pair, per ring placement, per strategy. + /// + /// **The ring placement is the dimension a collector is most likely to + /// miss.** Each directed hop is measured twice, once with the ring on the + /// producer's node and once on the consumer's, and `memory_node` on each + /// row says which. Rows that agree on every other field are therefore not + /// duplicates, and averaging them together would erase exactly the + /// asymmetry -- remote write against remote read -- that measuring both + /// placements exists to expose. /// /// Empty on a single-node machine. **That emptiness is the finding this /// tool most wants from a large host**, so it is an empty list rather than @@ -215,7 +223,7 @@ impl fmt::Display for SubmissionRecord { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "schema {} | {} | {} | {} placements, {} node hops", + "schema {} | {} | {} | {} placement rows, {} node-hop rows", self.schema_version, self.build, self.host, diff --git a/crates/windows-thread-ambient-sys/DESIGN-NOTES.md b/crates/windows-thread-ambient-sys/DESIGN-NOTES.md index 244848cd..9b7af2a1 100644 --- a/crates/windows-thread-ambient-sys/DESIGN-NOTES.md +++ b/crates/windows-thread-ambient-sys/DESIGN-NOTES.md @@ -132,10 +132,12 @@ any blocking call can raise a hard error. Failing to restore impersonation is fail-fast, because returning a shared worker -to a pool under an unknown identity is a process-wide security failure. That -semantics is **inherited unchanged** from -[windows-impersonation-token-sys](../windows-impersonation-token-sys/DESIGN-NOTES.md); -this crate does not restate or reimplement it. +to a pool under an unknown identity is a process-wide security failure. **That is +this crate's decision.** +[windows-impersonation-token-sys](../windows-impersonation-token-sys/DESIGN-NOTES.md) +is used because its behaviour already satisfies it, and this crate does not +reimplement it -- but the guarantee is owned here, so a change in that dependency +would make the dependency wrong rather than change this contract. The other aspects do not warrant that severity, and imposing it on them would be the single-strictest-semantics failure the composite exists to avoid. Their diff --git a/crates/windows-thread-ambient-sys/README.md b/crates/windows-thread-ambient-sys/README.md index 3a7a470f..942d1ea6 100644 --- a/crates/windows-thread-ambient-sys/README.md +++ b/crates/windows-thread-ambient-sys/README.md @@ -117,7 +117,7 @@ assert_eq!(refused.bits(), 0x0004); | Aspect | How it relates to the caller | Notes | |---|---|---| -| Impersonation | captured | Consumed from [windows-impersonation-token-sys](../windows-impersonation-token-sys/README.md); its fail-fast restore is inherited unchanged | +| Impersonation | captured | Fail-fast restore, guaranteed by this crate; [windows-impersonation-token-sys](../windows-impersonation-token-sys/README.md) is used because it already behaves that way | | Thread error mode | captured **and** declarable | The only aspect in both sets, so a consumer may transplant it or impose its own | | TxF transaction | captured | Outside the default set: deprecated, and a captured transaction can be committed or rolled back beneath the worker | | WOW64 redirection | declared | Has no getter at all, so there is nothing to capture | diff --git a/crates/windows-thread-ambient-sys/src/impersonation.rs b/crates/windows-thread-ambient-sys/src/impersonation.rs index 30aabaaa..4be7b1b1 100644 --- a/crates/windows-thread-ambient-sys/src/impersonation.rs +++ b/crates/windows-thread-ambient-sys/src/impersonation.rs @@ -15,8 +15,8 @@ //! the crate's three-state [`Captured`] shape and to subset application; it does //! not reimplement any part of it, and it does not soften its semantics. //! -//! In particular, **restore failure remains fail-fast**, inherited rather than -//! chosen. Returning a shared worker to a pool under an unknown identity is a +//! In particular, **restore failure is fail-fast, and this crate owns that +//! choice.** Returning a shared worker to a pool under an unknown identity is a //! process-wide security failure, which is a different order of hazard from the //! other aspects, and the reason this crate composes per-aspect guards instead //! of one guard with one policy. @@ -89,10 +89,11 @@ pub fn capture() -> Result, CaptureError> { /// /// # Panics /// -/// Panics if the thread's entry context cannot be restored afterwards. This is -/// [`windows_impersonation_token_sys`]'s documented behaviour and is inherited -/// deliberately: a worker left under an unknown identity must not be returned to -/// shared infrastructure. +/// Panics if the thread's entry context cannot be restored afterwards. **That +/// is this crate's guarantee**: a worker left under an unknown identity must not +/// be returned to shared infrastructure, and no error return could make a caller +/// notice in time. [`windows_impersonation_token_sys`] is used because its +/// documented behaviour already satisfies it. pub fn with_applied( captured: &Captured, operation: F, diff --git a/crates/windows-thread-ambient-sys/src/state.rs b/crates/windows-thread-ambient-sys/src/state.rs index 9a33072f..628fdf18 100644 --- a/crates/windows-thread-ambient-sys/src/state.rs +++ b/crates/windows-thread-ambient-sys/src/state.rs @@ -53,10 +53,13 @@ //! //! # The blast radius of fail-fast restoration //! -//! A failure to restore impersonation panics. That is inherited from -//! [`windows_impersonation_token_sys`] rather than chosen here, and it is -//! correct: a shared worker returned to a pool under an unknown identity is a -//! process-wide security failure. +//! A failure to restore impersonation panics. **That is this crate's decision, +//! not one inherited from a dependency**: a shared worker returned to a pool +//! under an unknown identity is a process-wide security failure, and no error +//! return could make a caller notice in time. +//! [`windows_impersonation_token_sys`] is used because its behaviour already +//! satisfies that requirement; if it stopped doing so, the dependency would be +//! wrong and this guarantee would stay. //! //! The consequence is worth stating plainly for anyone running many impersonated //! workers. A panic inside a thread-pool callback **aborts the process** -- the diff --git a/crates/windows-waitable-queues/src/error.rs b/crates/windows-waitable-queues/src/error.rs index c663a2a0..c063152c 100644 --- a/crates/windows-waitable-queues/src/error.rs +++ b/crates/windows-waitable-queues/src/error.rs @@ -164,12 +164,36 @@ impl fmt::Display for CapacityError { write!(f, "a queue capacity of zero can never accept an item") } CapacityErrorKind::NotPowerOfTwo => { - let (lo, hi) = (self.previous_valid(), self.next_valid()); - write!( - f, - "capacity {} is not a power of two; the nearest valid capacities are {:?} and {:?}", - self.requested, lo, hi - ) + // Each case spelled out, because `{:?}` on an `Option` puts + // Rust's own vocabulary into a message a user reads: "the + // nearest valid capacities are Some(64) and None" is both + // cluttered when there are two and wrong when there is one. + // What a caller needs is a number they can pass instead. + let requested = self.requested; + match (self.previous_valid(), self.next_valid()) { + (Some(lower), Some(upper)) => write!( + f, + "capacity {requested} is not a power of two; \ + the nearest valid capacities are {lower} and {upper}" + ), + (Some(lower), None) => write!( + f, + "capacity {requested} is not a power of two; \ + the nearest valid capacity below it is {lower}, \ + and none above it is representable" + ), + (None, Some(upper)) => write!( + f, + "capacity {requested} is not a power of two; \ + the nearest valid capacity above it is {upper}, \ + and none below it is large enough" + ), + (None, None) => write!( + f, + "capacity {requested} is not a power of two, \ + and this queue shape can represent no valid capacity near it" + ), + } } CapacityErrorKind::TooSmall => write!( f, diff --git a/tools/check-publishable.ps1 b/tools/check-publishable.ps1 new file mode 100644 index 00000000..8ea5df6e --- /dev/null +++ b/tools/check-publishable.ps1 @@ -0,0 +1,61 @@ +# Copyright (c) 2026 Mike Grier +<# +.SYNOPSIS + Checks that every release-managed crate can actually be published. + +.DESCRIPTION + release-please decides which crates get versioned and tagged; + publish-crate.yml decides which tags trigger a publish. Nothing connects + them, so a crate can be added to the first and forgotten in the second -- + and the failure is silent in the worst way: release-please raises the PR, + the tag is created, the workflow simply does not run, and the crate is + "released" everywhere except on crates.io. + + That happened to windows-waitable-queues, and was found by review rather + than by any check. This script is the check. + + A crate may be deliberately absent from release-please -- windows-placement- + probe ships as a downloadable binary and is not on crates.io yet -- so the + comparison is one-directional: everything release-please manages must be + publishable. The reverse is allowed. +#> +[CmdletBinding()] +param( + [string] $RepositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +) + +$ErrorActionPreference = 'Stop' + +$configPath = Join-Path $RepositoryRoot 'release-please-config.json' +$workflowPath = Join-Path $RepositoryRoot '.github/workflows/publish-crate.yml' + +foreach ($required in @($configPath, $workflowPath)) { + if (-not (Test-Path $required)) { + throw "cannot check publication: $required is missing" + } +} + +$managed = (Get-Content $configPath -Raw | ConvertFrom-Json).packages.PSObject.Properties.Name | + ForEach-Object { Split-Path $_ -Leaf } | + Sort-Object + +$workflow = Get-Content $workflowPath -Raw + +$missing = @() +foreach ($crate in $managed) { + $hasTag = $workflow -match [regex]::Escape("'$crate-v*'") + $hasDispatch = $workflow -match ("(?m)^\s+- " + [regex]::Escape($crate) + "\s*$") + if (-not $hasTag) { $missing += "$crate : no tag trigger, so its release tag would publish nothing" } + if (-not $hasDispatch) { $missing += "$crate : not a workflow_dispatch choice, so it cannot be published by hand either" } +} + +if ($missing.Count -gt 0) { + Write-Host "Release-managed crates that cannot be published:" -ForegroundColor Red + $missing | ForEach-Object { Write-Host " $_" -ForegroundColor Red } + Write-Host '' + Write-Host "Add them to .github/workflows/publish-crate.yml, in both the tag list and the dispatch choices." + exit 1 +} + +Write-Host "All $($managed.Count) release-managed crates have a publish trigger." -ForegroundColor Green +exit 0 From c7ef089e870f3d84babb5481a4da7b40812a7a3c Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 21:47:02 -0400 Subject: [PATCH 095/361] fix(placement-probe): unglue a doc comment the previous commit welded together CI's encoding check caught it: a string replacement in the affinity work joined two doc-comment lines into `...goes out of scope.///`, which swallows the blank line and folds the guard's explanation into its summary. Exactly the defect that check exists for, and it found it in the same push that added the code -- worth noting because the same class of damage from scripted edits has slipped through review twice in this branch when nothing mechanical was watching. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/src/peer_index_cache.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index aa2e60a0..71b78ea4 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -783,7 +783,8 @@ fn current_affinity() -> Option { (ok != 0).then_some(affinity) } -/// Puts the calling thread's affinity back when it goes out of scope./// +/// Puts the calling thread's affinity back when it goes out of scope. +/// /// A guard rather than a call at the end of the timed section, so an unwind /// restores it too: a panic between pinning and restoring would otherwise leave /// the thread confined for the rest of the process, and this crate's pinning From 3a4aa5251d23b44caa1b2f5656aa988ef4bf9519 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 22:04:17 -0400 Subject: [PATCH 096/361] fix(queues): a panicking item destructor abandoned the rest of the teardown Four findings from review 5073245942. **Panic containment covered the sink and not the item's own `Drop`.** With a custom disposal sink, a panic is caught so the walk over the surviving slots continues; with the default policy the item was dropped directly, so a panicking `T::drop` escaped -- abandoning every item behind it, and inside an unwind risking the second-panic abort. `T` is the caller's type, so its destructor is caller-supplied code exactly as a sink is, and the reasoning already written above that method never distinguished them. The default path had precisely the failure the sink path exists to prevent. Both are now caught. Two tests cover it with a type that panics in `Drop`, and sabotage confirms both fail if the catch is removed. **A doc comment contradicted itself.** `by_node_pair`'s opening was corrected an hour ago to describe three dimensions -- directed pair, ring placement, strategy -- while a sentence three lines below still promised "a single entry on a two-node machine". It is eight. Correcting the first half of a contract and leaving the second is the drift this repository keeps paying for, and this one was mine. **A relative `gitdir:` redirect resolved against the wrong directory.** It is relative to where the `.git` file sits, not to wherever cargo runs the build script, so in a worktree or submodule checkout the watch paths landed under the crate directory and commits stopped refreshing the embedded identity -- the exact failure that function was added to fix, surviving in the one layout it was added for. Now anchored to the redirect file's parent; an absolute redirect is unaffected, because `join` replaces on absolute paths. **The fingerprint's canonical-equivalence claim was too strong.** It said two hosts rendering the same string can express the same placements. Every partition is recorded as a list of *sizes* and never as how the partitions intersect, so two eight-processor hosts can both render `L2[4,4] ec[0:4,1:4] numa[4,4]` while one puts each efficiency class in its own cache domain and the other splits both across both -- and only the second can express a same-cache/cross-class pair. The claim now says what the string actually supports, and points a consumer needing placement equivalence at the measurement rows, which name their placement directly. The stronger fix -- a canonical placement signature inside the string -- is `PT-6.1` rather than done here: it is a serialized field and so a schema bump, which is not worth spending on a summary line when the record already answers the question. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 16 +++++ crates/windows-placement-probe/build.rs | 13 +++- .../src/core_affinity.rs | 7 ++- .../src/fingerprint.rs | 32 ++++++++-- .../windows-waitable-queues/src/disposal.rs | 26 +++++--- .../src/disposal/tests.rs | 60 +++++++++++++++++++ 6 files changed, 136 insertions(+), 18 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 72543d5a..9567435e 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -506,6 +506,22 @@ and numbered when that has happened. ## M6: is a set of "equivalent" processors actually equivalent? +- [ ] **PT-6.1** -- **Give the fingerprint a placement signature, or keep saying it is not canonical.** + [fingerprint.rs](crates/windows-placement-probe/src/fingerprint.rs) records each partition as a list + of *sizes* -- processors per cache domain, per efficiency class, per NUMA node -- and never how + those partitions intersect. Two eight-processor hosts can both render + `L2[4,4] ec[0:4,1:4] numa[4,4]` while one puts each efficiency class in its own cache domain and the + other splits both classes across both; only the second can express a same-cache/cross-class pair. + **The placements available to a run differ while the fingerprint agrees**, so string equality is not + placement equivalence. + The claim has been corrected in place, so nothing is currently wrong -- this item is the stronger + fix, not a bug. It needs a canonical signature of the expressible placements *in* the string, which + means a serialized field and therefore a schema bump. + **Deliberately gated on some other reason to bump the schema**, because a summary line is not worth + a version of its own when every measurement row already names the placement it was taken at, which + is what a collector needing equivalence should read. Raised by review 5073245942 on pull request + #56. + **Not gated on the release, unlike the rest of this file.** The work is an extension of the affinity measurement, which today lives in [crates/windows-platform-probes](crates/windows-platform-probes) and moves wholesale under PT-2.1. Build it there now; it travels with everything else. diff --git a/crates/windows-placement-probe/build.rs b/crates/windows-placement-probe/build.rs index 69e912cb..3b73428c 100644 --- a/crates/windows-placement-probe/build.rs +++ b/crates/windows-placement-probe/build.rs @@ -16,7 +16,7 @@ //! unable to tell, must be the safe direction. use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; /// What CI sets so the build does not have to shell out to `git`. @@ -106,7 +106,16 @@ fn watch_git_head(git_dir: &Path) { Ok(_) => git_dir.to_path_buf(), Err(_) => match fs::read_to_string(git_dir) { Ok(redirect) => match redirect.trim().strip_prefix("gitdir:") { - Some(path) => PathBuf::from(path.trim()), + // **Resolved against the redirect file's own directory.** A + // `gitdir:` path may be relative, and it is relative to where + // the `.git` file sits -- not to wherever cargo happens to run + // this script. Taking it literally produced watch paths under + // the crate directory, so in a worktree or submodule checkout + // commits would stop refreshing the stamp: the exact failure + // this function was added to fix, surviving in the one layout + // it was added for. `join` still yields an absolute redirect + // unchanged, so both forms work. + Some(path) => git_dir.parent().unwrap_or(Path::new(".")).join(path.trim()), None => return, }, // No repository. The stamp is "unknown", which is the honest answer diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index b6b9643e..72d05c2e 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -381,8 +381,11 @@ pub struct Observation { /// socket link -- so a single row would report whichever hop the enumeration /// happened to reach first. /// - /// Empty on a single-node machine, and a single entry on a two-node one, - /// where it restates the `CrossNumaNode` row rather than adding to it. + /// Empty on a single-node machine. A two-node machine yields **eight** + /// rows -- two directed pairs, each at two ring placements, each under two + /// strategies -- which is the product described above. This sentence said + /// "a single entry" while the paragraph above it said three dimensions, + /// which is the same doc comment contradicting itself. pub by_node_pair: Vec, } diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index 8c1522fa..b1bf67e8 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -265,12 +265,32 @@ pub struct Fingerprint { pub numa_node_sizes: Vec, /// Where the topology behind this fingerprint came from. /// - /// Rendered *inside* the string, not beside it. The fingerprint is - /// documented as canonical -- two hosts rendering the same string can - /// express the same placements, so string equality is a usable comparison. - /// A marker kept outside the string would leave a synthetic host comparing - /// equal to a real one, which is the specific bug this prevents rather than - /// a display nicety. + /// Rendered *inside* the string, not beside it. A marker kept outside would + /// leave a synthetic host comparing equal to a real one, which is the + /// specific bug this prevents rather than a display nicety. + /// + /// # What equal fingerprints do and do not mean + /// + /// **Equal strings mean equal marginal shape. They do not mean the two + /// hosts can express the same placements**, and an earlier version of this + /// note claimed they did. + /// + /// Every partition here is recorded as a list of *sizes* -- processors per + /// cache domain, per efficiency class, per NUMA node -- and never as how + /// those partitions intersect. Two eight-processor hosts can both render + /// `L2[4,4] ec[0:4,1:4] numa[4,4]` while one puts each efficiency class in + /// its own cache domain and the other splits both classes across both. Only + /// the second can express a same-cache/cross-class pair; only the first can + /// express cross-cache/same-class cleanly. The placements available to a + /// run therefore differ while the fingerprint agrees. + /// + /// So this is a summary for a banner and for grouping *by shape*, not a + /// key for pooling measurements. A consumer that needs placement + /// equivalence should read the placements a record actually reports, which + /// name themselves in every measurement row, rather than inferring them + /// from this string. Making the string canonical again would mean carrying + /// a placement signature, which is a serialized field and so a schema bump; + /// that is tracked as `PT-6.1` rather than done here. pub provenance: Provenance, } diff --git a/crates/windows-waitable-queues/src/disposal.rs b/crates/windows-waitable-queues/src/disposal.rs index 12583838..b7762f0c 100644 --- a/crates/windows-waitable-queues/src/disposal.rs +++ b/crates/windows-waitable-queues/src/disposal.rs @@ -127,25 +127,35 @@ impl Teardown { /// Dispose of one surviving item. /// - /// # A panicking sink does not strand the items behind it + /// # A panicking disposal does not strand the items behind it /// - /// The sink is caller-supplied code running inside a destructor, which is + /// This applies to the item's own `Drop` as much as to a sink, and an + /// earlier version guarded only the sink. Both are caller-supplied code + /// running inside a destructor -- `T` belongs to the caller too -- so a + /// panicking `T::drop` escaped this manual walk over the surviving slots, + /// abandoning every item behind it and risking the second-panic abort. The + /// default path had exactly the failure the sink path was written to + /// prevent, and the reasoning below never distinguished them. + /// + /// The disposal is caller-supplied code running inside a destructor, which is /// the worst place for it to panic: a panic escaping here during an unwind /// aborts the process, and one escaping otherwise abandons every item not /// yet disposed -- precisely the handles this whole mechanism exists to /// account for. /// /// So a panic is caught and the walk continues. That is deliberately *not* - /// "swallowing an error": the item has already been handed over, so there - /// is nothing left to report about it, and the alternative is to lose the - /// rest of the queue as well. A sink that panics is a bug in the caller; - /// this only declines to make it a much larger one. + /// "swallowing an error": the item has already been handed over or + /// destroyed, so there is nothing left to report about it, and the + /// alternative is to lose the rest of the queue as well. A sink or a `Drop` + /// that panics is a bug in the caller; this only declines to make it a much + /// larger one. pub(crate) fn dispose(&mut self, item: T) { let Some(disposal) = self.disposal.as_mut() else { // The default. Written as an explicit drop rather than left to fall // out of the binding going out of scope, because "destroy it here" - // is a decision this type exists to name. - drop(item); + // is a decision this type exists to name -- and caught for the same + // reason the sink is: `T::drop` is the caller's code too. + let _ = catch_unwind(AssertUnwindSafe(move || drop(item))); return; }; diff --git a/crates/windows-waitable-queues/src/disposal/tests.rs b/crates/windows-waitable-queues/src/disposal/tests.rs index f0de7872..3abaad15 100644 --- a/crates/windows-waitable-queues/src/disposal/tests.rs +++ b/crates/windows-waitable-queues/src/disposal/tests.rs @@ -152,3 +152,63 @@ fn the_debug_form_says_which_policy_is_in_force() { let handing: Teardown = Teardown::new(Some(Disposal::new(|_| {}))); assert!(format!("{handing:?}").contains("hands_off: true")); } + +/// An item whose own destructor panics on one chosen value. +/// +/// `T` is the caller's type, so its `Drop` is caller-supplied code exactly as a +/// sink is -- which is the whole point of the test below. +struct PanicsOnDrop { + value: u32, + dropped: Arc, +} + +impl Drop for PanicsOnDrop { + fn drop(&mut self) { + self.dropped.fetch_add(1, Ordering::Relaxed); + assert_ne!( + self.value, 3, + "deliberate panic from a caller-supplied destructor" + ); + } +} + +#[test] +fn a_panicking_item_destructor_does_not_strand_the_items_behind_it() { + // **The default policy had the defect the sink policy was written to + // avoid.** With no sink the item was dropped directly, so a panicking + // `T::drop` escaped this manual walk over the surviving slots -- abandoning + // every item behind it, and inside an unwind aborting the process on the + // second panic. The reasoning for catching the sink never distinguished the + // two, and neither does the code now. + let dropped = Arc::new(AtomicUsize::new(0)); + + let mut teardown = Teardown::new(None); + for value in 0..10 { + teardown.dispose(PanicsOnDrop { + value, + dropped: Arc::clone(&dropped), + }); + } + + assert_eq!( + dropped.load(Ordering::Relaxed), + 10, + "the walk must continue past a panicking destructor, or one bad item loses the rest" + ); +} + +#[test] +fn a_panicking_destructor_still_destroys_the_item_it_panicked_on() { + // Catching the panic must not turn into retaining the item: it was moved + // into the closure, so the unwind destroys it. Asserted separately so that + // "the panic is caught" cannot be mistaken for "the item survives". + let dropped = Arc::new(AtomicUsize::new(0)); + + let mut teardown = Teardown::new(None); + teardown.dispose(PanicsOnDrop { + value: 3, + dropped: Arc::clone(&dropped), + }); + + assert_eq!(dropped.load(Ordering::Relaxed), 1); +} From 91436d600a00aedce46cef21ffa0ed172974f17f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 22:17:52 -0400 Subject: [PATCH 097/361] fix(placement-probe): stop marking every Surface as a virtual machine `microsoft corporation` was in the hypervisor marker list, and it is matched against `SystemManufacturer`. That is the manufacturer of a Hyper-V guest and of every physical Surface, so any Surface submitting a record would have been reported as virtualised -- invisibly to the submitter, and unquestionable by anyone reading the collected data later. `google` had the same shape: it names a cloud and a laptop. A vendor name is not a hypervisor marker. What separates those hosts is the *product*: Hyper-V and Azure guests report `Virtual Machine`, Compute Engine reports `Google Compute Engine`, and no physical machine reports either. The vendor names are gone and the product markers are in, which was checked against this workspace's own host rather than assumed -- it reports manufacturer `Microsoft Corporation` and product `Virtual Machine`, and is still detected. The reported name now carries both fields, because "Virtual Machine" alone does not say which hypervisor and that string is what a collector reads months later. The decision is split out of the registry read as `classify_virtualisation`, so the rule can be tested against strings real machines report. The defect was in the rule and no test could reach it while the two were one function; seven tests now cover it, including the Surface case, and sabotage confirms restoring the vendor marker fails it. **Also corrected a cardinality claim the reviewer has not reached yet.** `by_class` was documented as one entry per efficiency class in both the observation and the record, omitting the strategy dimension -- there are two rows per class. Same defect as the `by_node_pair` counts fixed in the last two commits, found by sweeping for the pattern rather than waiting for it to be reported. The review's other finding, that `by_node_pair` still documents pre-expansion cardinality, was already fixed in 3a4aa52 and the current text reads "eight rows"; that review analysed an earlier commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core_affinity.rs | 5 +- crates/windows-placement-probe/src/machine.rs | 61 ++++++++-- .../src/machine/tests.rs | 104 +++++++++++++++++- crates/windows-placement-probe/src/record.rs | 6 +- 4 files changed, 161 insertions(+), 15 deletions(-) diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index 72d05c2e..0463427d 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -345,7 +345,10 @@ pub struct Measurement { pub struct Observation { /// Every logical processor, as discovered. pub processors: Vec, - /// One within-class, within-cache pair per efficiency class. + /// One within-class, within-cache pair per efficiency class, per strategy. + /// + /// Two rows per class, not one: the pair is chosen once and then measured + /// under each strategy, which is the comparison the rows exist to support. /// /// Separate from [`Self::measurements`] because the placement categories /// collapse every same-class pair into one row, which would answer "does diff --git a/crates/windows-placement-probe/src/machine.rs b/crates/windows-placement-probe/src/machine.rs index 770bc529..c9060b64 100644 --- a/crates/windows-placement-probe/src/machine.rs +++ b/crates/windows-placement-probe/src/machine.rs @@ -173,10 +173,26 @@ fn read_os_build() -> Option { } } -/// Firmware strings that name a hypervisor. +/// Firmware strings that name a hypervisor and nothing else. /// /// Matched case-insensitively on a substring, because the exact strings vary by /// version and by how the host was configured. +/// +/// # Every marker here must be unambiguous on its own +/// +/// **A vendor name is not a hypervisor marker**, and two used to be here. +/// `microsoft corporation` is the `SystemManufacturer` of a Hyper-V guest *and* +/// of a physical Surface, so it marked every Surface as virtualised -- a false +/// positive that a submitter could not see and a reader of the collected data +/// would have no way to question. `google` had the same shape. +/// +/// What distinguishes those hosts is the *product*: a Hyper-V or Azure guest +/// reports `Virtual Machine`, and Compute Engine reports `Google Compute +/// Engine`, neither of which any physical machine reports. So the product +/// markers are here and the vendor names are not, which keeps detection +/// working on this workspace's own Hyper-V host -- checked, it reports +/// manufacturer `Microsoft Corporation` and product `Virtual Machine` -- while +/// leaving physical hardware from the same vendors alone. const HYPERVISOR_MARKERS: &[&str] = &[ "vmware", "virtualbox", @@ -187,32 +203,53 @@ const HYPERVISOR_MARKERS: &[&str] = &[ "parallels", "bhyve", "amazon ec2", - "google", - "microsoft corporation", + "google compute engine", + "virtual machine", "hyper-v", ]; fn detect_virtualisation() -> (VirtualisationHint, Option) { const KEY: &str = r"HARDWARE\DESCRIPTION\System\BIOS"; - let manufacturer = read_registry_string(KEY, "SystemManufacturer"); - let product = read_registry_string(KEY, "SystemProductName"); + classify_virtualisation( + read_registry_string(KEY, "SystemManufacturer").as_deref(), + read_registry_string(KEY, "SystemProductName").as_deref(), + ) +} +/// Decide what the firmware strings say about virtualisation. +/// +/// Split from the registry read so the decision can be tested against the +/// strings real machines report. The false positive this replaced was in the +/// *rule*, not in the reading, and no test could reach the rule while the two +/// were one function. +fn classify_virtualisation( + manufacturer: Option<&str>, + product: Option<&str>, +) -> (VirtualisationHint, Option) { if manufacturer.is_none() && product.is_none() { return (VirtualisationHint::Unknown, None); } - for candidate in [manufacturer, product].into_iter().flatten() { - let lowered = candidate.to_lowercase(); - if HYPERVISOR_MARKERS + let matched = [manufacturer, product].into_iter().flatten().any(|field| { + let lowered = field.to_lowercase(); + HYPERVISOR_MARKERS .iter() .any(|marker| lowered.contains(marker)) - { - return (VirtualisationHint::Detected, Some(candidate)); - } + }); + if !matched { + return (VirtualisationHint::NotDetected, None); } - (VirtualisationHint::NotDetected, None) + // Both fields, not whichever one matched. "Microsoft Corporation Virtual + // Machine" tells a reader which hypervisor; "Virtual Machine" alone does + // not, and that string is what a collector sees months later. + let described = [manufacturer, product] + .into_iter() + .flatten() + .collect::>() + .join(" "); + (VirtualisationHint::Detected, Some(described)) } /// Read one string value from `HKEY_LOCAL_MACHINE`. diff --git a/crates/windows-placement-probe/src/machine/tests.rs b/crates/windows-placement-probe/src/machine/tests.rs index 7d1dcc55..818e3c23 100644 --- a/crates/windows-placement-probe/src/machine/tests.rs +++ b/crates/windows-placement-probe/src/machine/tests.rs @@ -7,7 +7,7 @@ //! suppression, that nothing forbidden is read -- rather than any particular //! value, which would be an assertion about whatever host ran the suite. -use super::{MachineDescription, VirtualisationHint}; +use super::{MachineDescription, VirtualisationHint, classify_virtualisation}; #[test] fn the_default_hint_is_not_a_claim_of_bare_metal() { @@ -159,3 +159,105 @@ fn a_detected_hypervisor_names_itself() { ), } } + +// --------------------------------------------------------------------------- +// Virtualisation detection. +// +// These use the strings real machines report, because the defect they guard was +// a rule that looked reasonable and was wrong about specific hardware: a vendor +// name that a hypervisor and a laptop both carry. +// --------------------------------------------------------------------------- + +#[test] +fn a_physical_surface_is_not_reported_as_virtualised() { + // **The false positive.** `Microsoft Corporation` is the manufacturer of a + // Hyper-V guest and of every Surface, so matching the vendor marked real + // hardware as a VM -- invisibly to the submitter, and unquestionable by + // anyone reading the collected data later. + let (hint, name) = + classify_virtualisation(Some("Microsoft Corporation"), Some("Surface Pro 9")); + + assert_eq!(hint, VirtualisationHint::NotDetected, "got {name:?}"); + assert_eq!(name, None); +} + +#[test] +fn a_hyper_v_guest_is_still_detected() { + // The case that must keep working, and the reason the vendor marker was + // there. This workspace's own host reports exactly these two strings. + let (hint, name) = + classify_virtualisation(Some("Microsoft Corporation"), Some("Virtual Machine")); + + assert_eq!(hint, VirtualisationHint::Detected); + assert_eq!( + name.as_deref(), + Some("Microsoft Corporation Virtual Machine") + ); +} + +#[test] +fn physical_hardware_from_a_cloud_vendor_is_not_virtualised() { + // The same shape as the Surface case: `Google` names both a cloud and a + // laptop, so only the product marker decides. + let (physical, _) = classify_virtualisation(Some("Google"), Some("Pixelbook")); + let (cloud, _) = classify_virtualisation(Some("Google"), Some("Google Compute Engine")); + + assert_eq!(physical, VirtualisationHint::NotDetected); + assert_eq!(cloud, VirtualisationHint::Detected); +} + +#[test] +fn the_common_hypervisors_are_detected_from_either_field() { + for (manufacturer, product) in [ + ("VMware, Inc.", "VMware Virtual Platform"), + ("innotek GmbH", "VirtualBox"), + ("QEMU", "Standard PC (Q35 + ICH9, 2009)"), + ("Xen", "HVM domU"), + ("Parallels International", "Parallels Virtual Platform"), + ("Amazon EC2", "t3.medium"), + ] { + let (hint, _) = classify_virtualisation(Some(manufacturer), Some(product)); + assert_eq!( + hint, + VirtualisationHint::Detected, + "{manufacturer} / {product} was not detected" + ); + } +} + +#[test] +fn ordinary_hardware_is_left_alone() { + for (manufacturer, product) in [ + ("Dell Inc.", "XPS 15 9520"), + ("LENOVO", "20XW00"), + ("ASUSTeK COMPUTER INC.", "ROG STRIX"), + ("Apple Inc.", "MacBookPro18,3"), + ] { + let (hint, _) = classify_virtualisation(Some(manufacturer), Some(product)); + assert_eq!( + hint, + VirtualisationHint::NotDetected, + "{manufacturer} / {product} was called a virtual machine" + ); + } +} + +#[test] +fn firmware_that_says_nothing_is_unknown_rather_than_physical() { + // Unknown and NotDetected are different answers: one is "asked and told + // no", the other is "could not ask", and a reader deciding how much to + // trust a submission needs to tell them apart. + let (hint, name) = classify_virtualisation(None, None); + + assert_eq!(hint, VirtualisationHint::Unknown); + assert_eq!(name, None); +} + +#[test] +fn one_readable_field_is_enough_to_answer() { + let (hint, _) = classify_virtualisation(None, Some("VMware Virtual Platform")); + assert_eq!(hint, VirtualisationHint::Detected); + + let (hint, _) = classify_virtualisation(Some("Dell Inc."), None); + assert_eq!(hint, VirtualisationHint::NotDetected); +} diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index 2cd42ab9..f12c72ce 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -111,7 +111,11 @@ pub struct SubmissionRecord { /// an omitted field: a collector can then tell "measured, none exist" from /// "this version did not report them". pub node_hops: Vec, - /// One entry per efficiency class, comparing like with like. + /// One entry per efficiency class, per strategy, comparing like with like. + /// + /// Two rows per class. The pair is chosen once and measured under each + /// strategy, so rows that differ only in `strategy` are the comparison + /// rather than duplicates. pub by_class: Vec, } From f498a5baab893e10982f110537fa8108957d576b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 22:39:31 -0400 Subject: [PATCH 098/361] fix(placement-probe): watch the shared ref dir so a linked worktree's stamp refreshes A linked worktree keeps a private HEAD but shares every ref with the repository it was created from, naming that directory in a commondir file. Joining refs/heads/ onto the private gitdir missed, so nothing beyond HEAD was watched -- and on a branch HEAD is exactly the file a commit does not touch. Since a rerun-if-changed directive replaces cargo's package-wide default, the build script never re-ran and the stamp went stale: the failure watch_git_head exists to prevent, surviving in a layout it was written to cover. Measured in a throwaway git worktree add: before, only HEAD was emitted and a commit did not re-run the script; after, .git/worktrees//../../refs/heads/ is emitted and a commit does re-run it. A submodule has no commondir and is unaffected; the ordinary layout emits the same two paths as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/build.rs | 26 +++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/windows-placement-probe/build.rs b/crates/windows-placement-probe/build.rs index 3b73428c..d1a07373 100644 --- a/crates/windows-placement-probe/build.rs +++ b/crates/windows-placement-probe/build.rs @@ -136,15 +136,37 @@ fn watch_git_head(git_dir: &Path) { return; }; + // **Refs may live somewhere other than the gitdir that holds `HEAD`.** A + // linked worktree keeps a private `HEAD` -- correctly, since each worktree + // is on its own branch -- but shares every ref with the repository it was + // created from, naming that shared directory in a `commondir` file beside + // the private `HEAD`. Looking for the ref in the private gitdir finds + // nothing, so watching would stop at `HEAD`: on a branch, precisely the file + // that does not change when you commit, which is the silent staleness this + // whole function exists to prevent, surviving in a layout it was meant to + // cover. + // + // Measured before fixing rather than reasoned about: in a throwaway + // `git worktree add`, a commit left the worktree's `HEAD` untouched while + // the ref under `commondir` was rewritten eight seconds later. + // + // A submodule has no `commondir` and keeps its refs in the gitdir its + // `gitdir:` redirect names, so it falls through to `git_dir` unchanged. + let refs_dir = match fs::read_to_string(git_dir.join("commondir")) { + // Relative to the gitdir the file sits in (git writes `../..`). + Ok(common) => git_dir.join(common.trim()), + Err(_) => git_dir.clone(), + }; + // A loose ref is rewritten on every commit. A ref that has been packed does // not exist as a file, and `packed-refs` is what changes instead -- so // whichever of the two is present is the one to watch. Emitting a path that // does not exist would make cargo re-run this script on every single build. - let loose = git_dir.join(reference.trim()); + let loose = refs_dir.join(reference.trim()); if loose.exists() { watch(&loose); } else { - let packed = git_dir.join("packed-refs"); + let packed = refs_dir.join("packed-refs"); if packed.exists() { watch(&packed); } From 5091386c71e0d256d909fffd6d88f89a6559bc9f Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Mon, 31 Aug 2026 22:56:25 -0400 Subject: [PATCH 099/361] test(topology): close three real gaps a mutation run found A `cargo mutants` run over windows-topology-sys reported 71 caught and 61 missed. Reading the misses rather than the headline changes the picture completely: 57 of the 61 are in `mod serde_impl`, which is `#[cfg(feature = "serde")]`, and the run was invoked without that feature. Those mutants were applied to source that was never compiled into the test binary, so the suite passed trivially. They are artifacts of the invocation, not gaps -- the crate has 29 serde tests that the same invocation also compiled out. That leaves four genuine misses, and one of those is uncatchable: replacing `ProcessorSet::empty` with `Default::default()` is a truly equivalent mutant, because `empty()` is literally `Self::default()`. No test can distinguish them. The remaining three are real, and all three are now caught -- each verified by re-injecting the mutation and confirming the suite fails, rather than assumed from a passing run. `Topology::processors_from` matched a core domain with a guard testing that the domain actually contains the processor being described. Replacing that guard with `true` or with `false` changed nothing observable. Both are serious: with `true` every processor takes the FIRST core domain's efficiency class, so on a heterogeneous machine the performance cores report the efficiency cores' class; with `false` nothing matches and every processor reports capacity 0. Nothing caught either because the only test reaching this code asserted the *count* of processors, and the serde round-trip compares a discovered topology against itself -- identically wrong on both sides. This matters beyond the crate: `capacity` is the public answer to "what class is this processor", and the affinity probes added this week make placement decisions in those terms. The new tests use a synthetic two-core, two-class fixture rather than the real machine, so they assert the mapping on every host instead of only a heterogeneous one, and cover the offline-slot path separately so that check is tested by something other than the guard's own tests. `ProcessorSet::is_empty` replaced with `true` also survived: both existing tests asserted only that an *empty* set reports empty, so a predicate that always said "yes" satisfied all of them. One-sided assertions on a boolean accessor are the classic shape of this gap, and the tests were individually correct while jointly proving nothing. 62 tests without serde, 91 with. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/processor_set/tests.rs | 31 ++++ .../src/topology/tests.rs | 156 ++++++++++++++++++ 2 files changed, 187 insertions(+) diff --git a/crates/windows-topology-sys/src/processor_set/tests.rs b/crates/windows-topology-sys/src/processor_set/tests.rs index 372c7f62..cc5c4a6f 100644 --- a/crates/windows-topology-sys/src/processor_set/tests.rs +++ b/crates/windows-topology-sys/src/processor_set/tests.rs @@ -175,3 +175,34 @@ fn deserializing_a_well_formed_description_produces_the_expected_set() { assert!(set.contains(1, 0)); assert_eq!(set.len(), 3); } + +#[test] +fn a_populated_set_is_not_empty() { + // A `cargo mutants` run replaced `is_empty` with `true` and the suite + // passed: every existing test asserted only that an *empty* set reports + // empty, so a predicate that always said "yes" satisfied all of them. + // + // One-sided assertions on a boolean accessor are the classic shape of this + // gap -- the tests were correct and jointly proved nothing. + let mut set = ProcessorSet::empty(); + set.insert(0, 3); + assert!(!set.is_empty(), "a set with a member is not empty"); + assert_eq!(set.len(), 1); +} + +#[test] +fn a_set_built_from_a_nonzero_mask_is_not_empty() { + let set = ProcessorSet::from_group_mask(0, 0b1011); + assert!(!set.is_empty()); + assert_eq!(set.len(), 3); +} + +#[test] +fn emptiness_tracks_the_contents_across_groups() { + // A member in any group is enough, which is what makes `is_empty` a + // statement about the whole set rather than about group 0. + let mut set = ProcessorSet::empty(); + assert!(set.is_empty()); + set.insert(5, 0); + assert!(!set.is_empty(), "a member in a non-zero group still counts"); +} diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 4045238a..9008aafb 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -390,3 +390,159 @@ mod serde_provenance { assert_eq!(reloaded.distances, measured.distances); } } + +// --- capacity is the class of the processor's OWN core (mutation-testing gap) --- +// +// A `cargo mutants` run replaced the match guard in `processors_from` -- the +// test that a core domain actually contains the processor being described -- +// with both `true` and `false`, and the whole suite passed either way. +// +// Both mutants are real defects. With `true`, every processor takes the first +// core domain's efficiency class, so on a heterogeneous machine the performance +// cores would be reported with the efficiency cores' class. With `false`, no +// domain ever matches and every processor reports capacity 0. +// +// Nothing caught them because the only test reaching `processors_from` asserted +// the *count* of processors, and the serde round-trip compares a discovered +// topology against itself -- identically wrong on both sides of the comparison. +// +// These use a synthetic two-core, two-class fixture rather than the real +// machine, so they assert the mapping on every host rather than only on a +// heterogeneous one. + +/// Two processors on two cores of different efficiency classes. +fn heterogeneous_relations() -> (crate::relation::Relations, Vec) { + use crate::relation::{CoreRelation, GroupRelation, Relations}; + + let cpu0 = ProcessorSet::from_group_mask(0, 0b01); + let cpu1 = ProcessorSet::from_group_mask(0, 0b10); + + let relations = Relations { + cores: vec![ + CoreRelation { + simultaneous_multithreading: false, + efficiency_class: 0, + processors: cpu0.clone(), + }, + CoreRelation { + simultaneous_multithreading: false, + efficiency_class: 1, + processors: cpu1.clone(), + }, + ], + packages: Vec::new(), + dies: Vec::new(), + modules: Vec::new(), + caches: Vec::new(), + numa_nodes: Vec::new(), + groups: vec![GroupRelation { + group: 0, + maximum_processor_count: 2, + active_processor_count: 2, + active_processors: ProcessorSet::from_group_mask(0, 0b11), + }], + }; + + let domains = vec![ + Domain { + kind: DomainKind::Core { + simultaneous_multithreading: false, + efficiency_class: 0, + }, + id: 0, + processors: cpu0, + }, + Domain { + kind: DomainKind::Core { + simultaneous_multithreading: false, + efficiency_class: 1, + }, + id: 1, + processors: cpu1, + }, + ]; + + (relations, domains) +} + +#[test] +fn each_processor_takes_the_efficiency_class_of_its_own_core() { + let (relations, domains) = heterogeneous_relations(); + let processors = Topology::processors_from(&relations, &domains); + + assert_eq!(processors.len(), 2); + assert_eq!( + processors[0].capacity, 0, + "processor 0 belongs to the class-0 core" + ); + assert_eq!( + processors[1].capacity, 1, + "processor 1 belongs to the class-1 core, and must not inherit the \ + first core domain's class" + ); +} + +#[test] +fn a_processor_with_no_matching_core_domain_reports_no_capacity() { + // The other side of the same guard: a domain list that does not describe + // this processor must yield 0 rather than borrowing some other core's + // class. Windows reports relations only for active processors, so this is + // the inactive-slot path. + let (relations, _) = heterogeneous_relations(); + let processors = Topology::processors_from(&relations, &[]); + + assert_eq!(processors.len(), 2); + for processor in &processors { + assert_eq!( + processor.capacity, 0, + "with no core domains there is no class to report" + ); + } +} + +#[test] +fn an_offline_processor_reports_no_capacity_even_when_a_core_claims_it() { + use crate::relation::{CoreRelation, GroupRelation, Relations}; + + // A slot that exists but is not active. The core domain still names it, so + // only the `online` check keeps its capacity at 0 -- which makes this the + // test for that check rather than for the guard above. + let both = ProcessorSet::from_group_mask(0, 0b11); + let relations = Relations { + cores: vec![CoreRelation { + simultaneous_multithreading: false, + efficiency_class: 7, + processors: both.clone(), + }], + packages: Vec::new(), + dies: Vec::new(), + modules: Vec::new(), + caches: Vec::new(), + numa_nodes: Vec::new(), + groups: vec![GroupRelation { + group: 0, + maximum_processor_count: 2, + active_processor_count: 1, + // Only processor 0 is online. + active_processors: ProcessorSet::from_group_mask(0, 0b01), + }], + }; + let domains = vec![Domain { + kind: DomainKind::Core { + simultaneous_multithreading: false, + efficiency_class: 7, + }, + id: 0, + processors: both, + }]; + + let processors = Topology::processors_from(&relations, &domains); + + assert!(processors[0].online); + assert_eq!(processors[0].capacity, 7); + assert!(!processors[1].online); + assert_eq!( + processors[1].capacity, 0, + "an offline slot's capacity is not invented from a domain that names it" + ); +} From 4d44b5daa704b5b7940565a4cb1a68e2fc93e436 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Mon, 31 Aug 2026 23:03:00 -0400 Subject: [PATCH 100/361] test(topology): verify the float-to-integer coercion the deserializer promises The corrected mutation run -- with `serde` actually enabled -- leaves a coherent residue rather than scattered noise: every operator in `as_u64`'s and `as_i64`'s float guard survives. Replacing `n.fract() == 0.0 && range.contains(&n)` with `true` or `false`, flipping `==` to `!=`, or `&&` to `||`, all left the suite green. No test had ever fed a float into an integer field, so the coercion rule was entirely unverified. That guard is not incidental code. It was added as a PR #20 review response, specifically to stop the silent precision loss the older f64-for-everything encoding had, and the reasoning is written out in its doc comment. A correction made in response to review and then never verified is the worst version of this gap: the argument is on record and the behaviour is not. It also matters in practice, because an open hand-writable schema is the whole point of the serde support. JSON has one number type, so a generator emitting `1024.0` for a byte count is entirely ordinary and must be accepted, while one emitting `1024.5` is a defect that must be refused rather than truncated into a plausible-looking machine description. Eleven tests now cover both directions of both guards: whole-number floats accepted, fractional refused, negative refused for the unsigned field and accepted for the signed one (which is what distinguishes `as_i64`'s guard from `as_u64`'s rather than duplicating it), out-of-range refused rather than saturated, zero accepted at the inclusive boundary, and plain integer literals still parsing so a guard that refused everything could not pass the group. Five representative mutants re-injected and confirmed caught, rather than assumed from a passing run. 101 tests with serde, up from 91. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-topology-sys/src/domain/tests.rs | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) diff --git a/crates/windows-topology-sys/src/domain/tests.rs b/crates/windows-topology-sys/src/domain/tests.rs index c8edfd00..410d0f9a 100644 --- a/crates/windows-topology-sys/src/domain/tests.rs +++ b/crates/windows-topology-sys/src/domain/tests.rs @@ -399,4 +399,146 @@ mod serde_tests { "error should name the missing field: {error}" ); } + + // --- float-to-integer coercion (mutation-testing gap) --- + // + // `as_u64`/`as_i64` accept a JSON float only when it is a whole number + // inside the target's range. That guard was added as a PR #20 review + // response, specifically to stop the silent precision loss the older + // f64-for-everything encoding had -- and a `cargo mutants` run found that + // *every* operator in it survives mutation. Replacing the whole guard with + // `true` or `false`, flipping `==` to `!=`, or `&&` to `||`, all left the + // suite green, because no test ever fed a float into an integer field. + // + // A correction made in response to review, never verified, is the worst + // case of this: the reasoning is on record and the behaviour is not. + // + // These matter for a hand-written or fed-in description, which is the whole + // reason the schema is open: JSON has one number type, so a generator that + // emits 4.0 for a count is entirely ordinary, and one that emits 4.5 is a + // defect that must be refused rather than truncated. + + /// A memory domain whose `memory_bytes` is written as the given JSON number + /// literal, which is the shortest path to `as_u64`. + fn memory_domain_with(bytes_literal: &str) -> Result { + let json = format!( + r#"{{"kind": "memory", "id": 0, "processors": [], "memory_bytes": {bytes_literal}}}"# + ); + serde_json::from_str(&json) + } + + #[test] + fn a_whole_number_float_is_accepted_as_an_unsigned_field() { + let domain = memory_domain_with("1024.0").expect("4.0 is a whole number"); + assert_eq!( + domain.kind, + DomainKind::Memory { + memory_bytes: Some(1024) + }, + "a generator emitting a whole number as a float is ordinary JSON" + ); + } + + #[test] + fn a_fractional_float_is_refused_rather_than_truncated() { + // The precision-loss case the guard exists for. Truncating to 1024 + // would be silent data corruption in a field describing a machine. + assert!( + memory_domain_with("1024.5").is_err(), + "a fractional byte count must be refused, not rounded" + ); + } + + #[test] + fn a_negative_float_is_refused_for_an_unsigned_field() { + assert!( + memory_domain_with("-1.0").is_err(), + "a negative count is out of range for an unsigned field" + ); + } + + #[test] + fn a_float_beyond_the_unsigned_range_is_refused() { + // Above u64::MAX. `n as u64` would saturate silently, which is exactly + // the conversion the range half of the guard prevents. + assert!( + memory_domain_with("1e300").is_err(), + "a float larger than u64::MAX must be refused, not saturated" + ); + } + + #[test] + fn zero_is_accepted_at_the_bottom_of_the_unsigned_range() { + // The boundary the range check includes. A guard written with an + // exclusive bound would wrongly refuse this. + let domain = memory_domain_with("0.0").expect("zero is in range"); + assert_eq!( + domain.kind, + DomainKind::Memory { + memory_bytes: Some(0) + } + ); + } + + #[test] + fn an_integer_literal_still_parses_unchanged() { + // The common path, asserted beside the float ones so a guard that + // refused everything could not pass this group. + let domain = memory_domain_with("4096").expect("plain integers parse"); + assert_eq!( + domain.kind, + DomainKind::Memory { + memory_bytes: Some(4096) + } + ); + } + + /// A cache domain whose `cache_type` carries a raw signed code, which is + /// the path to `as_i64`. + fn cache_domain_with_other_type(code_literal: &str) -> Result { + let json = format!( + r#"{{"kind": "cache", "id": 0, "processors": [], + "level": 2, "associativity": 8, "line_size": 64, + "size_bytes": 1024, "cache_type": {{"other": {code_literal}}}}}"# + ); + serde_json::from_str(&json) + } + + #[test] + fn a_whole_number_float_is_accepted_as_a_signed_field() { + let domain = cache_domain_with_other_type("9.0").expect("9.0 is a whole number"); + let DomainKind::Cache { cache_type, .. } = domain.kind else { + panic!("expected a cache domain"); + }; + assert_eq!(cache_type, CacheKind::Other(9)); + } + + #[test] + fn a_negative_whole_float_is_accepted_as_a_signed_field() { + // The signed range genuinely extends below zero -- a raw + // PROCESSOR_CACHE_TYPE is an i32 and is not guaranteed non-negative -- + // so this is the case that distinguishes `as_i64`'s guard from + // `as_u64`'s rather than duplicating it. + let domain = cache_domain_with_other_type("-3.0").expect("-3.0 is a whole number"); + let DomainKind::Cache { cache_type, .. } = domain.kind else { + panic!("expected a cache domain"); + }; + assert_eq!(cache_type, CacheKind::Other(-3)); + } + + #[test] + fn a_fractional_float_is_refused_for_a_signed_field() { + assert!( + cache_domain_with_other_type("-3.5").is_err(), + "a fractional cache-type code must be refused, not truncated" + ); + } + + #[test] + fn a_float_beyond_the_signed_range_is_refused() { + assert!( + cache_domain_with_other_type("-1e300").is_err(), + "a float below i64::MIN must be refused, not saturated" + ); + } } From 9706e6c93e4f578b18a26e1fa531da6724caf62b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 23:26:22 -0400 Subject: [PATCH 101/361] fix(topology): count cache partitions, not cache relationships, at any level Windows reports one relationship per cache, not per partition. Measured on the development host: L1 arrives as eight data domains plus eight instruction domains over the same eight processor pairs, so the probe printed 'L1 16 domain(s)' for a machine with eight L1 partitions and fed that doubled count to every policy in domain_counts. On a host whose L1i and L1d are its only cache domains the same miscount reports a level as partitioning when it divides nothing. Two consumers also swept a hard-coded 1..=4, though DomainKind::Cache::level is a u8, so a partitioning L5 was silently reported as absent. Both rules are now stated once, in the crate that owns the topology, as Topology::cache_levels, cache_partitions_at_level and outermost_partitioning_cache; the fingerprint, its processor-placement path and the probe summary all ask rather than restate, so they cannot disagree about which level divides a host or into how many parts. Verified end to end: probe-topology now reports L1 as 8 domains, L2 8, L3 1. Both guards were sabotage-checked -- removing the dedup fails four tests, reinstating the 1..=4 ceiling fails the L5 test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/fingerprint.rs | 38 ++--- .../windows-platform-probes/src/topology.rs | 32 ++-- crates/windows-topology-sys/src/topology.rs | 74 +++++++++ .../src/topology/tests.rs | 140 ++++++++++++++++++ 4 files changed, 256 insertions(+), 28 deletions(-) diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index b1bf67e8..b9974bcd 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -335,19 +335,21 @@ impl Fingerprint { } efficiency_classes.sort_unstable(); - // The outermost level that actually divides the machine. A level with - // one domain covers everything and partitions nothing. + // The outermost level that actually divides the machine, asked of the + // topology rather than recomputed here: `Topology` owns that rule, and + // a second statement of it drifts. It also deduplicates a level + // reported once per cache -- an L1 arriving as separate `data` and + // `instruction` domains over the same processors is two relationships + // but one partition, and counting relationships would put a doubled + // domain count into the fingerprint. let mut partitioning_cache_level = None; let mut cache_domain_sizes = Vec::new(); - for level in 1..=4_u8 { - let sizes: Vec = topology - .caches_at_level(level) + if let Some((level, partitions)) = topology.outermost_partitioning_cache() { + partitioning_cache_level = Some(level); + cache_domain_sizes = partitions + .iter() .map(|domain| domain.processors.len()) .collect(); - if sizes.len() > 1 { - partitioning_cache_level = Some(level); - cache_domain_sizes = sizes; - } } cache_domain_sizes.sort_unstable(); if partitioning_cache_level.is_none() { @@ -490,17 +492,15 @@ pub fn places_from_topology(topology: &Topology) -> Vec { } } - // The outermost cache level that actually divides the machine, matching - // the fingerprint's own rule so the two cannot disagree. + // The outermost cache level that actually divides the machine. This calls + // the same `Topology` method the fingerprint does, rather than repeating + // the rule, so the two cannot disagree about which level partitions the + // host or about how many partitions it has. let mut cache_of = std::collections::BTreeMap::new(); - for level in 1..=4_u8 { - let domains: Vec<_> = topology.caches_at_level(level).collect(); - if domains.len() > 1 { - cache_of.clear(); - for domain in domains { - for id in domain.processors.iter() { - cache_of.insert(id, domain.id); - } + if let Some((_, partitions)) = topology.outermost_partitioning_cache() { + for domain in partitions { + for id in domain.processors.iter() { + cache_of.insert(id, domain.id); } } } diff --git a/crates/windows-platform-probes/src/topology.rs b/crates/windows-platform-probes/src/topology.rs index 61e173d5..e841b91b 100644 --- a/crates/windows-platform-probes/src/topology.rs +++ b/crates/windows-platform-probes/src/topology.rs @@ -45,9 +45,14 @@ use windows_topology_sys::{DomainKind, Topology}; pub struct CacheLevel { /// 1, 2, 3, ... as the firmware reports it. pub level: u8, - /// How many distinct domains exist at this level. + /// How many distinct processor *partitions* exist at this level. + /// + /// Not the number of caches: a level Windows reports once per cache -- L1 + /// as separate `data` and `instruction` domains over the same processors -- + /// is several relationships but one partition per processor set, and it is + /// the partition a caller dividing work cares about. pub domains: usize, - /// Processors per domain, in discovery order. + /// Processors per partition, in discovery order. pub processors_per_domain: Vec, } @@ -214,17 +219,26 @@ pub fn measure() -> io::Result { efficiency_class: *efficiency_class, processors: domain.processors.len(), }), - DomainKind::Cache { level, .. } => { - let count = domain.processors.len(); - match by_level.iter_mut().find(|(l, _)| l == level) { - Some((_, spans)) => spans.push(count), - None => by_level.push((*level, vec![count])), - } - } _ => {} } } + // Asked of the topology rather than counted from `domains` above, because + // Windows reports one relationship per *cache* and not per partition. + // Measured here: L1 arrives as eight `data` domains plus eight + // `instruction` domains over the same eight processor pairs, so counting + // relationships printed "L1 16 domain(s)" on a machine with eight L1 + // partitions -- and fed a doubled count to every policy in + // `domain_counts`. + for level in topology.cache_levels() { + let spans = topology + .cache_partitions_at_level(level) + .iter() + .map(|domain| domain.processors.len()) + .collect(); + by_level.push((level, spans)); + } + by_level.sort_by_key(|(level, _)| *level); let caches = by_level .into_iter() diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index fe3b16cb..f50bc98d 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -214,6 +214,80 @@ impl Topology { ) } + /// Every cache level this machine reports, ascending, without repeats. + /// + /// Derived from what the topology actually contains rather than from a + /// fixed ceiling. [`DomainKind::Cache`]'s `level` is a `u8`, so a caller + /// that sweeps a hard-coded `1..=4` silently reports a partitioning L5 as + /// absent -- a wrong answer that looks like a confident one. + pub fn cache_levels(&self) -> Vec { + let mut levels: Vec = self + .caches() + .filter_map(|d| match &d.kind { + DomainKind::Cache { level, .. } => Some(*level), + _ => None, + }) + .collect(); + levels.sort_unstable(); + levels.dedup(); + levels + } + + /// The distinct processor partitions the caches at `level` form. + /// + /// # Why this is not [`Self::caches_at_level`] + /// + /// Windows reports one relationship per *cache*, not per partition, and a + /// level is routinely reported more than once over the very same + /// processors. Measured on the eight-core development host rather than + /// reasoned about: L1 arrives as eight `data` domains **plus** eight + /// `instruction` domains covering exactly the same eight processor pairs. + /// + /// Counting relationships therefore claims sixteen L1 partitions where the + /// machine has eight. Two consequences, both silent: a partition count that + /// is a whole multiple too large, and -- on a machine whose L1i and L1d are + /// its only two cache domains -- a level reported as *partitioning* when it + /// divides nothing at all. + /// + /// Deduplication is by processor set, which is the thing a caller + /// partitioning work actually cares about; the first domain covering each + /// distinct set is kept, so the returned ids are stable for a topology. + pub fn cache_partitions_at_level(&self, level: u8) -> Vec<&Domain> { + let mut partitions: Vec<&Domain> = Vec::new(); + for domain in self.caches_at_level(level) { + if !partitions + .iter() + .any(|kept| kept.processors == domain.processors) + { + partitions.push(domain); + } + } + partitions + } + + /// The outermost cache level that actually divides this machine, together + /// with the distinct partitions it forms. + /// + /// A level whose caches all cover the same processors partitions nothing -- + /// a fully shared L3 is one domain spanning everything -- so it is never a + /// candidate however far out it sits. `None` means no reported level + /// divides the machine, which is a real answer and not a failure: a + /// single-core host is one partition by every measure. + /// + /// This is deliberately not "level 3". A shipping ARM64 laptop measured + /// during the 2026-08-30 session reports **no L3 at all**, with two L2 + /// domains of six processors forming the real cluster boundary. + /// + /// Defined here, in the crate that owns the topology, so that every + /// consumer asks the same question rather than restating the rule and + /// drifting from it. + pub fn outermost_partitioning_cache(&self) -> Option<(u8, Vec<&Domain>)> { + self.cache_levels().into_iter().rev().find_map(|level| { + let partitions = self.cache_partitions_at_level(level); + (partitions.len() > 1).then_some((level, partitions)) + }) + } + /// Every memory domain, including one with no processors (D-5). pub fn memory_domains(&self) -> impl Iterator { self.domains diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 9008aafb..cc97f52c 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -546,3 +546,143 @@ fn an_offline_processor_reports_no_capacity_even_when_a_core_claims_it() { "an offline slot's capacity is not invented from a domain that names it" ); } + +/// A machine of `cores` two-processor cores, each with the split L1 that real +/// firmware reports, plus a shared last-level cache. +/// +/// The split L1 is the point: Windows reports one relationship per *cache*, so +/// a core contributes an L1 `data` domain **and** an L1 `instruction` domain +/// covering exactly the same two processors. +fn split_l1_machine(cores: u32, last_level: u8) -> Topology { + let mut domains = Vec::new(); + let mut all = 0usize; + let mut id = 0u32; + for core in 0..cores { + let mask = 0b11usize << (core * 2); + all |= mask; + let processors = ProcessorSet::from_group_mask(0, mask); + for cache_type in [CacheKind::Data, CacheKind::Instruction] { + domains.push(Domain { + kind: DomainKind::Cache { + level: 1, + associativity: 8, + line_size: 64, + size_bytes: 32 * 1024, + cache_type, + }, + id, + processors: processors.clone(), + }); + id += 1; + } + } + domains.push(Domain { + kind: DomainKind::Cache { + level: last_level, + associativity: 16, + line_size: 64, + size_bytes: 32 * 1024 * 1024, + cache_type: CacheKind::Unified, + }, + id, + processors: ProcessorSet::from_group_mask(0, all), + }); + + Topology { + processors: Vec::new(), + domains, + distances: None, + provenance: Provenance::Synthetic, + } +} + +#[test] +fn cache_levels_are_ascending_and_without_repeats() { + // Sixteen L1 relationships, one L3, but only two distinct levels. + assert_eq!(split_l1_machine(8, 3).cache_levels(), vec![1, 3]); +} + +#[test] +fn cache_levels_are_empty_when_no_cache_is_reported() { + let topo = Topology { + processors: Vec::new(), + domains: Vec::new(), + distances: None, + provenance: Provenance::Synthetic, + }; + assert!(topo.cache_levels().is_empty()); +} + +#[test] +fn a_split_instruction_and_data_cache_is_one_partition_not_two() { + // The measured shape of the development host: eight cores, so sixteen L1 + // relationships over eight distinct processor pairs. Counting + // relationships reports twice as many partitions as the machine has. + let topo = split_l1_machine(8, 3); + assert_eq!(topo.caches_at_level(1).count(), 16); + assert_eq!(topo.cache_partitions_at_level(1).len(), 8); +} + +#[test] +fn cache_partitions_keep_the_first_domain_for_each_processor_set() { + let topo = split_l1_machine(2, 3); + let ids: Vec = topo + .cache_partitions_at_level(1) + .iter() + .map(|domain| domain.id) + .collect(); + // 0 and 2 are the `data` domains; 1 and 3 are the `instruction` domains + // covering the same processors, and are the ones dropped. + assert_eq!(ids, vec![0, 2]); +} + +#[test] +fn the_outermost_partitioning_cache_skips_a_level_that_covers_everything() { + // L3 spans the machine and so divides nothing, however far out it sits. + let topo = split_l1_machine(4, 3); + let (level, partitions) = topo.outermost_partitioning_cache().expect("L1 divides"); + assert_eq!(level, 1); + assert_eq!(partitions.len(), 4); +} + +#[test] +fn a_partitioning_cache_above_level_four_is_found() { + // `level` is a `u8`. A consumer sweeping a hard-coded `1..=4` reports this + // machine as having no partitioning cache at all. + let mut topo = split_l1_machine(1, 5); + // Replace the shared last level with two L5 partitions, so the dividing + // level is one a fixed `1..=4` ceiling cannot reach. + topo.domains.pop(); + for (id, mask) in [(100u32, 0b01usize), (101, 0b10)] { + topo.domains.push(Domain { + kind: DomainKind::Cache { + level: 5, + associativity: 16, + line_size: 64, + size_bytes: 64 * 1024 * 1024, + cache_type: CacheKind::Unified, + }, + id, + processors: ProcessorSet::from_group_mask(0, mask), + }); + } + let (level, partitions) = topo.outermost_partitioning_cache().expect("L5 divides"); + assert_eq!(level, 5); + assert_eq!(partitions.len(), 2); +} + +#[test] +fn a_single_core_split_l1_partitions_nothing() { + // The false positive deduplication removes: two cache relationships over + // one processor set is one partition, so no level divides this machine and + // a caller must not be told L1 does. + let topo = split_l1_machine(1, 3); + assert_eq!(topo.caches_at_level(1).count(), 2); + assert_eq!(topo.cache_partitions_at_level(1).len(), 1); + assert!(topo.outermost_partitioning_cache().is_none()); +} + +#[test] +fn a_machine_with_no_cache_at_all_has_no_partitioning_cache() { + assert!(synthetic().outermost_partitioning_cache().is_none()); +} From 28f0486205df8e8e91f0d5d1ffd47f7e0c42142a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 23:33:33 -0400 Subject: [PATCH 102/361] fix(placement-probe): render the by-class comparison and make its rows attributable measure() populates by_class and the pre-run plan advertises "N efficiency class comparison(s)", but render() never walked the list, so an entire measured dimension reached only the raw JSON. A runner reading the printed report -- which is what the tool asks them to read before deciding whether to send the file -- saw a promise the output never kept. The record was incomplete too, which is the worse half: MeasurementRecord flattened producer and consumer to group, number and NUMA node but dropped the efficiency class, and by_class holds one same-class pair per class whose rows agree on placement and strategy by construction. Nothing in a v2 record said which row described the fast cores, so the omission was not merely a rendering gap. Both endpoints now carry producer_efficiency_class and consumer_efficiency_class. SCHEMA_VERSION is raised to 3 with a derived v3 golden rather than editing v2 in place. v2 has never been released, but this tool exists to emit records people paste, and v2 records were produced locally while developing it -- so it is treated as escaped. v3.txt records that a v2 row lacks the fields rather than measuring class 0. Sabotage-checked: removing the render_by_class call fails both new tests. The class-column assertion parses the class field specifically, because a substring search for "0" passes against a table that prints no class at all -- every row carries times like "10.5". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/schema/v3.txt | 97 +++++++++++++++++++ crates/windows-placement-probe/src/record.rs | 21 +++- .../src/record/tests.rs | 4 + crates/windows-placement-probe/src/report.rs | 51 ++++++++++ .../src/report/tests.rs | 73 ++++++++++++++ 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 crates/windows-placement-probe/schema/v3.txt diff --git a/crates/windows-placement-probe/schema/v3.txt b/crates/windows-placement-probe/schema/v3.txt new file mode 100644 index 00000000..40022ea0 --- /dev/null +++ b/crates/windows-placement-probe/schema/v3.txt @@ -0,0 +1,97 @@ +# Schema v3 for windows-placement-probe submission records. +# +# Every key path the record serializes to, sorted. Derived by serializing a +# fully populated record and walking the result -- never written by hand, so it +# cannot drift from the type it describes. +# +# APPEND-ONLY. Never edit a published version: records already in the wild +# claim this number, and they cannot be regenerated. To change the shape, raise +# SCHEMA_VERSION and add the next file beside this one. +# +# Changed from v2: every measurement row gains "producer_efficiency_class" and +# "consumer_efficiency_class". Without them a "by_class" row could not be +# attributed to the class it measures -- that list holds one same-class pair +# per class, so its rows agree on "placement" and "strategy" and were +# indistinguishable in v2. A v2 row does not carry them; do not assume a class +# of 0, because the field was absent rather than measured as zero. + +build +build.commit +build.crate_version +build.dirty +build.source +by_class +by_class[] +by_class[].consumer_batch +by_class[].consumer_efficiency_class +by_class[].consumer_group +by_class[].consumer_numa_node +by_class[].consumer_number +by_class[].memory_node +by_class[].nanos_per_item +by_class[].placement +by_class[].producer_batch +by_class[].producer_efficiency_class +by_class[].producer_group +by_class[].producer_numa_node +by_class[].producer_number +by_class[].slice +by_class[].strategy +host +host.arch +host.cache_domain_sizes +host.cache_domain_sizes[] +host.cores +host.efficiency_classes +host.efficiency_classes[] +host.efficiency_classes[][] +host.numa_node_sizes +host.numa_node_sizes[] +host.partitioning_cache_level +host.processors +host.provenance +host.smt +machine +machine.cpu_model +machine.model_suppressed +machine.os_build +machine.virtualisation +machine.virtualisation_name +node_hops +node_hops[] +node_hops[].consumer_batch +node_hops[].consumer_efficiency_class +node_hops[].consumer_group +node_hops[].consumer_numa_node +node_hops[].consumer_number +node_hops[].memory_node +node_hops[].nanos_per_item +node_hops[].placement +node_hops[].producer_batch +node_hops[].producer_efficiency_class +node_hops[].producer_group +node_hops[].producer_numa_node +node_hops[].producer_number +node_hops[].slice +node_hops[].strategy +placements +placements[] +placements[].consumer_batch +placements[].consumer_efficiency_class +placements[].consumer_group +placements[].consumer_numa_node +placements[].consumer_number +placements[].memory_node +placements[].nanos_per_item +placements[].placement +placements[].producer_batch +placements[].producer_efficiency_class +placements[].producer_group +placements[].producer_numa_node +placements[].producer_number +placements[].slice +placements[].strategy +recorded_at +recorded_at_epoch_seconds +schema_version +topology_provenance diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index f12c72ce..8be6fedb 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -51,7 +51,7 @@ use crate::machine::MachineDescription; /// **The golden files are append-only and a published version is never /// redefined.** Once a record exists in the wild claiming schema N, N's meaning /// is fixed, because that record cannot be regenerated. -pub const SCHEMA_VERSION: u32 = 2; +pub const SCHEMA_VERSION: u32 = 3; /// One run's complete output. #[derive(Clone, Debug)] @@ -139,12 +139,29 @@ pub struct MeasurementRecord { pub producer_number: u8, /// The producer's NUMA node. pub producer_numa_node: u32, + /// The producer's efficiency class. + /// + /// **Without this a `by_class` row cannot be attributed to the class it + /// measures.** That list holds one same-class pair per class, so two rows + /// agreeing on `placement` and `strategy` are the comparison rather than + /// duplicates -- and a reader with no class field has no way to tell which + /// row describes the fast cores. Carried on every row, not only those, so + /// a heterogeneous pair in `placements` or `node_hops` is legible too: + /// Windows will schedule across classes, and a hop between a performance + /// core and an efficiency core is a different measurement from one between + /// two peers. + pub producer_efficiency_class: u8, /// The consumer's processor group. pub consumer_group: u16, /// The consumer's processor number within its group. pub consumer_number: u8, /// The consumer's NUMA node. pub consumer_numa_node: u32, + /// The consumer's efficiency class. See + /// [`Self::producer_efficiency_class`]; in a `by_class` row the two are + /// equal by construction, and their being equal is what makes the row a + /// like-for-like comparison. + pub consumer_efficiency_class: u8, /// Median nanoseconds per item handed across the ring. pub nanos_per_item: f64, /// How many items each consumer-side shared read was amortised over. @@ -174,9 +191,11 @@ impl From<&Measurement> for MeasurementRecord { producer_group: measurement.producer.group, producer_number: measurement.producer.number, producer_numa_node: measurement.producer.numa_node, + producer_efficiency_class: measurement.producer.efficiency_class, consumer_group: measurement.consumer.group, consumer_number: measurement.consumer.number, consumer_numa_node: measurement.consumer.numa_node, + consumer_efficiency_class: measurement.consumer.efficiency_class, nanos_per_item: measurement.nanos_per_item, consumer_batch: measurement.consumer_batch, producer_batch: measurement.producer_batch, diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index a58a19d8..dd9e427f 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -30,9 +30,13 @@ pub(crate) fn fully_populated() -> SubmissionRecord { producer_group: 0, producer_number: 0, producer_numa_node: 0, + // Matching the `ec0` in the slice above. A fixture whose fields + // contradict its own slice string would teach the wrong shape. + producer_efficiency_class: 0, consumer_group: 0, consumer_number: 1, consumer_numa_node: 0, + consumer_efficiency_class: 0, nanos_per_item: 10.5, consumer_batch: 84.9, producer_batch: 1.0, diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index e02db2c5..25be60ce 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -26,6 +26,7 @@ pub fn render(record: &SubmissionRecord) -> String { render_header(&mut out, record); render_machine(&mut out, record); render_placements(&mut out, record); + render_by_class(&mut out, record); render_node_hops(&mut out, record); render_trust(&mut out, record); out @@ -127,6 +128,56 @@ fn render_placements(out: &mut String, record: &SubmissionRecord) { } } +fn render_by_class(out: &mut String, record: &SubmissionRecord) { + let _ = writeln!(out); + let _ = writeln!(out, "-- the handoff, by efficiency class --"); + + if record.by_class.is_empty() { + // Same reasoning as the empty node-hop table: on a homogeneous machine + // there is no second class to compare against, and saying so is a fact + // about the host rather than a measurement that failed. + let _ = writeln!( + out, + " Every core on this machine reports the same efficiency class, so" + ); + let _ = writeln!( + out, + " there is no fast-against-slow comparison to draw. On a machine" + ); + let _ = writeln!( + out, + " with performance and efficiency cores this table has a row each." + ); + return; + } + + // Class first, because it is what distinguishes these rows: the pair inside + // a class is same-class and same-cache by construction, so `placement` and + // `strategy` repeat down the table and only the class and the numbers move. + let _ = writeln!( + out, + "{:<8} {:<26} {:<10} {:>12} {:>12}", + "class", "placement", "strategy", "ns/item", "batch depth" + ); + for entry in &record.by_class { + let _ = writeln!( + out, + "{:<8} {:<26} {:<10} {:>12.1} {:>12.1}", + entry.producer_efficiency_class, + entry.placement, + entry.strategy, + entry.nanos_per_item, + entry.consumer_batch + ); + } + let _ = writeln!(out); + let _ = writeln!( + out, + " Higher class is the faster core. The values are only comparable" + ); + let _ = writeln!(out, " against each other on this machine."); +} + fn render_node_hops(out: &mut String, record: &SubmissionRecord) { let _ = writeln!(out); let _ = writeln!(out, "-- the handoff, by NUMA node pair --"); diff --git a/crates/windows-placement-probe/src/report/tests.rs b/crates/windows-placement-probe/src/report/tests.rs index a6c6c8fc..50784f13 100644 --- a/crates/windows-placement-probe/src/report/tests.rs +++ b/crates/windows-placement-probe/src/report/tests.rs @@ -269,3 +269,76 @@ fn the_placement_table_says_it_covers_one_direction() { "the placement table does not say what it covers:\n{text}" ); } + +#[test] +fn the_by_class_rows_are_rendered_and_labelled_by_class() { + // The defect this guards: `by_class` was measured, recorded, and then + // dropped from the report, so an entire dimension of the run existed only + // in the raw JSON. Rendering it is not enough on its own -- the rows of + // that list agree on placement and strategy by construction, so the class + // is the only thing distinguishing them and must appear. + let mut record = fully_populated(); + let mut fast = record.by_class[0].clone(); + fast.producer_efficiency_class = 1; + fast.consumer_efficiency_class = 1; + fast.nanos_per_item = 4.25; + record.by_class.push(fast); + + let text = render(&record); + + assert!( + text.contains("efficiency class"), + "the by-class section is missing entirely: {text}" + ); + for entry in &record.by_class { + assert!( + text.contains(&format!("{:.1}", entry.nanos_per_item)), + "the measurement for class {} is absent from the report: {text}", + entry.producer_efficiency_class + ); + } + // Both classes named in the *class column*, so the two rows can be told + // apart. Deliberately not a bare `contains("0")`: every row carries times + // like "10.5", so a substring search would pass on a table that never + // printed a class at all. + // Bounded at the next heading. Without that, the scan runs on into the node + // hop table, whose rows begin "0 -> 0" and so also start with a parsable + // number -- which is how the first draft of this test read a third class + // that the by-class table never printed. + let section = text + .split("-- the handoff, by efficiency class --") + .nth(1) + .expect("the by-class section must exist") + .split( + " +--", + ) + .next() + .expect("split always yields at least one part"); + let classes: Vec<&str> = section + .lines() + .filter_map(|line| line.split_whitespace().next()) + .filter(|first| first.parse::().is_ok()) + .collect(); + assert_eq!( + classes, + vec!["0", "1"], + "the class column must name each row's class: {section}" + ); +} + +#[test] +fn a_homogeneous_machine_says_why_there_is_no_class_comparison() { + // Same contract as the single-node hop table: an empty section must read as + // a fact about the host, not as a measurement that failed. + let mut record = fully_populated(); + record.by_class.clear(); + + let text = render(&record); + + assert!(text.contains("same efficiency class"), "got {text}"); + assert!( + !text.to_lowercase().contains("error"), + "a homogeneous machine was reported as an error: {text}" + ); +} From 79b9c4666a1bf3f604987f811a4a64eac2347cb2 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 31 Aug 2026 23:59:07 -0400 Subject: [PATCH 103/361] fix(placement-probe): watch for a loose ref appearing when the branch is packed A packed ref exists only as a line in packed-refs, but packing is a state and not a fate: the next commit on the branch writes a loose ref and leaves packed-refs untouched, so watching packed-refs alone sees nothing and cargo never re-runs the script. Measured on a local clone rather than reasoned about. After git pack-refs --all, a commit moved HEAD from 28f0486205df to 64259d9d8320 while the built binary went on reporting 28f0486205df. With the fix the same sequence tracks it: 64259d9d8320 to b33e9c98023e. The nearest existing ancestor directory is watched alongside packed-refs, because cargo takes a directory's newest contained mtime. Walking up matters for a slash-bearing branch name, whose intermediate directories need not exist while the ref is packed. The loose-ref case is unchanged and still watches the ref file exactly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/build.rs | 51 +++++++++++++++++++++---- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/crates/windows-placement-probe/build.rs b/crates/windows-placement-probe/build.rs index d1a07373..9bba1b39 100644 --- a/crates/windows-placement-probe/build.rs +++ b/crates/windows-placement-probe/build.rs @@ -158,18 +158,53 @@ fn watch_git_head(git_dir: &Path) { Err(_) => git_dir.clone(), }; - // A loose ref is rewritten on every commit. A ref that has been packed does - // not exist as a file, and `packed-refs` is what changes instead -- so - // whichever of the two is present is the one to watch. Emitting a path that - // does not exist would make cargo re-run this script on every single build. + // A loose ref is rewritten on every commit, so watching it is exact. let loose = refs_dir.join(reference.trim()); if loose.exists() { watch(&loose); - } else { - let packed = refs_dir.join("packed-refs"); - if packed.exists() { - watch(&packed); + return; + } + + // Otherwise the ref is packed: it exists only as a line in `packed-refs`, + // and that file is what a `git pack-refs` or `git gc` rewrites. + let packed = refs_dir.join("packed-refs"); + if packed.exists() { + watch(&packed); + } + + // **Packed is a state, not a fate, and this is the half that was missing.** + // The next commit on this branch writes a *loose* ref and leaves + // `packed-refs` completely untouched -- the stale packed line simply loses + // to the new file. So watching `packed-refs` alone sees nothing, cargo does + // not re-run, and the binary keeps the previous commit's stamp. + // + // Measured on a clone of this repository rather than reasoned about: after + // `git pack-refs --all`, a commit moved HEAD from 28f0486205df to + // 64259d9d8320 while the built binary went on reporting 28f0486205df. + // + // Cargo takes a watched directory's newest contained mtime, so naming the + // nearest existing ancestor catches the loose ref being created. It is + // deliberately coarse: another branch's commit re-runs this script too, + // which costs one cheap script run, while missing our own commit costs a + // wrong answer in a shipped record. + if let Some(ancestor) = nearest_existing_dir(&loose) { + watch(&ancestor); + } +} + +/// The closest ancestor of `path` that exists as a directory. +/// +/// A branch name may contain slashes, so the loose ref for `feature/x` sits at +/// `refs/heads/feature/x` and *neither* the file nor its `feature` directory +/// need exist while the ref is packed. Walking up finds the deepest directory +/// that does, which is the one that will observe the ref appearing. +fn nearest_existing_dir(path: &Path) -> Option { + let mut candidate = path.parent()?; + loop { + if candidate.is_dir() { + return Some(candidate.to_path_buf()); } + candidate = candidate.parent()?; } } From 2a3c1143282fdaccd5ee82b164c15450681d2890 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:00:39 -0400 Subject: [PATCH 104/361] fix(placement-probe): honour the CI source stamp only alongside the commit stamp PLACEMENT_PROBE_SOURCE=ci was accepted on its own. The commit and dirty flags then fell through to the local git fallback, and a clean checkout supplies a commit and dirty=false by itself -- so BuildIdentity::is_official returned true for a purely local build and the record carried no !!UNOFFICIAL!! marker. Measured before fixing: with only the source variable set, a local build reported 'v0.1.0 79b9c4666a1b [ci]'. After the fix the same build reports '!!UNOFFICIAL!! v0.1.0 79b9c4666a1b DIRTY [LOCAL]', while source plus commit still yields '[ci]' -- both release workflow sites set the pair together, so CI is unaffected. The claim and its evidence are now honoured as a unit, which is what this file already said it did: being unable to tell must resolve to untrusted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/build.rs | 28 +++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/crates/windows-placement-probe/build.rs b/crates/windows-placement-probe/build.rs index 9bba1b39..0bdece00 100644 --- a/crates/windows-placement-probe/build.rs +++ b/crates/windows-placement-probe/build.rs @@ -49,15 +49,31 @@ fn main() { println!("cargo::rerun-if-changed=Cargo.toml"); println!("cargo::rerun-if-changed=build.rs"); - let (commit, dirty) = match std::env::var(COMMIT_ENV) { - // CI knows the commit it checked out, and a CI checkout is clean by - // construction, so no `git` call is needed or wanted there. - Ok(sha) if !sha.trim().is_empty() => (Some(shorten(sha.trim())), Some(false)), - _ => (git_commit(), git_dirty()), + // CI knows the commit it checked out, and a CI checkout is clean by + // construction, so no `git` call is needed or wanted there. + let stamped_commit = std::env::var(COMMIT_ENV) + .ok() + .map(|sha| sha.trim().to_owned()) + .filter(|sha| !sha.is_empty()); + + let (commit, dirty) = match &stamped_commit { + Some(sha) => (Some(shorten(sha)), Some(false)), + None => (git_commit(), git_dirty()), }; + // **The two stamps are honoured as a unit, never singly.** `ci` is a claim + // about provenance, and the commit stamp is the evidence for it; accepting + // the claim alone lets a local build inherit the rest of its identity from + // the working tree and still pass `BuildIdentity::is_official`, because a + // clean checkout supplies a commit and `dirty = false` on its own. + // + // Measured before fixing: with only `PLACEMENT_PROBE_SOURCE=ci` set, a + // local build reported `v0.1.0 79b9c4666a1b [ci]` -- no `!!UNOFFICIAL!!` + // marker, so a record from it would have pooled with real CI results. + // That inverts this file's stated default, which is that being unable to + // tell must resolve to untrusted. let source = match std::env::var(SOURCE_ENV) { - Ok(value) if value.trim().eq_ignore_ascii_case("ci") => "ci", + Ok(value) if value.trim().eq_ignore_ascii_case("ci") && stamped_commit.is_some() => "ci", _ if commit.is_some() => "local", _ => "unknown", }; From 27aed68d1ee5c2dc1d310562b68593a381aa7db9 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:05:56 -0400 Subject: [PATCH 105/361] fix(placement-probe): record the requested NUMA node, not only the achieved one A directed hop is measured once per ring placement, so the two node_hops rows for a pair agree on every field except where the ring was asked to go. Only the achieved node was recorded -- and Windows may satisfy a NUMA allocation on a different node, which Slots::new_on deliberately tolerates rather than failing. Both rows could therefore serialise and print identically, collapsing the exact dimension that measuring both placements exists to expose, and a collector would reasonably read them as duplicates. Measurement and MeasurementRecord now carry requested_memory_node beside memory_node. The request identifies the row; the result is what it measured. Where the two disagree the row did not measure the placement it names, so the report keys its table on the request and lists every disagreement underneath rather than printing a redirected row as though it had succeeded. SCHEMA_VERSION is raised to 4 with a derived golden. v4.txt records that a v3 row cannot have its ring placement recovered. Sabotage-checked: keying the table on the achieved node again fails the new test. That test anchors its match at the start of the line, because the caveat under the table names the same pair and the node it landed on -- a substring search counted the caveat as a second table row. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/schema/v4.txt | 101 ++++++++++++++++++ .../src/core_affinity.rs | 26 ++++- crates/windows-placement-probe/src/record.rs | 18 +++- .../src/record/tests.rs | 4 + crates/windows-placement-probe/src/report.rs | 47 +++++++- .../src/report/tests.rs | 60 ++++++++++- 6 files changed, 246 insertions(+), 10 deletions(-) create mode 100644 crates/windows-placement-probe/schema/v4.txt diff --git a/crates/windows-placement-probe/schema/v4.txt b/crates/windows-placement-probe/schema/v4.txt new file mode 100644 index 00000000..238757bb --- /dev/null +++ b/crates/windows-placement-probe/schema/v4.txt @@ -0,0 +1,101 @@ +# Schema v4 for windows-placement-probe submission records. +# +# Every key path the record serializes to, sorted. Derived by serializing a +# fully populated record and walking the result -- never written by hand, so it +# cannot drift from the type it describes. +# +# APPEND-ONLY. Never edit a published version: records already in the wild +# claim this number, and they cannot be regenerated. To change the shape, raise +# SCHEMA_VERSION and add the next file beside this one. +# +# Changed from v3: every measurement row gains "requested_memory_node", the +# node the run asked for, beside "memory_node", the node it got. Windows may +# satisfy an allocation on a different node, so keyed on the achieved node +# alone the two "node_hops" rows for a directed pair can be identical; the +# requested node is what tells them apart. A row whose two nodes disagree did +# not measure the placement it names. A v3 row does not carry the request, so +# its ring placement cannot be recovered. + +build +build.commit +build.crate_version +build.dirty +build.source +by_class +by_class[] +by_class[].consumer_batch +by_class[].consumer_efficiency_class +by_class[].consumer_group +by_class[].consumer_numa_node +by_class[].consumer_number +by_class[].memory_node +by_class[].nanos_per_item +by_class[].placement +by_class[].producer_batch +by_class[].producer_efficiency_class +by_class[].producer_group +by_class[].producer_numa_node +by_class[].producer_number +by_class[].requested_memory_node +by_class[].slice +by_class[].strategy +host +host.arch +host.cache_domain_sizes +host.cache_domain_sizes[] +host.cores +host.efficiency_classes +host.efficiency_classes[] +host.efficiency_classes[][] +host.numa_node_sizes +host.numa_node_sizes[] +host.partitioning_cache_level +host.processors +host.provenance +host.smt +machine +machine.cpu_model +machine.model_suppressed +machine.os_build +machine.virtualisation +machine.virtualisation_name +node_hops +node_hops[] +node_hops[].consumer_batch +node_hops[].consumer_efficiency_class +node_hops[].consumer_group +node_hops[].consumer_numa_node +node_hops[].consumer_number +node_hops[].memory_node +node_hops[].nanos_per_item +node_hops[].placement +node_hops[].producer_batch +node_hops[].producer_efficiency_class +node_hops[].producer_group +node_hops[].producer_numa_node +node_hops[].producer_number +node_hops[].requested_memory_node +node_hops[].slice +node_hops[].strategy +placements +placements[] +placements[].consumer_batch +placements[].consumer_efficiency_class +placements[].consumer_group +placements[].consumer_numa_node +placements[].consumer_number +placements[].memory_node +placements[].nanos_per_item +placements[].placement +placements[].producer_batch +placements[].producer_efficiency_class +placements[].producer_group +placements[].producer_numa_node +placements[].producer_number +placements[].requested_memory_node +placements[].slice +placements[].strategy +recorded_at +recorded_at_epoch_seconds +schema_version +topology_provenance diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index 0463427d..ef78c1f8 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -338,6 +338,24 @@ pub struct Measurement { /// not be achieved -- the two are the same fact here (we do not know where /// the memory is) and neither may be reported as a node. pub memory_node: Option, + /// Which NUMA node this run *asked* for, independent of what it got. + /// + /// **The request is what identifies the row; the result is what it + /// measured, and they are not the same fact.** A directed hop is measured + /// once per ring placement, so the two rows for a pair differ only in what + /// they requested -- and Windows may redirect an allocation, which + /// `Slots::new_on` deliberately tolerates rather than failing. Recording + /// only the achieved node therefore lets both rows serialise identically, + /// collapsing the very dimension measuring both placements exists to + /// expose. + /// + /// It also makes a redirect visible instead of silent: a row whose + /// requested and observed nodes disagree did not measure the placement it + /// names, and a reader can now see that rather than infer it. + /// + /// None means nothing was requested, which is the normal case for the + /// placement and efficiency-class rows. + pub requested_memory_node: Option, } /// Everything one invocation measured. @@ -610,6 +628,7 @@ pub fn measure() -> std::io::Result { // Placement rows do not choose a node: they vary where the // threads run, holding everything else as it falls. memory_node: median.memory_node, + requested_memory_node: None, }); } } @@ -642,6 +661,7 @@ pub fn measure() -> std::io::Result { // Placement rows do not choose a node: they vary where the // threads run, holding everything else as it falls. memory_node: median.memory_node, + requested_memory_node: None, }); } } @@ -674,8 +694,12 @@ pub fn measure() -> std::io::Result { nanos_per_item: median.nanos / ITEMS as f64, consumer_batch: ITEMS as f64 / median.consumer_refreshes.max(1) as f64, producer_batch: ITEMS as f64 / median.producer_refreshes.max(1) as f64, - // What the run *achieved*, not what it asked for. + // What the run *achieved*, and separately what it asked + // for. Both are needed: the request identifies the row, + // the result is the measurement, and a disagreement + // between them is itself the finding. memory_node: median.memory_node, + requested_memory_node: Some(memory_node), }); } } diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index 8be6fedb..c230c756 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -51,7 +51,7 @@ use crate::machine::MachineDescription; /// **The golden files are append-only and a published version is never /// redefined.** Once a record exists in the wild claiming schema N, N's meaning /// is fixed, because that record cannot be regenerated. -pub const SCHEMA_VERSION: u32 = 3; +pub const SCHEMA_VERSION: u32 = 4; /// One run's complete output. #[derive(Clone, Debug)] @@ -180,6 +180,21 @@ pub struct MeasurementRecord { /// In `placements` and `by_class` none is ever arranged, so `null` is the /// normal case and means the ring was left wherever the allocator put it. pub memory_node: Option, + /// Which NUMA node the run *asked* for, independent of what it got. + /// + /// **This, not `memory_node`, is what tells two `node_hops` rows apart.** + /// Each directed hop is measured once per ring placement, so its two rows + /// agree on every other field -- and Windows may satisfy an allocation on + /// a node other than the one requested. Keyed on the achieved node alone, + /// the producer-local and consumer-local rows can serialise identically, + /// and a collector would reasonably read them as duplicates. + /// + /// A row whose requested and observed nodes disagree **did not measure the + /// placement it names**, and is a caveat rather than a result for that hop. + /// + /// `null` means nothing was requested, which is the normal case in + /// `placements` and `by_class`. + pub requested_memory_node: Option, } impl From<&Measurement> for MeasurementRecord { @@ -200,6 +215,7 @@ impl From<&Measurement> for MeasurementRecord { consumer_batch: measurement.consumer_batch, producer_batch: measurement.producer_batch, memory_node: measurement.memory_node, + requested_memory_node: measurement.requested_memory_node, } } } diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index dd9e427f..8b9f68f9 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -41,6 +41,10 @@ pub(crate) fn fully_populated() -> SubmissionRecord { consumer_batch: 84.9, producer_batch: 1.0, memory_node: Some(0), + // Populated, like every other field here, so the golden describes the + // full shape. Equal to `memory_node` because this fixture stands for a + // row that got what it asked for. + requested_memory_node: Some(0), }; SubmissionRecord { diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index 25be60ce..8b026f91 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -208,11 +208,14 @@ fn render_node_hops(out: &mut String, record: &SubmissionRecord) { "prod -> cons", "ring on", "strategy", "ns/item", "batch depth" ); for entry in &record.node_hops { - let ring_on = match entry.memory_node { + // **The requested node, because that is what identifies the row.** Two + // rows for one directed pair differ only in where the ring was asked to + // go; printing where it actually landed made them identical whenever + // Windows redirected an allocation, which is exactly the case a reader + // most needs to see. Any disagreement is called out under the table. + let ring_on = match entry.requested_memory_node { Some(node) => format!("node {node}"), - // Reported, not hidden. A hop whose ring landed somewhere unknown - // is still a measurement, but not of the pair it names. - None => "unknown".to_owned(), + None => "unspecified".to_owned(), }; let _ = writeln!( out, @@ -227,6 +230,42 @@ fn render_node_hops(out: &mut String, record: &SubmissionRecord) { entry.consumer_batch ); } + + // A redirected allocation is a caveat on the row, not a footnote: the row + // is labelled with the placement it asked for, and if the memory went + // somewhere else then it did not measure that placement at all. Windows is + // permitted to satisfy the request elsewhere, so this is an expected + // outcome to disclose rather than an error to hide. + let redirected = record + .node_hops + .iter() + .filter(|entry| entry.memory_node != entry.requested_memory_node); + let mut said_anything = false; + for entry in redirected { + if !said_anything { + let _ = writeln!(out); + let _ = writeln!( + out, + " Some rows did not get the memory they asked for, so they do not" + ); + let _ = writeln!(out, " measure the placement they name:"); + said_anything = true; + } + let landed = match entry.memory_node { + Some(node) => format!("node {node}"), + None => "somewhere this run could not determine".to_owned(), + }; + let _ = writeln!( + out, + " - {} -> {} ({}) asked for node {} and got {landed}", + entry.producer_numa_node, + entry.consumer_numa_node, + entry.strategy, + entry + .requested_memory_node + .map_or_else(|| "?".to_owned(), |node| node.to_string()), + ); + } } fn render_trust(out: &mut String, record: &SubmissionRecord) { diff --git a/crates/windows-placement-probe/src/report/tests.rs b/crates/windows-placement-probe/src/report/tests.rs index 50784f13..5df3f2a1 100644 --- a/crates/windows-placement-probe/src/report/tests.rs +++ b/crates/windows-placement-probe/src/report/tests.rs @@ -186,7 +186,11 @@ fn one_numa_edge() -> Vec { row.placement = "cross NUMA node".to_owned(); row.producer_numa_node = producer_node; row.consumer_numa_node = consumer_node; + // Both, and equal: this fixture stands for rows that got the + // placement they asked for. The redirect case, where they differ, + // has its own test below. row.memory_node = Some(memory_node); + row.requested_memory_node = Some(memory_node); // Distinct per row, so a report that collapses two rows into one is // visible rather than merely suspected. row.nanos_per_item = 100.0 + f64::from(producer_node) * 10.0 + f64::from(memory_node); @@ -242,8 +246,8 @@ fn a_hop_reads_as_a_direction_and_not_as_a_link() { #[test] fn a_hop_whose_ring_could_not_be_placed_says_so() { // Not a hidden caveat. A hop measured with the ring on an unknown node is - // still a measurement, but not of the pair it names, and the row has to - // admit that rather than leave a blank column reading as a zero. + // still a measurement, but not of the pair it names, and the report has to + // admit that rather than leave a column reading as though it succeeded. let mut record = fully_populated(); let mut rows = one_numa_edge(); rows[0].memory_node = None; @@ -252,8 +256,56 @@ fn a_hop_whose_ring_could_not_be_placed_says_so() { let text = render(&record); assert!( - text.contains("unknown"), - "a hop with no achieved placement did not admit it:\n{text}" + text.contains("did not get the memory they asked for"), + "a hop with no achieved placement did not admit it: +{text}" + ); + assert!( + text.contains("could not determine"), + "an unachievable placement must read differently from a redirected one: +{text}" + ); +} + +#[test] +fn two_hops_redirected_to_one_node_stay_distinguishable() { + // **The defect this guards.** Windows may satisfy a NUMA allocation on a + // node other than the one requested, and the probe tolerates that rather + // than failing. Keyed on the achieved node, the producer-local and + // consumer-local rows for a pair then serialise and print identically, and + // a reader sees two duplicate rows instead of the two placements the table + // exists to separate. + let mut record = fully_populated(); + let mut rows = one_numa_edge(); + // Both rows of the 0 -> 1 edge asked for different nodes; both landed on 0. + rows[0].requested_memory_node = Some(0); + rows[1].requested_memory_node = Some(1); + rows[0].memory_node = Some(0); + rows[1].memory_node = Some(0); + record.node_hops = rows; + + let text = render(&record); + + for requested in [0, 1] { + // Anchored at the start of the line so this counts *table rows* only. + // The caveat below the table names the same pair and also mentions the + // node it landed on, so a substring search matches it too -- which is + // how the first draft of this test reported two rows for node 0. + let matched = text.lines().filter(|line| { + line.starts_with("0 -> 1") && line.contains(&format!("node {requested}")) + }); + assert_eq!( + matched.count(), + 1, + "the row that asked for node {requested} is not identifiable: +{text}" + ); + } + // And the one that did not get what it asked for is called out. + assert!( + text.contains("did not get the memory they asked for"), + "a redirected allocation was reported as though it succeeded: +{text}" ); } From e2aa305ed88c9ea2cdb8b1b69ac34cd18878cec1 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:09:23 -0400 Subject: [PATCH 106/361] fix(ioring): make the file-handle NUMA spike honest about vacuity, alignment and its temp file Three defects in one spike, all of which survive into CI, where the numa-spikes job runs it on whatever machine the fleet supplies. The VACUOUS token was printed as the first statement of main, before any node was queried. tools/run-numa-spikes.ps1 classifies a spike by searching its output for that token, so this spike was reported vacuous on every machine -- making the single signal the job exists to raise, "a multi-node runner appeared, read the output", unreachable for it. The token is now gated on a measured node count, as the other spikes already gate theirs. Verified by forcing the count to two: the token disappears and the runner's verdict flips to NOT vacuous. Two reads formed references into a Vec, which promises only byte alignment, so &STORAGE_DEVICE_DESCRIPTOR and *const u32 into those buffers were undefined behaviour regardless of how many bytes the driver returned. That the system allocator returns suitably aligned blocks today is incidental behaviour, not a guarantee. Both now read_unaligned. The scratch file used a fixed name opened with truncation and deleted at the end, so a concurrent copy of the spike -- or any unrelated file at that path -- was destroyed and then removed. The name now carries the process id and is opened with create_new, so the probe can only delete a file it made itself. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spikes/file-handle-numa-spike.rs | 60 +++++++++++++++++-- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs index 36d5dd8f..f8b7a751 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs @@ -103,6 +103,7 @@ use std::ffi::{OsStr, c_void}; use std::fs; +use std::io::Write as _; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::AsRawHandle; use std::path::Path; @@ -239,7 +240,13 @@ fn describe_storage(path: &Path) -> Storage { }; if ok != 0 && (returned as usize) >= size_of::() { // SAFETY: the driver filled at least a descriptor's worth of `buf`. - let desc = unsafe { &*buf.as_ptr().cast::() }; + // + // Read out, not borrowed in place. `buf` is a `Vec`, which promises + // only byte alignment, so forming a `&STORAGE_DEVICE_DESCRIPTOR` into it + // is undefined behaviour no matter how many bytes are there. That the + // system allocator happens to hand back suitably aligned blocks today is + // exactly the kind of incidental behaviour not to build on. + let desc = unsafe { buf.as_ptr().cast::().read_unaligned() }; out.bus_type = Some(desc.BusType as u8); out.removable = Some(desc.RemovableMedia); // The ID offsets are byte offsets into the same buffer, or 0 for absent. @@ -301,7 +308,9 @@ fn describe_storage(path: &Path) -> Storage { }; if ok != 0 && (returned as usize) >= size_of::() { // SAFETY: the first field of VOLUME_DISK_EXTENTS is NumberOfDiskExtents. - let count = unsafe { *extents.as_ptr().cast::() }; + // Unaligned for the same reason as the descriptor above: `extents` is a + // `Vec` and carries no alignment guarantee beyond one byte. + let count = unsafe { extents.as_ptr().cast::().read_unaligned() }; out.disk_extents = Some(count); } @@ -313,6 +322,21 @@ fn describe_storage(path: &Path) -> Storage { #[link(name = "kernel32")] unsafe extern "system" { fn GetNumaNodeNumberFromHandle(hFile: HANDLE, NodeNumber: *mut u16) -> i32; + fn GetNumaHighestNodeNumber(HighestNodeNumber: *mut u32) -> i32; +} + +/// How many NUMA nodes this machine reports. +/// +/// One on failure, which is the conservative answer: it makes the spike call +/// itself vacuous rather than claim a result it cannot support. +fn numa_node_count() -> u32 { + let mut highest = 0_u32; + // SAFETY: `highest` is a live local for the duration of the call. + if unsafe { GetNumaHighestNodeNumber(&raw mut highest) } != 0 { + highest.saturating_add(1) + } else { + 1 + } } fn probe(label: &str, handle: HANDLE) -> (Option, Option) { @@ -379,8 +403,21 @@ fn probe(label: &str, handle: HANDLE) -> (Option, Option) { } fn main() -> std::io::Result<()> { - println!("NOTE: on a single-NUMA-node machine this spike is VACUOUS."); - println!("Check the node count first; if it is 1, these results say nothing.\n"); + // **The token is a verdict, not a disclaimer, and must be earned.** + // `tools/run-numa-spikes.ps1` classifies a spike by searching its output for + // `VACUOUS`, so printing it unconditionally -- as this did, before any node + // was queried -- marked every run vacuous and made the one signal the CI job + // exists to raise unreachable for this spike. The other spikes gate theirs + // on a measured node count; this one now does too. + let nodes = numa_node_count(); + if nodes <= 1 { + println!("NOTE: this machine reports one NUMA node, so this spike is VACUOUS."); + println!("The apparatus below still runs, but its results say nothing. +"); + } else { + println!("this machine reports {nodes} NUMA nodes, so the results below are real. +"); + } // Q6: pass a directory on a Storage Space as argv[1] to ask the harder // question. Default target is the temp directory, i.e. the boot volume. @@ -394,8 +431,19 @@ fn main() -> std::io::Result<()> { } None => std::env::temp_dir(), }; - let path = dir.join("numa-probe-target.bin"); - fs::write(&path, vec![0_u8; 4096])?; + // **Per-process, and created exclusively.** The old fixed name was written + // with truncation and deleted at the end, so a second copy of this spike -- + // or any unrelated file that happened to sit at that path -- was destroyed + // and then removed. `create_new` refuses to open anything that already + // exists, so the probe can only ever delete a file it made itself. + let path = dir.join(format!("numa-probe-target-{}.bin", std::process::id())); + { + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path)?; + file.write_all(&[0_u8; 4096])?; + } // What the volume is physically made of. Printed before the node queries so // that a reader has the context to interpret a failure: a virtual disk From 9f736ee61632a3260c3e882623f9fbe80519eeee Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:13:19 -0400 Subject: [PATCH 107/361] fix(ioring): give the thread-stack spike a real control, and join before unwinding Control B is spawned with no affinity attribute, so it inherits the creating thread's node -- but nothing ever established where that thread was running. If it happened to sit on the node chosen as "far", B and A were created on the same node, and the interpretation reads a == b as "creation-time affinity does NOT govern stack placement" and tells the reader to stop claiming otherwise. That is a confident, actionable, wrong verdict produced by the apparatus rather than by the machine, on the one question this spike exists to answer. Near and far are now both chosen from the nodes that actually host processors, and the creator is pinned to near before anything is spawned, so the control differs from the treatment by construction. Failing to pin aborts the run rather than measuring an unknown control. Near and far coinciding is reported as vacuous, which the previous single-node check missed: several nodes may be reported while only one hosts processors. Separately, a partial spawn failure returned while earlier threads were still running with raw pointers into slots, a stack local of that frame -- a use-after-free in exactly the partial-failure case nobody reruns. Every handle already collected is now joined first. The structured record gains near_node, and its vacuous field now agrees with the printed verdict instead of testing only the node count. Verified through tools/run-numa-spikes.ps1: single-node run pins to node 0 and reports vacuous once. Injecting a synthetic second hosting node exercises the multi-node path that this machine cannot reach -- near 0, far 1, creator pinned to the near mask, no false vacuity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spikes/thread-stack-numa-spike.rs | 68 ++++++++++++++++--- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs index 0e07c068..919d0934 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs @@ -304,27 +304,60 @@ fn main() { println!("discriminated. Running anyway only validates the apparatus."); } - // The far node is the highest-numbered one that actually has processors; a - // memory-only node cannot host a thread, so it cannot answer this question. - let mut far = GROUP_AFFINITY::default(); - let mut far_node = u32::MAX; - for candidate in (0..=highest).rev() { + // Every node that can actually host a thread. A memory-only node cannot + // answer this question, so it is not a candidate for either end. + let mut hosting: Vec<(u32, GROUP_AFFINITY)> = Vec::new(); + for candidate in 0..=highest { let mut ga = GROUP_AFFINITY::default(); if unsafe { GetNumaNodeProcessorMaskEx(candidate as u16, &raw mut ga) } != 0 && ga.Mask != 0 { - far = ga; - far_node = candidate; - break; + hosting.push((candidate, ga)); } } - if far_node == u32::MAX { + let (Some(&(near_node, near)), Some(&(far_node, far))) = (hosting.first(), hosting.last()) + else { println!("no NUMA node reports any processors; cannot proceed."); return; + }; + + // **The creator's node has to be established, not assumed -- this is what + // makes control B a control at all.** B is created with no affinity + // attribute, so it inherits the creating thread's node. Nothing here ever + // located that thread, so if it happened to be running on the node picked + // as "far", then A (created *with* the far attribute) and B were created on + // the same node. The comparison below reads `a == b` as "creation-time + // affinity does NOT govern stack placement" and tells the reader to stop + // claiming otherwise -- a confident conclusion produced by the apparatus + // rather than by the machine, and the exact opposite of the truth. + // + // Pinning the creator to a definite near node removes the coincidence. + let pinned = + unsafe { SetThreadGroupAffinity(GetCurrentThread(), &raw const near, std::ptr::null_mut()) }; + println!( + "creator pinned to near node {near_node} (group {}, mask {:#x}): {}", + near.Group, + near.Mask, + pinned != 0 + ); + if pinned == 0 { + println!("could not pin the creator, so control B's node is not known; cannot proceed."); + return; } println!( "far node chosen: {far_node} (group {}, mask {:#x})", far.Group, far.Mask ); + if near_node == far_node && highest > 0 { + // Guarded on `highest > 0` because the single-node case already said + // this above, and saying it twice reads as two separate problems. What + // is left is the case that announcement misses: several nodes reported, + // but only one of them hosts processors, so near and far coincide and + // the control cannot differ from the treatment. + println!(); + println!("*** VACUOUS ON THIS MACHINE ***"); + println!("Only one NUMA node hosts processors, so near and far are the"); + println!("same node and the control cannot differ from the treatment."); + } let mut slots = [ Slot { @@ -360,6 +393,15 @@ fn main() { Ok(h) => handles.push(h), Err(e) => { println!("spawn failed for {}: {e}", slot.label); + // **Join what is already running before unwinding this frame.** + // Each spawned thread writes its results through a raw pointer + // into `slots`, which lives on this stack frame, so returning + // with threads still alive is a use-after-free -- and one that + // would surface as corrupted numbers or an intermittent crash + // in exactly the partial-failure case nobody reruns. + for handle in std::mem::take(&mut handles) { + join(handle); + } return; } } @@ -432,15 +474,19 @@ fn main() { println!( concat!( r#"{{"reason":"x-spike-thread-stack-numa","arch":"{}","numa_nodes":{},"#, - r#""far_node":{},"vacuous":{},"usable":{},"#, + r#""near_node":{},"far_node":{},"vacuous":{},"usable":{},"#, r#""created_far":{{"shallow":{},"deep":{}}},"#, r#""control_near":{{"shallow":{},"deep":{}}},"#, r#""bound_after":{{"shallow":{},"deep":{}}}}}"# ), std::env::consts::ARCH, highest + 1, + near_node, far_node, - highest == 0, + // Vacuous whenever the control cannot differ from the treatment, which + // is not only the single-node case: several nodes may be reported while + // just one of them hosts processors. + highest == 0 || near_node == far_node, usable, probe_json(slots[0].shallow), probe_json(slots[0].deep), From c6bd16c139bc99027b8c09625586d249b1214995 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:30:20 -0400 Subject: [PATCH 108/361] fix(placement-probe): make --no-default-features actually build, and guard it in CI The manifest states that the serde feature "exists so the measurement code can be used without it", which advertises --no-default-features as a supported configuration. It did not rot -- it never worked: paste_json and submission were ungated while importing serde, so the advertised configuration failed to compile with five errors. Both modules are serialization and nothing else. paste_json re-lays out serde_json values; a submission is the serialized record wrapped in fences and a checksum. Both are now gated, as are the three record tests that describe the serialized shape and the BTreeSet import they alone use. The binary gains required-features, because its entire output is that submission -- without it cargo tried to build the binary and failed on an unresolved import rather than simply not building it. What remains without the feature is what the manifest promised: measurement, the fingerprint, the machine description, and the human-readable report. 134 tests pass there against 168 with the feature on. A CI job now builds, lints, tests and documents that configuration, matching what windows-ioring-sys and windows-overlapped-io-sys already get. Nothing else covers it: every --workspace step takes the default set and --all-features turns the feature back on, which is exactly how a configuration that never compiled stayed green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 37 +++++++++++++++++++ crates/windows-placement-probe/Cargo.toml | 5 +++ crates/windows-placement-probe/src/lib.rs | 13 +++++++ .../src/record/tests.rs | 8 ++++ 4 files changed, 63 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81ba68d6..3bd317a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -329,6 +329,43 @@ jobs: RUST_LIB_BACKTRACE: 1 run: cargo test -p windows-ioring-sys --locked --no-fail-fast + placement-probe-no-serde: + name: windows-placement-probe (no serde feature) + runs-on: windows-latest + # The crate's manifest states that the `serde` feature "exists so the + # measurement code can be used without it", which makes + # `--no-default-features` a promised configuration that nothing else builds: + # every `--workspace` step takes the default set, and `--all-features` turns + # the feature back on. + # + # Unprotected, it did not merely rot -- it never worked. `paste_json` and + # `submission` were ungated while importing `serde`, so the advertised + # configuration failed to compile outright with five errors. This job is what + # would have caught that, and is the cost the feature gate is accepted with. + env: + RUSTUP_TOOLCHAIN: stable + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: cargo build + run: cargo build -p windows-placement-probe --all-targets --no-default-features --locked + - name: cargo clippy + run: cargo clippy -p windows-placement-probe --all-targets --no-default-features --locked -- -D warnings + - name: cargo test + env: + RUST_BACKTRACE: 1 + RUST_LIB_BACKTRACE: 1 + run: cargo test -p windows-placement-probe --no-default-features --locked --no-fail-fast + # The `docs` job documents `--all-features` only, so a link from ungated + # prose into a now-gated item resolves there and dangles here. + - name: cargo doc (deny broken intra-doc links) + env: + RUSTDOCFLAGS: "-D rustdoc::broken_intra_doc_links -D rustdoc::private_intra_doc_links" + run: cargo doc -p windows-placement-probe --no-deps --no-default-features --locked + fmt: name: rustfmt runs-on: windows-latest diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index 86df0e06..93dd07ae 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -41,6 +41,11 @@ path = "src/lib.rs" [[bin]] name = "placement-probe" path = "src/bin/placement_probe/main.rs" +# The binary's whole output is the serialized record wrapped in fences and a +# checksum, so it cannot exist without the feature. Declared rather than left +# implicit: without this, `--no-default-features` tried to build the binary and +# failed with an unresolved import instead of simply not building it. +required-features = ["serde"] [features] # The submission record is the product, so serialization is not optional the way diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index ef919387..610e37a3 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -60,6 +60,13 @@ pub mod fingerprint; /// What machine this was, beyond its measurable shape. pub mod machine; /// JSON laid out to be read in a terminal rather than by a machine. +/// +/// Gated, because it is serialization and nothing else: it parses and re-lays +/// out `serde_json` values and cannot be built without `serde` in scope. The +/// `serde` feature exists so the *measurement* code can be used without it, and +/// leaving this module ungated made `--no-default-features` -- a configuration +/// this crate's manifest advertises -- fail to compile outright. +#[cfg(feature = "serde")] pub mod paste_json; /// The handoff itself, and the strategies the placement experiment compares. pub mod peer_index_cache; @@ -68,4 +75,10 @@ pub mod record; /// The human-readable report, rendered from the record. pub mod report; /// Turning a run into something a person can paste into a discussion thread. +/// +/// Gated for the same reason as [`paste_json`]: the paste *is* the serialized +/// record wrapped in fences and a checksum, so there is nothing here that can +/// exist without a serializer. Measurement, the fingerprint, and the +/// human-readable report all remain available without the feature. +#[cfg(feature = "serde")] pub mod submission; diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index 8b9f68f9..6975196f 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -2,6 +2,8 @@ //! Tests for [`SubmissionRecord`](super::SubmissionRecord), including the //! schema guard. +// Used only by the schema-shape helpers, which are serialization-only. +#[cfg(feature = "serde")] use std::collections::BTreeSet; use windows_topology_sys::Provenance; @@ -92,6 +94,9 @@ pub(crate) fn fully_populated() -> SubmissionRecord { /// /// An array contributes `field[]`, not one entry per element, so the golden /// describes the shape and not the size of one sample. +/// Serialization only: without the `serde` feature the record has no +/// serialized shape for this to describe. +#[cfg(feature = "serde")] fn key_paths(value: &serde_json::Value) -> BTreeSet { fn walk(value: &serde_json::Value, prefix: &str, into: &mut BTreeSet) { match value { @@ -123,6 +128,7 @@ fn key_paths(value: &serde_json::Value) -> BTreeSet { } #[test] +#[cfg(feature = "serde")] fn the_records_shape_matches_the_archived_schema_for_its_version() { // The guard. Change the record's shape without raising SCHEMA_VERSION and // adding the next golden, and this fails -- with a diff that names exactly @@ -171,6 +177,7 @@ fn the_schema_version_in_a_record_is_the_constant() { } #[test] +#[cfg(feature = "serde")] fn every_field_of_a_fully_populated_record_is_present_in_the_json() { // Guards the fixture rather than the code: if a future field is added and // left `None` here, it would be omitted from the JSON and would never enter @@ -195,6 +202,7 @@ fn every_field_of_a_fully_populated_record_is_present_in_the_json() { } #[test] +#[cfg(feature = "serde")] fn node_hops_is_an_empty_list_rather_than_an_absent_field() { // The distinction a large-machine submission depends on: "measured, and // there are none" must be tellable from "this version did not report them". From 08199bf276273b81ef5816ec10a3ee60c3cf766f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:32:25 -0400 Subject: [PATCH 109/361] fix(ioring): gate the thread-stack verdict on vacuity, not on the node count Follow-up to 9f736ee, which fixed the announcement and left the verdict computing on the old premise. When several NUMA nodes are reported but only one hosts processors, near and far collapse; the previous commit said so and then let the interpretation run anyway. Every thread is on one node there, so A, B and C all equal far_node, the first arm matches, and it printed the strongest claim the spike can make -- "Creation-time affinity GOVERNS stack placement ... The thread builder is justified" -- from an apparatus whose control was identical to its treatment. Verified as a before-and-after by simulating that machine: the pre-fix code prints GOVERNS, the fixed code prints VACUOUS. Vacuity is now decided once and asked by the announcement, the verdict and the structured record, rather than each testing the node count for itself. That independent restatement is what let the three disagree in the first place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spikes/thread-stack-numa-spike.rs | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs index 919d0934..aa1328fa 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs @@ -347,7 +347,15 @@ fn main() { "far node chosen: {far_node} (group {}, mask {:#x})", far.Group, far.Mask ); - if near_node == far_node && highest > 0 { + // **One definition of vacuity, asked by everything that depends on it.** + // The announcement, the final verdict, and the structured record must not + // each decide this for themselves: an earlier revision announced that near + // and far had collapsed and then let the interpretation run anyway, which + // printed a confident "GOVERNS" from a setup whose control was identical to + // its treatment. + let vacuous = highest == 0 || near_node == far_node; + + if vacuous && highest > 0 { // Guarded on `highest > 0` because the single-node case already said // this above, and saying it twice reads as two separate problems. What // is left is the case that announcement misses: several nodes reported, @@ -423,14 +431,24 @@ fn main() { } } - // Only interpret when the pages are resident and the machine has more than - // one node; otherwise say so rather than printing a confident conclusion. + // Only interpret when the pages are resident and the treatment could + // actually differ from the control; otherwise say so rather than printing a + // confident conclusion. + // + // **Gated on `vacuous`, not on the node count.** Testing `highest == 0` + // here let the case where several nodes are reported but only one hosts + // processors fall straight through to the interpretation -- and with every + // thread on one node, A, B and C all equal `far_node`, so the first arm + // matched and it printed "GOVERNS ... the thread builder is justified". + // That is the strongest claim this spike can make, produced by an + // apparatus that measured nothing. let usable = slots .iter() .all(|s| s.shallow.queried && s.shallow.valid && s.deep.queried && s.deep.valid); println!(); - if highest == 0 { - println!("=> VACUOUS: one node. Apparatus works; question unanswered."); + if vacuous { + println!("=> VACUOUS: near and far are the same node. Apparatus works;"); + println!(" question unanswered."); } else if !usable { println!("=> INCONCLUSIVE: a probed page was not resident or the query failed."); } else { @@ -483,10 +501,9 @@ fn main() { highest + 1, near_node, far_node, - // Vacuous whenever the control cannot differ from the treatment, which - // is not only the single-node case: several nodes may be reported while - // just one of them hosts processors. - highest == 0 || near_node == far_node, + // The same value the announcement and the verdict used, so the record + // cannot disagree with the text beside it. + vacuous, usable, probe_json(slots[0].shallow), probe_json(slots[0].deep), From 9fe2d8a8ed3919851e4910673bcb46e378277cd7 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:35:23 -0400 Subject: [PATCH 110/361] fix(placement-probe): key the node-pair lookup on the requested placement Follow-up to 27aed68, which established requested_memory_node as the row's identity in the record and the report and then left this lookup keyed on the achieved node -- the same correction landing in some statements of a contract and not others. It matters because Windows may satisfy a NUMA allocation elsewhere. Keyed on the achieved node, the two rows of a pair become indistinguishable when both are redirected: one requested placement is unfindable, and a lookup for the other returns whichever row comes first. The only caller uses it to pair a baseline with a cached run, so the failure is a table whose two columns describe different configurations -- exactly what that caller's own comment says the key exists to prevent. The second renderer in windows-platform-probes is corrected with it, since it displayed the achieved node in the ring-placement column for the same reason, and now marks a redirected row with a trailing `!` and explains it. That file already carries a comment about a correction being applied to the probe crate's report and not to this second view of the same data; this is that again. Sabotage-checked: keying on the achieved node again fails the new test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core_affinity.rs | 19 +++- .../src/core_affinity/tests.rs | 88 +++++++++++++++++++ .../src/bin/core_affinity.rs | 27 +++++- 3 files changed, 126 insertions(+), 8 deletions(-) diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index ef78c1f8..c8e7ae76 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -763,22 +763,33 @@ impl Observation { .collect() } - /// The measurement for one node pair, strategy and ring placement. + /// The measurement for one node pair, strategy and *requested* ring + /// placement. /// - /// The full key. `memory_node` is what the singular lookup used to omit. + /// The full key. `requested_memory_node` is what the singular lookup used + /// to omit entirely, and then keyed on the achieved node instead. + /// + /// **Keyed on the request, because the request is what identifies the + /// row.** Windows may satisfy an allocation on a node other than the one + /// asked for, so two rows of a pair can share an achieved node while + /// describing different placements. Keyed on that, one requested placement + /// becomes unfindable and a lookup for the other returns whichever row + /// happens to come first -- silently pairing a baseline taken at one + /// placement against a cached run taken at the other, which is precisely + /// the mistake the caller's own comment says this key exists to prevent. #[must_use] pub fn node_pair( &self, pair: (u32, u32), strategy: Strategy, - memory_node: Option, + requested_memory_node: Option, ) -> Option { self.by_node_pair .iter() .find(|m| { (m.producer.numa_node, m.consumer.numa_node) == pair && m.strategy == strategy - && m.memory_node == memory_node + && m.requested_memory_node == requested_memory_node }) .cloned() } diff --git a/crates/windows-placement-probe/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs index 67194731..db91d5c3 100644 --- a/crates/windows-placement-probe/src/core_affinity/tests.rs +++ b/crates/windows-placement-probe/src/core_affinity/tests.rs @@ -1062,3 +1062,91 @@ fn a_longer_run_is_never_promised_as_shorter() { previous = seconds; } } + +/// One `by_node_pair` row, with the request and the result stated separately. +fn hop_row( + pair: (u32, u32), + strategy: super::Strategy, + requested: Option, + achieved: Option, + nanos: f64, +) -> super::Measurement { + let mut producer = place(0, 0, Some(0)); + let mut consumer = place(1, 0, Some(0)); + producer.numa_node = pair.0; + consumer.numa_node = pair.1; + super::Measurement { + slice: super::Slice::pair(producer, consumer), + producer, + consumer, + placement: Placement::CrossNumaNode, + strategy, + nanos_per_item: nanos, + consumer_batch: 1.0, + producer_batch: 1.0, + memory_node: achieved, + requested_memory_node: requested, + } +} + +/// An observation carrying only the given node-pair rows. +fn observation_of(rows: Vec) -> super::Observation { + super::Observation { + processors: Vec::new(), + by_class: Vec::new(), + measurements: Vec::new(), + by_node_pair: rows, + } +} + +#[test] +fn a_node_pair_lookup_finds_each_requested_placement_when_both_were_redirected() { + // **The defect this guards.** Windows may satisfy a NUMA allocation on a + // node other than the one requested. Keyed on the achieved node, the two + // rows of a pair become indistinguishable: one requested placement is + // unfindable and a lookup for the other returns whichever row comes first, + // which silently pairs a baseline taken at one placement against a cached + // run taken at the other. + let observation = observation_of(vec![ + hop_row((0, 1), super::Strategy::Cached, Some(0), Some(0), 10.0), + hop_row((0, 1), super::Strategy::Cached, Some(1), Some(0), 20.0), + ]); + + let asked_for_zero = observation + .node_pair((0, 1), super::Strategy::Cached, Some(0)) + .expect("the row that asked for node 0 must be findable"); + let asked_for_one = observation + .node_pair((0, 1), super::Strategy::Cached, Some(1)) + .expect("the row that asked for node 1 must be findable"); + + assert!( + (asked_for_zero.nanos_per_item - 10.0).abs() < f64::EPSILON, + "got {}", + asked_for_zero.nanos_per_item + ); + assert!( + (asked_for_one.nanos_per_item - 20.0).abs() < f64::EPSILON, + "got {}", + asked_for_one.nanos_per_item + ); +} + +#[test] +fn a_node_pair_lookup_distinguishes_rows_that_share_a_requested_node() { + // The converse, so the key is not merely coarser in the other direction: + // rows differing by strategy must still be told apart. + let observation = observation_of(vec![ + hop_row((0, 1), super::Strategy::Baseline, Some(1), Some(1), 30.0), + hop_row((0, 1), super::Strategy::Cached, Some(1), Some(1), 40.0), + ]); + + let baseline = observation + .node_pair((0, 1), super::Strategy::Baseline, Some(1)) + .expect("baseline row"); + let cached = observation + .node_pair((0, 1), super::Strategy::Cached, Some(1)) + .expect("cached row"); + + assert!((baseline.nanos_per_item - 30.0).abs() < f64::EPSILON); + assert!((cached.nanos_per_item - 40.0).abs() < f64::EPSILON); +} diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index 9df07d0c..2e0b8ea2 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -355,6 +355,11 @@ fn print_node_distances(observation: &Observation) { "{:<14} {:>8} {:>8} {:>8} {:>12} {:>12} {:>10}", "prod -> cons", "ring on", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" ); + // Stated rather than left as a mystery glyph. `ring on` names the node the + // run asked for, since that is what identifies the row; a `!` means the + // memory did not land there, so that row does not measure the placement it + // names. + println!(" (`ring on` is the node requested; `!` means it landed elsewhere)"); let mut slowest: Option<(f64, (u32, u32))> = None; let mut fastest: Option<(f64, (u32, u32))> = None; @@ -362,8 +367,15 @@ fn print_node_distances(observation: &Observation) { for pair in &pairs { for base in observation.node_pair_rows(*pair, Strategy::Baseline) { // Matched on the ring placement as well, so the two columns - // describe the same configuration. - let Some(cached) = observation.node_pair(*pair, Strategy::Cached, base.memory_node) + // describe the same configuration -- and on the placement that was + // *requested*, not the one that was achieved. Windows may redirect + // an allocation, so two rows can share an achieved node while + // describing different placements; keyed on that, this pairs a + // baseline taken at one placement against a cached run taken at the + // other, which is the exact error the comment above says the key + // exists to prevent. + let Some(cached) = + observation.node_pair(*pair, Strategy::Cached, base.requested_memory_node) else { continue; }; @@ -375,8 +387,15 @@ fn print_node_distances(observation: &Observation) { // data was not, which is how two views of one measurement drift // apart. format!("{} -> {}", pair.0, pair.1), - base.memory_node - .map_or_else(|| "unknown".to_owned(), |node| format!("node {node}")), + // The requested node, matching the key above and the probe + // crate's own report. A trailing `!` marks a row whose memory + // did not land where it was asked to go, so a redirected run + // is not read as a measurement of the placement it names. + match (base.requested_memory_node, base.memory_node) { + (Some(asked), Some(got)) if asked == got => format!("node {asked}"), + (Some(asked), _) => format!("node {asked}!"), + (None, _) => "unspecified".to_owned(), + }, format!("g{}/cpu{}", base.producer.group, base.producer.number), format!("g{}/cpu{}", base.consumer.group, base.consumer.number), base.nanos_per_item, From c98c8a22be35e219a90287aa337469495708378a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:38:39 -0400 Subject: [PATCH 111/361] fix(placement-probe): stop telling a heterogeneous machine its cores are all one class An empty by_class does not prove homogeneity. within_class_pair needs two cores of one class sharing a cache domain, and measure() skips any class that cannot supply a pair -- a singleton class, or one whose cores sit in different cache domains. A heterogeneous machine reaches the empty branch too, and was told that every core reports the same efficiency class: a false statement about the reader's hardware, in the section that exists to describe exactly that. The record already carries the class list, so the branch asks it instead of inferring the answer from the emptiness of a list that has more than one cause. The heterogeneous case now names how many classes were found and which selection rule none of them satisfied. Sabotage-checked: forcing the homogeneous branch fails the new test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/src/report.rs | 60 ++++++++++++++----- .../src/report/tests.rs | 33 ++++++++++ 2 files changed, 78 insertions(+), 15 deletions(-) diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index 8b026f91..8577c994 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -133,21 +133,51 @@ fn render_by_class(out: &mut String, record: &SubmissionRecord) { let _ = writeln!(out, "-- the handoff, by efficiency class --"); if record.by_class.is_empty() { - // Same reasoning as the empty node-hop table: on a homogeneous machine - // there is no second class to compare against, and saying so is a fact - // about the host rather than a measurement that failed. - let _ = writeln!( - out, - " Every core on this machine reports the same efficiency class, so" - ); - let _ = writeln!( - out, - " there is no fast-against-slow comparison to draw. On a machine" - ); - let _ = writeln!( - out, - " with performance and efficiency cores this table has a row each." - ); + // **Empty does not mean homogeneous, and it must not claim to.** The + // measurement needs a pair in one class on two different cores that + // share a cache domain, and skips any class that cannot supply one -- + // a singleton class, or one whose cores sit in different cache + // domains. A heterogeneous machine reaches here too, and telling its + // owner that every core reports the same class is simply false. + // + // The record already knows which case this is, so it is asked rather + // than guessed at. + if record.host.efficiency_classes.len() <= 1 { + let _ = writeln!( + out, + " Every core on this machine reports the same efficiency class, so" + ); + let _ = writeln!( + out, + " there is no fast-against-slow comparison to draw. On a machine" + ); + let _ = writeln!( + out, + " with performance and efficiency cores this table has a row each." + ); + } else { + let _ = writeln!( + out, + " This machine reports {} efficiency classes, but none of them could", + record.host.efficiency_classes.len() + ); + let _ = writeln!( + out, + " supply a comparable pair: the measurement needs two cores of the" + ); + let _ = writeln!( + out, + " same class sharing a cache domain, and a class with a single core" + ); + let _ = writeln!( + out, + " -- or with its cores split across caches -- cannot provide one." + ); + let _ = writeln!( + out, + " That is a fact about this machine's layout, not a failed run." + ); + } return; } diff --git a/crates/windows-placement-probe/src/report/tests.rs b/crates/windows-placement-probe/src/report/tests.rs index 5df3f2a1..aa2d312e 100644 --- a/crates/windows-placement-probe/src/report/tests.rs +++ b/crates/windows-placement-probe/src/report/tests.rs @@ -385,6 +385,7 @@ fn a_homogeneous_machine_says_why_there_is_no_class_comparison() { // a fact about the host, not as a measurement that failed. let mut record = fully_populated(); record.by_class.clear(); + record.host.efficiency_classes = vec![(0, 16)]; let text = render(&record); @@ -394,3 +395,35 @@ fn a_homogeneous_machine_says_why_there_is_no_class_comparison() { "a homogeneous machine was reported as an error: {text}" ); } + +#[test] +fn a_heterogeneous_machine_is_not_told_its_cores_are_all_one_class() { + // **The defect this guards.** An empty `by_class` does not prove the + // machine is homogeneous: the measurement skips any class that cannot + // supply two cores sharing a cache domain, so a machine with a singleton + // class -- or one whose class is split across caches -- lands here too. + // Telling its owner that every core reports the same class is a false + // statement about their hardware, in the section that exists to describe + // exactly that. + let mut record = fully_populated(); + record.by_class.clear(); + record.host.efficiency_classes = vec![(0, 8), (1, 1)]; + + let text = render(&record); + + assert!( + !text.contains("Every core on this machine reports the same"), + "a heterogeneous machine was reported as homogeneous: +{text}" + ); + assert!( + text.contains("2 efficiency classes"), + "the report must name what it actually found: +{text}" + ); + assert!( + text.contains("sharing a cache domain"), + "the report must state the selection rule that failed: +{text}" + ); +} From 44adccf93840def93692ecce70cb61dce35a841e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:38:49 -0400 Subject: [PATCH 112/361] fix(placement-probe): show the topology in the preview, and drop a false equivalence claim The README offers --preview as 'see exactly what it collects', and the notice heads its list with 'as read just now'. Every row showed a real value except topology, which named a subject -- while the paragraph below it warns that the topology identifies the part whether or not the model is named. A runner asked to judge that could not see the thing they were judging, which is the preview's only job. The fingerprint is now read before the notice and printed, and one reading serves both the notice and the record so the two cannot describe different machines. Separately, write_backup claimed the attached file and the pasted text were byte-identical. Only the embedded JSON is: the terminal text also carries the instructions, the report, the checksum line and the markdown fences. Diffing the two would have reported a difference on every submission. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/bin/placement_probe/main.rs | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/windows-placement-probe/src/bin/placement_probe/main.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs index 9f4d84e2..45021128 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/main.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -71,7 +71,19 @@ fn main() -> ExitCode { }; let plan = RunPlan::for_processors(&places); - print_collection_notice(&machine, options.suppress_model); + // Read before the notice, not after the measurement, because the notice is + // what a runner decides on and it cannot show a value it does not have. + // One reading serves both the notice and the record, so the two can never + // describe different machines. + let host = match Fingerprint::discover() { + Ok(host) => host, + Err(error) => { + eprintln!("could not read this machine's shape: {error}"); + return ExitCode::FAILURE; + } + }; + + print_collection_notice(&machine, &host, options.suppress_model); print_plan(&plan); if options.preview { @@ -92,14 +104,6 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let host = match Fingerprint::discover() { - Ok(host) => host, - Err(error) => { - eprintln!("could not read this machine's shape: {error}"); - return ExitCode::FAILURE; - } - }; - let record = SubmissionRecord::new(&observation, host, machine); let text = match submission::render_submission(&record) { Ok(text) => text, @@ -122,7 +126,7 @@ fn main() -> ExitCode { /// A person deciding whether to do this a favour should be able to decide with /// the real values in front of them rather than a promise about them, which is /// why the preview exists and why this prints what was actually read. -fn print_collection_notice(machine: &MachineDescription, suppressed: bool) { +fn print_collection_notice(machine: &MachineDescription, host: &Fingerprint, suppressed: bool) { println!("== windows-placement-probe =="); println!(); println!("This measures what thread placement costs on your machine, and prints"); @@ -150,7 +154,14 @@ fn print_collection_notice(machine: &MachineDescription, suppressed: bool) { None => String::new(), } ); - println!(" topology processor, core, cache and NUMA layout"); + // **The value, not the category.** Every other row here shows what was + // actually read, and this one named a subject instead -- while the + // paragraph below warns that the topology identifies the part whether or + // not the model is named. A runner asked to judge that could not see the + // thing they were being asked to judge, which is the one job the preview + // has. + println!(" topology {host}"); + println!(" (processor, core, cache and NUMA layout)"); println!(" timings how long a handoff takes at each placement"); println!(); println!("What it does NOT collect: your host name, your user name, file paths,"); @@ -191,8 +202,14 @@ fn print_plan(plan: &RunPlan) { /// A failure here is reported and does not fail the run: the submission is the /// text on screen, and losing the backup copy costs nothing that matters. fn write_backup(record: &SubmissionRecord) { - // The same layout as the printed record, so the backup a runner attaches - // and the text they paste are byte-identical. + // The same layout as the printed record, so the JSON a runner attaches and + // the JSON embedded in the text they paste are byte-identical. + // + // The two artifacts are not: the terminal text also carries the + // instructions, the human-readable report, the checksum line and the + // markdown fences. Saying otherwise promised an equivalence a collector + // might rely on -- diffing a pasted comment against an attached file would + // report a difference on every submission. let json = match windows_placement_probe::paste_json::to_paste_json(record) { Ok(json) => json, Err(error) => { From 80a0c1cd118f4e4ab6702f51838e168380a7c68f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 00:55:23 -0400 Subject: [PATCH 113/361] fix(placement-probe): publish artifacts only from a release tag The artifact upload ran on every trigger, so each pull-request run attached two downloadable binaries stamped PLACEMENT_PROBE_SOURCE=ci. is_official accepts those and SubmissionRecord::is_fully_trusted then passes, so anyone could take one from the run's artifact list and submit results indistinguishable from a real release build -- the exact boundary this file's header says the release attachment exists to draw. Measured on this pull request rather than reasoned about. The run published both artifacts, and the downloaded x86_64 binary reported 'v0.1.0 5e9e85de527c [ci]' with no !!UNOFFICIAL!! marker. That sha is also the ephemeral merge commit github.sha carries on a pull_request event: 'Merge 44adccf into 80170d4', which exists on no branch and is not in a clone. The provenance claim was not merely unearned, it named a commit a reader cannot check out. The three publishing steps now carry the same tag guard the release job already had. Building and verifying still run on every trigger, which is why those triggers exist; nothing consumes the artifacts off a tag, so gating them loses nothing. The header comment that called the release job's guard sufficient is corrected in the same change, since it was the statement this defect contradicted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-placement-probe.yml | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-placement-probe.yml b/.github/workflows/release-placement-probe.yml index c1f68c82..73977d0c 100644 --- a/.github/workflows/release-placement-probe.yml +++ b/.github/workflows/release-placement-probe.yml @@ -23,8 +23,15 @@ on: # exactly when verification is wanted. # # Path-scoped so an unrelated pull request does not pay for two Windows - # builds. The release job below is guarded on the tag ref, so nothing is - # published from a pull request no matter what runs here. + # builds. + # + # **Nothing is published from a pull request**, and that now takes two guards + # rather than one. The release job below is guarded on the tag ref, which was + # once described here as sufficient -- it was not. The artifact upload in the + # build job ran on every trigger, so each pull-request run attached two + # downloadable binaries stamped `[ci]` that reported themselves official. The + # publishing steps are guarded on the tag ref too, and this sentence is only + # true because of that. pull_request: # **The dependencies are in this filter for a reason specific to ARM64.** # This is the only workflow in the repository that builds @@ -163,10 +170,31 @@ jobs: ;; esac + # **Everything below here is release-only, and the guard is the point.** + # + # A pull-request or dispatch run builds and verifies -- that is why those + # triggers exist -- but it must not *publish*. Without the guard these + # steps ran on every trigger, so each pull-request run attached two + # downloadable binaries stamped `[ci]`, which `is_official` accepts and + # `SubmissionRecord::is_fully_trusted` then passes. Anyone could take one + # from the run's artifact list and submit results indistinguishable from a + # real release build, which is precisely the boundary this file's header + # says the release attachment exists to draw. + # + # Measured on this pull request rather than reasoned about: the run + # published both artifacts, and the x86_64 one reported + # `v0.1.0 5e9e85de527c [ci]` -- no `!!UNOFFICIAL!!` marker. Worse, on a + # `pull_request` event `github.sha` is the ephemeral merge commit, so that + # identity names a commit which exists on no branch and cannot be checked + # out. The claim was not merely unearned; it was unresolvable. + # + # The release job below is already tag-guarded, so nothing consumes these + # on any other trigger and gating them loses nothing. - name: Rebuild with the stamps for release # The negative check above overwrote the artifact with an unstamped one. # Rebuilding is not a formality: shipping that binary would attach a # file marked UNOFFICIAL to an official release. + if: startsWith(github.ref, 'refs/tags/placement-probe-v') shell: bash env: PLACEMENT_PROBE_COMMIT: ${{ github.sha }} @@ -177,6 +205,7 @@ jobs: -p windows-placement-probe --bin placement-probe - name: Name the artifact for its architecture + if: startsWith(github.ref, 'refs/tags/placement-probe-v') shell: bash run: | arch="${{ matrix.target }}" @@ -186,6 +215,7 @@ jobs: "dist/placement-probe-${arch}.exe" - uses: actions/upload-artifact@v4 + if: startsWith(github.ref, 'refs/tags/placement-probe-v') with: name: placement-probe-${{ matrix.target }} path: dist/placement-probe-*.exe From 485e27c21446171d14a29a48100a113cea8baec4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 01:08:25 -0400 Subject: [PATCH 114/361] fix(file-watcher): stop the test drain discarding another subscription's notification CI failure: two_subscriptions_on_one_session_are_distinguishable timed out waiting for a change to in-second.txt. The fault is in the test helper, not the watcher. A receiver carries every subscription's stream, and await_change looped until the name it wanted turned up, dropping everything else. The two directories in that test are watched independently and nothing orders their notifications, so when the second arrived first the wait for in-first.txt consumed and threw it away; the wait for in-second.txt then blocked for the full 30s on something that had already been delivered. Diagnosed by reproducing it deterministically rather than by re-running until it failed: writing the second file first, with a gap, fails in 30s every time and produces the identical message. The watcher had delivered both notifications correctly in every case. await_change is replaced by a Drain that holds what it has not been asked for, so arrival order stops mattering, and all five call sites move to it -- the discarding helper is gone rather than left available to the next test that waits twice. The forced-order case is kept as a regression test; with the stash it passes in 0.31s. Sabotage-checked: restoring the discard fails that test in 30.31s. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-file-watcher/src/watch/tests.rs | 155 +++++++++++++++--- 1 file changed, 130 insertions(+), 25 deletions(-) diff --git a/crates/windows-file-watcher/src/watch/tests.rs b/crates/windows-file-watcher/src/watch/tests.rs index 5dba3646..f4209c28 100644 --- a/crates/windows-file-watcher/src/watch/tests.rs +++ b/crates/windows-file-watcher/src/watch/tests.rs @@ -20,28 +20,84 @@ use crate::testing::TempDir; /// Upper bound for waiting on a change the kernel really should report. const NOTIFY_TIMEOUT: Duration = Duration::from_secs(30); -/// Drain until a change with `name` arrives, returning the subscription it was -/// tagged with. Fails rather than hanging. -fn await_change(receiver: &Receiver, name: &str) -> WatchId { - let deadline = Instant::now() + NOTIFY_TIMEOUT; - loop { - let remaining = deadline - .checked_duration_since(Instant::now()) - .unwrap_or_default(); - assert!( - !remaining.is_zero(), - "timed out waiting for a change to {name}" - ); - let Some(item) = receiver.recv_timeout(remaining) else { - continue; - }; - if let Notification::Batch { watch, changes } = item - && changes.iter().any(|change| { +/// The subscription this notification tagged, if it reports `name` as added. +fn added_by(item: &Notification, name: &str) -> Option { + match item { + Notification::Batch { watch, changes } + if changes.iter().any(|change| { change.kind == ChangeKind::Added && change.name.to_os_string().to_string_lossy() == name - }) + }) => + { + Some(*watch) + } + _ => None, + } +} + +/// A receiver being drained, holding on to whatever has not been asked for yet. +/// +/// # Waiting for one name must not discard the others +/// +/// **A receiver carries every subscription's stream, and two watches deliver +/// independently.** The obvious helper -- loop until the wanted name shows up, +/// dropping whatever else arrives -- silently destroys notifications the test +/// has not asked for *yet*. A second wait then blocks for the full timeout on +/// something that already arrived and was thrown away. +/// +/// That is not hypothetical: it is what +/// [`two_subscriptions_on_one_session_are_distinguishable`] hit in CI. Its two +/// directories are watched separately, nothing orders one against the other, +/// and when the second arrived first the wait for `in-first.txt` consumed and +/// discarded it. Reproduced deterministically by writing the second file first +/// -- the failure is a property of the helper, not of the watcher, which had +/// delivered both. +/// +/// Holding the unmatched items makes the order irrelevant, so a test states +/// what it expects to see rather than what order it must arrive in. +struct Drain<'a> { + receiver: &'a Receiver, + held: Vec, +} + +impl<'a> Drain<'a> { + fn new(receiver: &'a Receiver) -> Self { + Self { + receiver, + held: Vec::new(), + } + } + + /// Wait for a change to `name`, returning the subscription it was tagged + /// with. Fails rather than hanging. + fn wait_for(&mut self, name: &str) -> WatchId { + // Anything already taken off the receiver is checked first, which is + // the whole point of holding it. + if let Some(index) = self + .held + .iter() + .position(|item| added_by(item, name).is_some()) { - return watch; + let item = self.held.remove(index); + return added_by(&item, name).expect("just matched"); + } + + let deadline = Instant::now() + NOTIFY_TIMEOUT; + loop { + let remaining = deadline + .checked_duration_since(Instant::now()) + .unwrap_or_default(); + assert!( + !remaining.is_zero(), + "timed out waiting for a change to {name}" + ); + let Some(item) = self.receiver.recv_timeout(remaining) else { + continue; + }; + if let Some(watch) = added_by(&item, name) { + return watch; + } + self.held.push(item); } } } @@ -188,7 +244,7 @@ fn a_watch_delivers_to_its_sessions_receiver_tagged_with_its_own_id() { std::fs::write(dir.path().join("alpha.txt"), b"a").expect("create"); assert_eq!( - await_change(&receiver, "alpha.txt"), + Drain::new(&receiver).wait_for("alpha.txt"), watch.id(), "a notification carries the subscription that produced it" ); @@ -217,8 +273,14 @@ fn two_subscriptions_on_one_session_are_distinguishable() { std::fs::write(second.path().join("in-second.txt"), b"b").expect("create"); // One receiver, two streams: the tag is what lets a client tell them apart. - assert_eq!(await_change(&receiver, "in-first.txt"), a.id()); - assert_eq!(await_change(&receiver, "in-second.txt"), b.id()); + // + // One `Drain` for both waits, deliberately. The two directories are watched + // independently and nothing orders their notifications, so either may + // arrive first; a helper that dropped the one it was not asked for would + // make this test fail whenever the second beat the first. + let mut changes = Drain::new(&receiver); + assert_eq!(changes.wait_for("in-first.txt"), a.id()); + assert_eq!(changes.wait_for("in-second.txt"), b.id()); drop((a, b)); drop(monitor); @@ -241,7 +303,10 @@ fn a_subtree_subscription_reports_below_itself() { std::fs::create_dir(&nested).expect("create the subdirectory"); std::fs::write(nested.join("deep.txt"), b"deep").expect("create the nested file"); - assert_eq!(await_change(&receiver, "nested\\deep.txt"), watch.id()); + assert_eq!( + Drain::new(&receiver).wait_for("nested\\deep.txt"), + watch.id() + ); drop(watch); drop(monitor); @@ -667,7 +732,7 @@ fn a_file_target_reports_changes_to_that_file_and_nothing_else() { std::fs::remove_file(&target).expect("remove the target"); std::fs::write(&target, b"recreated").expect("recreate the target"); - assert_eq!(await_change(&receiver, "target.txt"), watch.id()); + assert_eq!(Drain::new(&receiver).wait_for("target.txt"), watch.id()); drop(watch); drop(monitor); @@ -697,10 +762,50 @@ fn a_file_target_and_a_directory_target_on_the_same_directory_coalesce() { ); std::fs::write(dir.path().join("fresh.txt"), b"x").expect("create a new file"); - let seen_by = await_change(&receiver, "fresh.txt"); + let seen_by = Drain::new(&receiver).wait_for("fresh.txt"); assert!(seen_by == file_watch.id() || seen_by == dir_watch.id()); drop((file_watch, dir_watch)); drop(monitor); dir.cleanup(); } + +#[test] +fn a_wait_does_not_discard_another_subscriptions_notification() { + // **The regression guard for a real CI failure.** Two directories are + // watched independently and nothing orders their notifications, so the + // second may be delivered first. A drain that dropped whatever it was not + // currently asked for destroyed that notification, and the next wait then + // blocked for the full timeout on something that had already arrived -- + // reported as "timed out waiting for a change to in-second.txt". + // + // Writing the second file first, with a gap, forces the order that made it + // fail rather than waiting for chance to produce it: before the fix this + // failed in 30s every run, and after it passes in well under one. + let first = TempDir::new("watch-order-first"); + let second = TempDir::new("watch-order-second"); + let monitor = Monitor::new().expect("create the monitor"); + let (session, receiver) = monitor.session(); + + let a = session + .subscribe(first.path(), WatchOptions::new()) + .expect("register"); + let b = session + .subscribe(second.path(), WatchOptions::new()) + .expect("register"); + monitor.quiesce(); + + std::fs::write(second.path().join("in-second.txt"), b"b").expect("create"); + std::thread::sleep(Duration::from_millis(300)); + std::fs::write(first.path().join("in-first.txt"), b"a").expect("create"); + + // Asked for in the opposite order to the one they arrived in. + let mut changes = Drain::new(&receiver); + assert_eq!(changes.wait_for("in-first.txt"), a.id()); + assert_eq!(changes.wait_for("in-second.txt"), b.id()); + + drop((a, b)); + drop(monitor); + first.cleanup(); + second.cleanup(); +} From 055e75d74f0e5c64f627a6c74fad8d37bdef103e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 01:22:04 -0400 Subject: [PATCH 115/361] chore(placement-probe)!: collapse the record schema back to v1 The schema reached v4 during development, each bump adding a derived golden on the reasoning that this tool exists to emit records people paste and some had been produced locally. Followed consistently, that would have released a crate whose first published version carried four schema files and three versions no obtainable build ever emitted. The append-only rule protects records held by other people. This crate has never been released, so there are none, and the rule's rationale does not yet apply. v2, v3 and v4 are removed, v1.txt is regenerated from the current record, and SCHEMA_VERSION returns to 1. The boundary is now stated rather than judged per change: regenerate v1.txt while unreleased; from the first release onward never edit a published golden, raise the version and add the next file beside it. Recorded in the crate's DESIGN-NOTES.md so the reasoning is not re-derived, together with the two rejected alternatives -- keeping v1 through v4, and dropping the golden until release. The guard itself is unweakened, which is the part worth checking rather than assuming: it compares the file against a freshly serialized record either way. Sabotage-checked by adding a field, which fails the test naming all three affected paths. Marked breaking because the emitted schema_version changes for anyone who built from this branch, even though no release carried v2 through v4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-placement-probe/DESIGN-NOTES.md | 40 +++++++ crates/windows-placement-probe/schema/v1.txt | 25 ++++- crates/windows-placement-probe/schema/v2.txt | 89 --------------- crates/windows-placement-probe/schema/v3.txt | 97 ----------------- crates/windows-placement-probe/schema/v4.txt | 101 ------------------ crates/windows-placement-probe/src/record.rs | 17 ++- 6 files changed, 77 insertions(+), 292 deletions(-) delete mode 100644 crates/windows-placement-probe/schema/v2.txt delete mode 100644 crates/windows-placement-probe/schema/v3.txt delete mode 100644 crates/windows-placement-probe/schema/v4.txt diff --git a/crates/windows-placement-probe/DESIGN-NOTES.md b/crates/windows-placement-probe/DESIGN-NOTES.md index be36b7dd..84896079 100644 --- a/crates/windows-placement-probe/DESIGN-NOTES.md +++ b/crates/windows-placement-probe/DESIGN-NOTES.md @@ -50,3 +50,43 @@ later: the record schema becomes a semver surface the moment anyone stores one (see the package metadata), and the README must say plainly that a crates.io build produces records marked unofficial, so nobody chooses that path without knowing what it costs the data. + +## The schema freezes at the first release, not before + +**Decided: regenerate `schema/v1.txt` in place while the tool is unreleased, and +apply the append-only rule from the first release onward.** + +The archived golden exists so that a shape change cannot happen silently, and the +append-only rule on top of it exists for a narrower reason: **a record already +held by someone else cannot be regenerated**, so once a record in the wild claims +schema N, N's meaning is fixed. + +That rationale is about other people's data. Until the tool is released nobody +has any, so bumping the version records a shape that never reached a single +reader -- and the first public release then ships already carrying dead numbers, +each with an archived file describing a record that never existed. + +This was learned by doing it. During development the schema was raised twice in +one evening, to v3 and then v4, each with a derived golden, on the reasoning that +the tool exists to emit records people paste and some had been produced locally. +Following that consistently would have released a crate whose first version +carried four schema files and three unreachable versions. It was collapsed back +to v1 in one change. + +**The latitude ends at the release, and the boundary is deliberately sharp** +rather than a judgement call repeated per change: + +- *Before the first release* -- regenerate `v1.txt` from the record. The golden + still guards every change, because the test compares the file against a freshly + serialized record either way; what is given up is only the archive of shapes + nobody received. +- *After the first release* -- never edit a published golden. The next shape + change raises `SCHEMA_VERSION` and adds the next file beside it. + +**Rejected: keeping v1 through v4.** It is the safer-looking option and it is +strictly worse for a reader, who would find four archived shapes and no way to +tell that three of them were never emitted by any build anyone could obtain. + +**Rejected: dropping the golden until release.** The guard is what makes an +accidental shape change visible, and that is as valuable during development as +after it -- more so, since that is when the shape actually moves. diff --git a/crates/windows-placement-probe/schema/v1.txt b/crates/windows-placement-probe/schema/v1.txt index 23ccb230..3a7cd4e3 100644 --- a/crates/windows-placement-probe/schema/v1.txt +++ b/crates/windows-placement-probe/schema/v1.txt @@ -4,12 +4,17 @@ # fully populated record and walking the result -- never written by hand, so it # cannot drift from the type it describes. # -# APPEND-ONLY. Never edit a published version: records already in the wild -# claim this number, and they cannot be regenerated. To change the shape, raise -# SCHEMA_VERSION and add the next file beside this one. -# # An array contributes "field[]" rather than one entry per element, so this # describes the shape and not the size of any one sample. +# +# APPEND-ONLY FROM THE FIRST RELEASE ONWARD. The freeze protects records held +# by other people, and until the tool is released there are none -- so this +# file was regenerated in place during development rather than accumulating +# versions nobody ever received. It reached v4 that way and was collapsed back. +# +# Once the tool is released that latitude is gone: the next shape change raises +# SCHEMA_VERSION to 2 and adds v2.txt beside this file, and this file is never +# edited again, because a record in the wild claiming v1 cannot be regenerated. build build.commit @@ -19,15 +24,19 @@ build.source by_class by_class[] by_class[].consumer_batch +by_class[].consumer_efficiency_class by_class[].consumer_group by_class[].consumer_numa_node by_class[].consumer_number +by_class[].memory_node by_class[].nanos_per_item by_class[].placement by_class[].producer_batch +by_class[].producer_efficiency_class by_class[].producer_group by_class[].producer_numa_node by_class[].producer_number +by_class[].requested_memory_node by_class[].slice by_class[].strategy host @@ -53,29 +62,37 @@ machine.virtualisation_name node_hops node_hops[] node_hops[].consumer_batch +node_hops[].consumer_efficiency_class node_hops[].consumer_group node_hops[].consumer_numa_node node_hops[].consumer_number +node_hops[].memory_node node_hops[].nanos_per_item node_hops[].placement node_hops[].producer_batch +node_hops[].producer_efficiency_class node_hops[].producer_group node_hops[].producer_numa_node node_hops[].producer_number +node_hops[].requested_memory_node node_hops[].slice node_hops[].strategy placements placements[] placements[].consumer_batch +placements[].consumer_efficiency_class placements[].consumer_group placements[].consumer_numa_node placements[].consumer_number +placements[].memory_node placements[].nanos_per_item placements[].placement placements[].producer_batch +placements[].producer_efficiency_class placements[].producer_group placements[].producer_numa_node placements[].producer_number +placements[].requested_memory_node placements[].slice placements[].strategy recorded_at diff --git a/crates/windows-placement-probe/schema/v2.txt b/crates/windows-placement-probe/schema/v2.txt deleted file mode 100644 index ad93aba3..00000000 --- a/crates/windows-placement-probe/schema/v2.txt +++ /dev/null @@ -1,89 +0,0 @@ -# Schema v2 for windows-placement-probe submission records. -# -# Every key path the record serializes to, sorted. Derived by serializing a -# fully populated record and walking the result -- never written by hand, so it -# cannot drift from the type it describes. -# -# APPEND-ONLY. Never edit a published version: records already in the wild -# claim this number, and they cannot be regenerated. To change the shape, raise -# SCHEMA_VERSION and add the next file beside this one. -# -# Changed from v1: every measurement row gains "memory_node", naming which NUMA -# node held the ring. A v1 row does not carry it, and must not be read as though -# the memory was somewhere irrelevant -- it was simply not controlled or -# recorded. - -build -build.commit -build.crate_version -build.dirty -build.source -by_class -by_class[] -by_class[].consumer_batch -by_class[].consumer_group -by_class[].consumer_numa_node -by_class[].consumer_number -by_class[].memory_node -by_class[].nanos_per_item -by_class[].placement -by_class[].producer_batch -by_class[].producer_group -by_class[].producer_numa_node -by_class[].producer_number -by_class[].slice -by_class[].strategy -host -host.arch -host.cache_domain_sizes -host.cache_domain_sizes[] -host.cores -host.efficiency_classes -host.efficiency_classes[] -host.efficiency_classes[][] -host.numa_node_sizes -host.numa_node_sizes[] -host.partitioning_cache_level -host.processors -host.provenance -host.smt -machine -machine.cpu_model -machine.model_suppressed -machine.os_build -machine.virtualisation -machine.virtualisation_name -node_hops -node_hops[] -node_hops[].consumer_batch -node_hops[].consumer_group -node_hops[].consumer_numa_node -node_hops[].consumer_number -node_hops[].memory_node -node_hops[].nanos_per_item -node_hops[].placement -node_hops[].producer_batch -node_hops[].producer_group -node_hops[].producer_numa_node -node_hops[].producer_number -node_hops[].slice -node_hops[].strategy -placements -placements[] -placements[].consumer_batch -placements[].consumer_group -placements[].consumer_numa_node -placements[].consumer_number -placements[].memory_node -placements[].nanos_per_item -placements[].placement -placements[].producer_batch -placements[].producer_group -placements[].producer_numa_node -placements[].producer_number -placements[].slice -placements[].strategy -recorded_at -recorded_at_epoch_seconds -schema_version -topology_provenance diff --git a/crates/windows-placement-probe/schema/v3.txt b/crates/windows-placement-probe/schema/v3.txt deleted file mode 100644 index 40022ea0..00000000 --- a/crates/windows-placement-probe/schema/v3.txt +++ /dev/null @@ -1,97 +0,0 @@ -# Schema v3 for windows-placement-probe submission records. -# -# Every key path the record serializes to, sorted. Derived by serializing a -# fully populated record and walking the result -- never written by hand, so it -# cannot drift from the type it describes. -# -# APPEND-ONLY. Never edit a published version: records already in the wild -# claim this number, and they cannot be regenerated. To change the shape, raise -# SCHEMA_VERSION and add the next file beside this one. -# -# Changed from v2: every measurement row gains "producer_efficiency_class" and -# "consumer_efficiency_class". Without them a "by_class" row could not be -# attributed to the class it measures -- that list holds one same-class pair -# per class, so its rows agree on "placement" and "strategy" and were -# indistinguishable in v2. A v2 row does not carry them; do not assume a class -# of 0, because the field was absent rather than measured as zero. - -build -build.commit -build.crate_version -build.dirty -build.source -by_class -by_class[] -by_class[].consumer_batch -by_class[].consumer_efficiency_class -by_class[].consumer_group -by_class[].consumer_numa_node -by_class[].consumer_number -by_class[].memory_node -by_class[].nanos_per_item -by_class[].placement -by_class[].producer_batch -by_class[].producer_efficiency_class -by_class[].producer_group -by_class[].producer_numa_node -by_class[].producer_number -by_class[].slice -by_class[].strategy -host -host.arch -host.cache_domain_sizes -host.cache_domain_sizes[] -host.cores -host.efficiency_classes -host.efficiency_classes[] -host.efficiency_classes[][] -host.numa_node_sizes -host.numa_node_sizes[] -host.partitioning_cache_level -host.processors -host.provenance -host.smt -machine -machine.cpu_model -machine.model_suppressed -machine.os_build -machine.virtualisation -machine.virtualisation_name -node_hops -node_hops[] -node_hops[].consumer_batch -node_hops[].consumer_efficiency_class -node_hops[].consumer_group -node_hops[].consumer_numa_node -node_hops[].consumer_number -node_hops[].memory_node -node_hops[].nanos_per_item -node_hops[].placement -node_hops[].producer_batch -node_hops[].producer_efficiency_class -node_hops[].producer_group -node_hops[].producer_numa_node -node_hops[].producer_number -node_hops[].slice -node_hops[].strategy -placements -placements[] -placements[].consumer_batch -placements[].consumer_efficiency_class -placements[].consumer_group -placements[].consumer_numa_node -placements[].consumer_number -placements[].memory_node -placements[].nanos_per_item -placements[].placement -placements[].producer_batch -placements[].producer_efficiency_class -placements[].producer_group -placements[].producer_numa_node -placements[].producer_number -placements[].slice -placements[].strategy -recorded_at -recorded_at_epoch_seconds -schema_version -topology_provenance diff --git a/crates/windows-placement-probe/schema/v4.txt b/crates/windows-placement-probe/schema/v4.txt deleted file mode 100644 index 238757bb..00000000 --- a/crates/windows-placement-probe/schema/v4.txt +++ /dev/null @@ -1,101 +0,0 @@ -# Schema v4 for windows-placement-probe submission records. -# -# Every key path the record serializes to, sorted. Derived by serializing a -# fully populated record and walking the result -- never written by hand, so it -# cannot drift from the type it describes. -# -# APPEND-ONLY. Never edit a published version: records already in the wild -# claim this number, and they cannot be regenerated. To change the shape, raise -# SCHEMA_VERSION and add the next file beside this one. -# -# Changed from v3: every measurement row gains "requested_memory_node", the -# node the run asked for, beside "memory_node", the node it got. Windows may -# satisfy an allocation on a different node, so keyed on the achieved node -# alone the two "node_hops" rows for a directed pair can be identical; the -# requested node is what tells them apart. A row whose two nodes disagree did -# not measure the placement it names. A v3 row does not carry the request, so -# its ring placement cannot be recovered. - -build -build.commit -build.crate_version -build.dirty -build.source -by_class -by_class[] -by_class[].consumer_batch -by_class[].consumer_efficiency_class -by_class[].consumer_group -by_class[].consumer_numa_node -by_class[].consumer_number -by_class[].memory_node -by_class[].nanos_per_item -by_class[].placement -by_class[].producer_batch -by_class[].producer_efficiency_class -by_class[].producer_group -by_class[].producer_numa_node -by_class[].producer_number -by_class[].requested_memory_node -by_class[].slice -by_class[].strategy -host -host.arch -host.cache_domain_sizes -host.cache_domain_sizes[] -host.cores -host.efficiency_classes -host.efficiency_classes[] -host.efficiency_classes[][] -host.numa_node_sizes -host.numa_node_sizes[] -host.partitioning_cache_level -host.processors -host.provenance -host.smt -machine -machine.cpu_model -machine.model_suppressed -machine.os_build -machine.virtualisation -machine.virtualisation_name -node_hops -node_hops[] -node_hops[].consumer_batch -node_hops[].consumer_efficiency_class -node_hops[].consumer_group -node_hops[].consumer_numa_node -node_hops[].consumer_number -node_hops[].memory_node -node_hops[].nanos_per_item -node_hops[].placement -node_hops[].producer_batch -node_hops[].producer_efficiency_class -node_hops[].producer_group -node_hops[].producer_numa_node -node_hops[].producer_number -node_hops[].requested_memory_node -node_hops[].slice -node_hops[].strategy -placements -placements[] -placements[].consumer_batch -placements[].consumer_efficiency_class -placements[].consumer_group -placements[].consumer_numa_node -placements[].consumer_number -placements[].memory_node -placements[].nanos_per_item -placements[].placement -placements[].producer_batch -placements[].producer_efficiency_class -placements[].producer_group -placements[].producer_numa_node -placements[].producer_number -placements[].requested_memory_node -placements[].slice -placements[].strategy -recorded_at -recorded_at_epoch_seconds -schema_version -topology_provenance diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index c230c756..b6f82ed6 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -51,7 +51,22 @@ use crate::machine::MachineDescription; /// **The golden files are append-only and a published version is never /// redefined.** Once a record exists in the wild claiming schema N, N's meaning /// is fixed, because that record cannot be regenerated. -pub const SCHEMA_VERSION: u32 = 4; +/// +/// # That rule starts at the first release, and this crate has not had one +/// +/// The freeze exists to protect records held by other people. Until the tool is +/// released there are none, so a version bump would archive a shape that never +/// reached anyone and leave the first public release already carrying dead +/// numbers. +/// +/// This crate reached version 4 during development that way, and was collapsed +/// back to version 1. +/// +/// **After the first release the rule above applies without exception**: the +/// next shape change raises this to 2 and adds `schema/v2.txt`, and `v1.txt` is +/// never touched again. See this crate's `DESIGN-NOTES.md`, "The schema freezes +/// at the first release, not before". +pub const SCHEMA_VERSION: u32 = 1; /// One run's complete output. #[derive(Clone, Debug)] From a2bf46c9cc07a656062e2a40db93bfff30850795 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 01:41:23 -0400 Subject: [PATCH 116/361] fix(ioring): escape every JSON control character, and name the storage bus tags Two defects in the file-handle NUMA spike. The hand-rolled JSON escaping covered only backslash and quote. The product and vendor strings come from firmware, not from this program, and JSON forbids any unescaped character below U+0020 -- so a device reporting a tab or newline in its product text turned the one line promising to be machine-readable into the line that fails to parse. Verified both ways by feeding a product string containing U+0009, U+000A, U+0001 and U+001F through the real runner: the old escaping produced "Unterminated string" from a JSON parser, and the new one parses and round-trips. Separately, the STORAGE_BUS_TYPE discriminants were bare hex literals in a match, against the repository rule on manifest numeric constants. They are now a named const module documenting that the values are Windows's own, so editing one would not rename a bus but silently report a different one -- which matters here because telling a virtual bus from real hardware is what this spike is for. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spikes/file-handle-numa-spike.rs | 115 ++++++++++++++---- 1 file changed, 89 insertions(+), 26 deletions(-) diff --git a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs index f8b7a751..9e10935a 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs @@ -141,30 +141,97 @@ struct Storage { disk_extents: Option, } +/// `STORAGE_BUS_TYPE`, as Windows defines it. +/// +/// **These values are not ours to choose.** Each is the discriminant Windows +/// assigns in `STORAGE_BUS_TYPE`, so changing one would not rename a bus, it +/// would silently report a different one -- and this spike's whole purpose is +/// distinguishing a virtual bus from real hardware. Named rather than written +/// inline because windows-sys 0.61 does not expose the enum, and a bare +/// `0x11 =>` in a match arm is a number a reader has nothing to check against. +/// +/// Adding a value as the SDK grows is safe; editing one is a breaking change to +/// what this probe reports. +mod bus { + pub const SCSI: u8 = 0x01; + pub const ATAPI: u8 = 0x02; + pub const ATA: u8 = 0x03; + pub const IEEE1394: u8 = 0x04; + pub const SSA: u8 = 0x05; + pub const FIBRE: u8 = 0x06; + pub const USB: u8 = 0x07; + pub const RAID: u8 = 0x08; + pub const ISCSI: u8 = 0x09; + pub const SAS: u8 = 0x0A; + pub const SATA: u8 = 0x0B; + pub const SD: u8 = 0x0C; + pub const MMC: u8 = 0x0D; + pub const VIRTUAL: u8 = 0x0E; + pub const FILE_BACKED_VIRTUAL: u8 = 0x0F; + pub const SPACES: u8 = 0x10; + pub const NVME: u8 = 0x11; + pub const SCM: u8 = 0x12; + pub const UFS: u8 = 0x13; +} + +/// One JSON string literal, with every character JSON requires escaped. +/// +/// **The strings here come from firmware, not from this program.** A storage +/// descriptor's product and vendor text is whatever the device reports, and a +/// device that returns a tab or a newline inside it is unusual rather than +/// impossible. Escaping only `\` and `"` -- which is what this did -- emits +/// those bytes raw, and JSON forbids an unescaped character below U+0020, so +/// the one line in this spike that promises to be machine-readable would be +/// the line that fails to parse. Escaped here rather than depended on not +/// happening, because a CI log is mined long after the run. +fn json_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + // Every other control character has no short form and must be + // written as a Unicode escape. + c if (c as u32) < 0x20 => { + out.push_str(&format!("\\u{:04x}", c as u32)); + } + c => out.push(c), + } + } + out.push('"'); + out +} + /// `STORAGE_BUS_TYPE` values worth naming. A virtual bus is the tell for a /// hosted runner; NVMe and SAS are the cases where device proximity data /// plausibly exists. -fn bus_name(bus: u8) -> &'static str { - match bus { - 0x01 => "SCSI", - 0x02 => "ATAPI", - 0x03 => "ATA", - 0x04 => "1394", - 0x05 => "SSA", - 0x06 => "Fibre", - 0x07 => "USB", - 0x08 => "RAID", - 0x09 => "iSCSI", - 0x0A => "SAS", - 0x0B => "SATA", - 0x0C => "SD", - 0x0D => "MMC", - 0x0E => "Virtual", - 0x0F => "FileBackedVirtual", - 0x10 => "Spaces", - 0x11 => "NVMe", - 0x12 => "SCM", - 0x13 => "UFS", +fn bus_name(value: u8) -> &'static str { + match value { + bus::SCSI => "SCSI", + bus::ATAPI => "ATAPI", + bus::ATA => "ATA", + bus::IEEE1394 => "1394", + bus::SSA => "SSA", + bus::FIBRE => "Fibre", + bus::USB => "USB", + bus::RAID => "RAID", + bus::ISCSI => "iSCSI", + bus::SAS => "SAS", + bus::SATA => "SATA", + bus::SD => "SD", + bus::MMC => "MMC", + bus::VIRTUAL => "Virtual", + bus::FILE_BACKED_VIRTUAL => "FileBackedVirtual", + bus::SPACES => "Spaces", + bus::NVME => "NVMe", + bus::SCM => "SCM", + bus::UFS => "UFS", _ => "unknown", } } @@ -516,11 +583,7 @@ fn main() -> std::io::Result<()> { // One machine-readable line, so accumulated CI logs can be mined without // parsing the prose above. let json_opt_u32 = |v: Option| v.map_or("null".to_string(), |n| n.to_string()); - let json_opt_str = |v: Option<&str>| { - v.map_or("null".to_string(), |s| { - format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) - }) - }; + let json_opt_str = |v: Option<&str>| v.map_or("null".to_string(), json_string); println!( concat!( r#"{{"reason":"x-spike-file-handle-numa","arch":"{}","volume_root":{},"#, From 678221b06e8b3280507875de14ed8371a3fc4d02 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 01:47:20 -0400 Subject: [PATCH 117/361] docs(placement-probe): correct the fingerprint's equality claim where it is still stated The provenance field already says equal strings mean equal marginal shape and not equal expressible placements, and says an earlier note claimed otherwise. Two other statements of the same fact were never updated with it: the fingerprint module header, and windows-platform-probes' design note. Both still told a reader that two hosts rendering one string can express the same placements, which is what pools incomparable measurements. Swept rather than fixed at the reported site: the reviewer named the module header, and the platform-probes note carried it too. That note's argument -- provenance must live inside the string, or a synthetic host compares equal to a real one -- survives unchanged, because it needs only that the string is compared, not that it implies placement equivalence. The module header's link is spelled from the crate root because lib.rs also carries an outer doc comment on the module, so the merged documentation resolves links in the parent's scope; the bare name does not resolve there, which the rustdoc job catches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/fingerprint.rs | 25 +++++++++++++++---- .../windows-platform-probes/DESIGN-NOTES.md | 9 ++++--- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index b9974bcd..e120343c 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -53,11 +53,26 @@ //! a synthetic host compare *equal* to a real one -- and the comparison is the //! whole point of having a canonical form. //! -//! **It is canonical**, so two hosts that render the same string can express -//! the same placements, and string equality is a usable comparison. It -//! deliberately omits clock speeds, cache sizes, and model names: those vary -//! without changing which experiments are possible, and a fingerprint that -//! changes when the answer does not is a fingerprint nobody can compare. +//! **It is a canonical summary of the machine's marginal shape.** Two hosts +//! rendering the same string have the same processor, core, cache-domain, +//! efficiency-class and NUMA-node *sizes*, which makes string equality a usable +//! way to group results by shape. It deliberately omits clock speeds, cache +//! sizes, and model names: those vary without changing which experiments are +//! possible, and a fingerprint that changes when the answer does not is a +//! fingerprint nobody can compare. +//! +//! **Equal strings do not mean the two hosts can express the same placements.** +//! Every partition is recorded as a list of sizes and never as how those +//! partitions intersect, so two hosts can agree here and still offer different +//! pairs to measure. That is set out in full on the `provenance` field of +//! [`Fingerprint`](crate::fingerprint::Fingerprint), along with why it is not a +//! key for pooling measurements; do not restate the stronger claim here, which +//! is what an earlier revision of this header did. +//! +//! (The path is spelled from the crate root deliberately: `lib.rs` also carries +//! an outer doc comment on `pub mod fingerprint;`, so the merged documentation +//! resolves its links in the parent's scope, where the bare name is not in +//! scope.) use std::fmt; diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index e6d2952f..06326108 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -576,9 +576,12 @@ measured reason: the effects it studies are coherence effects that a debug build ## The fingerprint carries provenance inside the string, not beside it -The fingerprint is documented as **canonical**: two hosts rendering the same string can express the -same placements, so string equality is a supported comparison. That property is what forces the -provenance marker to live *inside* the rendered form. A marker kept alongside -- a separate field, a +The fingerprint is a **canonical summary of a machine's marginal shape**: two hosts rendering the +same string have the same processor, core, cache-domain, class and node sizes, so string equality +is a supported way to group results by shape. (It does *not* mean the two can express the same +placements -- the sizes are recorded without how the partitions intersect. See +[`Fingerprint::provenance`](../windows-placement-probe/src/fingerprint.rs).) That the string is +compared at all is what forces the provenance marker to live *inside* the rendered form. A marker kept alongside -- a separate field, a second printed line, a note in the surrounding prose -- would leave a fabricated machine claiming the exact shape of a real one **comparing equal to it**. That is a concrete bug rather than a display preference, and it has a test named for it. From 9a95e3e75001e9b8d562f7af33984e24dbcd1306 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 01:47:38 -0400 Subject: [PATCH 118/361] docs: replace the anticipated WaitableQueue trait with the surface that shipped The root design note recorded a single WaitableQueue trait as anticipated and named SpscRing and MpscRing as the concrete types. Neither is what exists. The crate deliberately rejected the fat trait -- a shape never waited on has no doorbell to return and an unbounded one no capacity to report, so it is unimplementable rather than merely inelegant -- and ships Bounded, Consumer, Drain, Observable, Producer, Reserving and Waitable instead. The types are a module per shape (spsc, slotwise_mpsc, reserving_mpsc), each exporting its own Producer and Consumer. A reader following the canonical note was sent looking for a public surface that does not exist, and told a decision had been taken that had in fact been reversed. The section now carries a supersedence marker pointing at the crate's D-2 and at traits.rs, and keeps the part that survived: signatures had to be trait-compatible from the first type, which binds just as hard for a set of narrow traits as for one wide one. The open question later in the same file about whether the doorbell makes WaitableQueue consumer-side is answered in the same change, since it was deferred to the crate's notes and those have since answered it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DESIGN-NOTES.md | 44 ++++++++++++++++++++++++++++---------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index e3c22def..73c06db9 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -105,21 +105,30 @@ MPSC are siblings, and the shapes deferred to `M-inf` -- intrusive-linked and sh siblings too. No single queue is the queue. **The consequence is accepted deliberately: there is no bare `Queue` type.** A crate named -"queues" that exported one would be claiming a primacy the name denies, so every type is -specifically named (`SpscRing`, `MpscRing`) and a consumer must say which it wants. That stops a +"queues" that exported one would be claiming a primacy the name denies, so a consumer must say +which shape it wants: the shipped surface is a module per shape -- `spsc`, `slotwise_mpsc`, +`reserving_mpsc` -- each exporting its own `Producer` and `Consumer` handles. That stops a default from accreting by accident, which is the failure the plural is chosen to prevent. -**A `WaitableQueue` *trait* is the opposite case, and is anticipated.** The rule above forbids a -bare `Queue` *type*, and the distinction matters: a type named `Queue` sitting among peers claims -to be the one that matters, while a trait names the contract those peers *share* and claims -nothing. The two are complementary. Concrete types remain the primary API -- usable directly, -with no type parameter and no dispatch -- and the trait exists for consumers who want to be -generic over a shape, exactly as `std::io::Read` sits beside `File`. - -**Anticipating that trait is a constraint on the concrete types now, not an addition later.** If -one shape ships `pop(&mut self) -> Option` and another ships `try_pop(&self) -> Result`, no trait unifies them afterwards without a breaking change to one of them. Signatures -must therefore be trait-compatible from the first type, whether or not the trait ever ships. +**The single `WaitableQueue` trait anticipated here was rejected when the traits were built.** +Superseded by +[D-2](crates/windows-waitable-queues/DESIGN-NOTES.md#d-2) in the crate's own notes, and by +[traits.rs](crates/windows-waitable-queues/src/traits.rs), which states the same thing where a +reader of the code will meet it. + +The reasoning below was right about *why* a trait is not the same case as a bare type, and wrong +about the shape it would take. What forced the change is that a fat trait is not merely +inelegant but **unimplementable** by shapes this crate intends to ship: a queue that is never +waited on has no doorbell to return, and an unbounded one has no capacity to report. So the +capability is sliced the way `std::io` slices it -- `Read`, `Write`, `Seek`, rather than one +`Io` -- and what ships is `Bounded`, `Consumer`, `Drain`, `Observable`, `Producer`, `Reserving` +and `Waitable`, with each shape implementing the subset it genuinely has. + +**What survives unchanged is the constraint that motivated recording this early.** If one shape +ships `pop(&mut self) -> Option` and another ships `try_pop(&self) -> Result`, no +trait unifies them afterwards without a breaking change to one of them. Signatures must +therefore be trait-compatible from the first type -- which is exactly as binding for a set of +narrow traits as it would have been for one wide one. **So every shape is split into producer and consumer handles, and cardinality is expressed by `Clone`.** This is the hard part, because the conventions differ by shape: an SPSC queue is @@ -141,9 +150,12 @@ rule-you-must-remember that introduced to eliminate elsewhere in this workspace. **The doorbell belongs on the consumer side**, since the consumer is what waits and the producer -merely rings. Whether that makes `WaitableQueue` a consumer-side trait, or splits the contract -into a producer trait and a consumer trait, is left to the crate's own design notes rather than -guessed here. +merely rings. Whether that made the contract one consumer-side trait or a producer/consumer pair +was deferred to the crate's own design notes rather than guessed here, and has since been +answered: neither. The contract is sliced by *capability*, so waiting is its own trait -- +[`Waitable`](crates/windows-waitable-queues/src/traits.rs) -- which a shape implements only if it +has a doorbell at all. See +[D-2](crates/windows-waitable-queues/DESIGN-NOTES.md#d-2). **What unifies the family is waitability, not I/O.** An earlier candidate, `windows-io-queue`, was rejected on this point: the queues themselves have nothing to do with I/O, and the domain From e67365dd23f73e065b773002a3309795d661b8da Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 01:47:38 -0400 Subject: [PATCH 119/361] docs: mark two plans in progress that had checked items CHECKLIST-ship-topology-and-queues.md was recorded as not started with SH-1.1 through SH-1.4, SH-2.1 and SH-2.4 complete, and CHECKLIST.md likewise with M34.1 complete. The tracker is what a contributor consults to find active work, so a plan that is underway and reads as untouched hides it. Found by cross-checking every row against its checklist rather than only the one reported: seven rows, two mismatched. The remaining not-started row is accurate. The CHECKLIST.md description also gains M34, whose items came out of this review cycle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- PLANS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PLANS.md b/PLANS.md index c477f540..e649f1bc 100644 --- a/PLANS.md +++ b/PLANS.md @@ -18,8 +18,8 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | -| [CHECKLIST.md](CHECKLIST.md) | not started | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | -| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | not started | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | +| [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | +| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | | [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | in progress | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Only the crates.io publication is gated on releasing `windows-topology-sys` and `windows-waitable-queues`** -- an earlier version of this row gated the whole tool on them, which was wrong: the canonical artifact is a CI-built binary compiled from this repository through `path` dependencies, and it needs nothing published. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M1B keys a processor by `(group, number)` so a machine with more than 64 of them is not silently miscounted; M1C measures each NUMA hop in both directions and places the ring deliberately; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 distributes the binary; M5+ publishes to crates.io once the download path is established. | [crates/windows-placement-probe/DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | From 78a27acf39c69639ab111bc4cea50db1d440f160 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 01:49:39 -0400 Subject: [PATCH 120/361] docs: repoint links this branch's module moves left dangling M2 of the placement tool moved fingerprint.rs and core_affinity.rs out of windows-platform-probes, and a later commit moved the tool's binary to src/bin/placement_probe/main.rs. Three links still named the old paths: two in the probes' design note and one in M34.2, which is the item a future contributor follows to find the files it lists. Found by resolving every relative markdown link target in the repository rather than by noticing one. Two broken links remain and are left alone deliberately: a forward reference to a per-crate COMPLETED-PLANS.md that is created when a plan completes, and a windows-file-enumeration-sys path that became a directory -- neither is this branch's doing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST.md | 2 +- crates/windows-platform-probes/DESIGN-NOTES.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index c559b871..9a7d376a 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -130,7 +130,7 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. call `println!`/`eprintln!` from more than one site in a tool; introduce a writer trait, sink or formatter at the first occurrence and route everything through it. Seven binaries violate this today and were flagged individually in review 5072622803 on pull request #56: - [placement_probe.rs](crates/windows-placement-probe/src/bin/placement_probe.rs), + [main.rs](crates/windows-placement-probe/src/bin/placement_probe/main.rs), [doorbell_cost.rs](crates/windows-platform-probes/src/bin/doorbell_cost.rs), [queue_contention.rs](crates/windows-platform-probes/src/bin/queue_contention.rs), [request_cost.rs](crates/windows-platform-probes/src/bin/request_cost.rs), diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 06326108..b3f521f2 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -615,10 +615,10 @@ as unambiguously good and here it is only half true: **A seam that only moves data is safe. A seam that lets fabricated labels reach real hardware is not.** -- [`places_from_topology`](src/fingerprint.rs) **has** a seam. It is a pure conversion -- topology in, +- [`places_from_topology`](../windows-placement-probe/src/fingerprint.rs) **has** a seam. It is a pure conversion -- topology in, processor positions out, nothing pinned and nothing timed. A synthetic topology yields synthetic positions, which is what the caller asked for and cannot be mistaken for a measurement. -- [`measure`](src/core_affinity.rs) **must not**, and its documentation says so at the definition. +- [`measure`](../windows-placement-probe/src/core_affinity.rs) **must not**, and its documentation says so at the definition. A synthetic topology's processor *numbers* are still valid on the real host, so every pin would succeed and the run would produce genuine timings filed under fabricated node ids -- output indistinguishable from a real NUMA measurement that measured no such thing. The pin assertion does From 00e1f364b60aacff051a73b65694f3d74fea5ae0 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 02:13:59 -0400 Subject: [PATCH 121/361] fix(placement-probe): stop an affinity failure hanging the probe instead of failing it pin_current_thread panics on failure, and both threads then had a way to hang rather than stop. If the producer panicked while pinning, the consumer still entered consume and spun on items no living thread would ever write. Both transfer loops are unbounded with no deadline, so the process simply stopped making progress. If the consumer's pin panicked -- it ran after the producer had been spawned -- the unwind reached thread::scope's cleanup, which waits for a producer itself blocked forever on a ring nobody is draining. In CI either is a job timeout rather than a diagnosis. The consumer is now pinned before anything is spawned, so that failure propagates with nothing running. The producer publishes its pin outcome through a guard armed before the attempt, so an unwind out of the pin still reports; the consumer waits for that outcome and skips the transfer entirely when it is a failure, leaving join to surface the panic. Neither side enters the transfer until both pins are settled. Measured rather than argued: with the consumer ignoring the producer's pin state, a forced pin failure times out at 30s; with the fix both directions return in under a millisecond. Both are kept as regression tests using a processor number past usize::BITS, which fails deterministically on any machine rather than depending on which processors are online. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/peer_index_cache.rs | 69 ++++++++++++++++++- .../src/peer_index_cache/tests.rs | 57 +++++++++++++++ 2 files changed, 123 insertions(+), 3 deletions(-) diff --git a/crates/windows-placement-probe/src/peer_index_cache.rs b/crates/windows-placement-probe/src/peer_index_cache.rs index 71b78ea4..5658eefb 100644 --- a/crates/windows-placement-probe/src/peer_index_cache.rs +++ b/crates/windows-placement-probe/src/peer_index_cache.rs @@ -52,7 +52,7 @@ use std::cell::UnsafeCell; use std::hint::black_box; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering}; use std::thread; use std::time::Instant; @@ -657,19 +657,54 @@ pub fn time_model_placed( ) -> Sample { let ring = Ring::new_on(CAPACITY, memory_node); let placed_on = ring.memory_node(); + + // **Pinned before anything is spawned.** `pin_current_thread` panics on + // failure, and this used to run *after* the producer was already started: + // the unwind then reached `thread::scope`'s cleanup, which waits for a + // producer that is itself blocked forever on a ring nobody is draining. A + // failure that should stop the run hung it instead. Nothing is running yet + // here, so the panic simply propagates. + let _consumer_pinned = pin_current_thread(consumer_cpu); + + // Outside the scope so it outlives every borrow the spawned thread takes. + let producer_pin = AtomicU8::new(PIN_PENDING); + let started = Instant::now(); let (consumer_refreshes, producer_refreshes) = thread::scope(|scope| { let shared = ˚ + let producer_pin = &producer_pin; let producer = scope.spawn(move || { + // Armed *before* the pin attempt, so an unwind out of it still + // publishes an answer. Without that the consumer below waits on a + // thread that has already died. + let signal = PinSignal(producer_pin); // Bound, not discarded: an unbound guard drops at the end of its // own statement, which would unpin the thread immediately and // measure the scheduler's choice while claiming to measure this // processor. `#[must_use]` makes that mistake a warning. let _pinned = pin_current_thread(producer_cpu); + producer_pin.store(PIN_READY, Ordering::Release); + // Its work is done; dropping it now cannot overwrite `PIN_READY`. + drop(signal); produce(shared, strategy) }); - let _pinned = pin_current_thread(consumer_cpu); - let consumer_refreshes = consume(&ring, strategy); + + // **Neither side enters the transfer until both pins are settled.** + // Spinning rather than parking because the wait is a pin call long, + // and because this thread is already pinned and must not be handed to + // another processor by a blocking primitive. + while producer_pin.load(Ordering::Acquire) == PIN_PENDING { + std::hint::spin_loop(); + } + + // On failure `consume` is skipped entirely: it would spin forever on + // items no living producer will write. `join` then surfaces the + // producer's panic, which is the outcome the caller should see. + let consumer_refreshes = if producer_pin.load(Ordering::Acquire) == PIN_READY { + consume(&ring, strategy) + } else { + 0 + }; let producer_refreshes = producer.join().expect("the producer must not panic"); (consumer_refreshes, producer_refreshes) }); @@ -681,6 +716,34 @@ pub fn time_model_placed( } } +/// The producer has not yet reached the end of its pin attempt. +const PIN_PENDING: u8 = 0; +/// The producer is pinned and has begun producing. +const PIN_READY: u8 = 1; +/// The producer left its pin attempt without succeeding, so no data is coming. +const PIN_FAILED: u8 = 2; + +/// Publishes [`PIN_FAILED`] if the producer unwinds before it reports success. +/// +/// **The point is the unwind path, not the success path.** A plain store after +/// `pin_current_thread` would never run when that call panics, and the consumer +/// would then wait on a producer that no longer exists. A guard armed before +/// the attempt runs either way, so the wait always ends. +struct PinSignal<'a>(&'a AtomicU8); + +impl Drop for PinSignal<'_> { + fn drop(&mut self) { + // Only moves `PENDING` on, so dropping this after a successful + // `PIN_READY` store is a no-op rather than a lost signal. + let _ = self.0.compare_exchange( + PIN_PENDING, + PIN_FAILED, + Ordering::Release, + Ordering::Relaxed, + ); + } +} + /// Confine the calling thread to one logical processor, named by group. /// /// **The returned guard restores the previous affinity and must be held for as diff --git a/crates/windows-placement-probe/src/peer_index_cache/tests.rs b/crates/windows-placement-probe/src/peer_index_cache/tests.rs index 848c87f1..fc66bfb7 100644 --- a/crates/windows-placement-probe/src/peer_index_cache/tests.rs +++ b/crates/windows-placement-probe/src/peer_index_cache/tests.rs @@ -305,3 +305,60 @@ fn asking_for_no_pin_leaves_the_affinity_alone() { .join() .expect("must not panic"); } + +/// A processor number no group can hold, so `pin_current_thread` always fails. +/// +/// 200 is past `usize::BITS`, which the pin asserts on before it ever reaches +/// Windows -- deterministic on every machine rather than dependent on which +/// processors happen to be online. +const UNPINNABLE: (u16, u8) = (0, 200); + +/// Well inside the time either case takes when it works (both return in +/// milliseconds), and far outside the "never" the defect produced. +const MUST_FINISH_WITHIN: std::time::Duration = std::time::Duration::from_secs(20); + +#[test] +fn a_failed_producer_pin_stops_the_run_rather_than_hanging_it() { + // **The defect this guards.** `pin_current_thread` panics on failure. When + // the producer was the one to fail, the consumer still entered `consume` + // and spun forever on items no living thread would ever write -- an + // unbounded loop with no deadline, so the process simply stopped making + // progress. A run that should have failed loudly hung instead, which in CI + // is a job timeout rather than a diagnosis. + let started = std::time::Instant::now(); + let outcome = std::panic::catch_unwind(|| { + super::time_model_on(super::Strategy::Baseline, Some(UNPINNABLE), None) + }); + + assert!( + outcome.is_err(), + "an impossible pin must not report success" + ); + assert!( + started.elapsed() < MUST_FINISH_WITHIN, + "the run did not terminate: {:?}", + started.elapsed() + ); +} + +#[test] +fn a_failed_consumer_pin_stops_the_run_rather_than_hanging_it() { + // The other direction, and it hung for a different reason: the consumer + // was pinned *after* the producer had been spawned, so the panic unwound + // into `thread::scope`'s cleanup, which waits for a producer that is + // itself blocked forever on a ring nobody is draining. + let started = std::time::Instant::now(); + let outcome = std::panic::catch_unwind(|| { + super::time_model_on(super::Strategy::Baseline, None, Some(UNPINNABLE)) + }); + + assert!( + outcome.is_err(), + "an impossible pin must not report success" + ); + assert!( + started.elapsed() < MUST_FINISH_WITHIN, + "the run did not terminate: {:?}", + started.elapsed() + ); +} From 032a7dc48859c1572a4ae0fc6ff07e899454dc86 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 02:15:41 -0400 Subject: [PATCH 122/361] fix(placement-probe): require both provenance fields to agree before trusting a record topology_provenance duplicates the fingerprint's own provenance so a collector reading the record's top level need not reach into host. Both fields are public, so the duplication can be broken -- and is_fully_trusted consulted only the copy, letting a record whose fingerprint renders !!SYNTHETIC!! report itself fully trusted. The printed report would then contradict the string beside it. Both are now required. That is the conservative reading: a record disagreeing with itself about where its topology came from is exactly the record not to pool, whichever field happens to be right. The same commit corrects the node_hops contract, which still named memory_node as what tells the two ring-placement rows apart. It is requested_memory_node -- a redirected allocation can give both rows the same achieved node -- and this was the last statement of that fact left over from 27aed68. Sabotage-checked: dropping the fingerprint check fails the new test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/src/record.rs | 36 +++++++++++++++---- .../src/record/tests.rs | 21 +++++++++++ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index b6f82ed6..f9fdf1e2 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -114,12 +114,18 @@ pub struct SubmissionRecord { /// One entry per *directed* node pair, per ring placement, per strategy. /// /// **The ring placement is the dimension a collector is most likely to - /// miss.** Each directed hop is measured twice, once with the ring on the - /// producer's node and once on the consumer's, and `memory_node` on each - /// row says which. Rows that agree on every other field are therefore not - /// duplicates, and averaging them together would erase exactly the - /// asymmetry -- remote write against remote read -- that measuring both - /// placements exists to expose. + /// miss.** Each directed hop is measured twice, once with the ring asked + /// for on the producer's node and once on the consumer's, and + /// `requested_memory_node` on each row says which. Rows that agree on every + /// other field are therefore not duplicates, and averaging them together + /// would erase exactly the asymmetry -- remote write against remote read -- + /// that measuring both placements exists to expose. + /// + /// **Key on the request, not on `memory_node`.** That field records what + /// the allocation actually got, and Windows may satisfy a request on + /// another node, so both rows of a pair can carry the same achieved node + /// while describing different placements. A row whose two nodes disagree + /// did not measure the placement it names. /// /// Empty on a single-node machine. **That emptiness is the finding this /// tool most wants from a large host**, so it is an empty list rather than @@ -266,9 +272,25 @@ impl SubmissionRecord { /// A record that fails this is still worth sending -- it is not worth /// silently pooling with the rest, because a defect found later can only be /// traced through a build and a topology that can name themselves. + /// + /// # Both copies of the provenance are consulted, not just one + /// + /// `topology_provenance` deliberately duplicates the fingerprint's own + /// provenance so a collector reading the record's top level need not reach + /// into `host`. Both fields are public, so the duplication can be broken -- + /// by hand-assembling a record, or by editing one field of a deserialized + /// one -- and consulting only the copy let a record whose fingerprint + /// renders `!!SYNTHETIC!!` report itself fully trusted. The printed report + /// would then contradict the very string beside it. + /// + /// Requiring both is the conservative reading: a record that disagrees with + /// itself about where its topology came from is exactly the record not to + /// pool, whichever field happens to be right. #[must_use] pub fn is_fully_trusted(&self) -> bool { - self.build.is_official() && self.topology_provenance.is_measured() + self.build.is_official() + && self.topology_provenance.is_measured() + && self.host.provenance.is_measured() } } diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index 6975196f..d8c0ded4 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -231,6 +231,27 @@ fn a_record_is_fully_trusted_only_when_the_build_and_the_topology_both_are() { assert!(!unofficial.is_fully_trusted()); } +#[test] +fn a_record_whose_two_provenance_fields_disagree_is_not_trusted() { + // **The defect this guards.** `topology_provenance` duplicates the + // fingerprint's provenance for a collector's convenience, and both fields + // are public, so the two can be made to disagree. Consulting only the + // top-level copy let a record whose fingerprint renders `!!SYNTHETIC!!` + // report itself fully trusted -- the printed report contradicting the very + // string beside it. + let mut top_level_lies = fully_populated(); + top_level_lies.host.provenance = Provenance::Synthetic; + assert!( + !top_level_lies.is_fully_trusted(), + "a synthetic fingerprint was reported as fully trusted" + ); + + // And the converse, so the check is not merely reading the other field now. + let mut duplicate_lies = fully_populated(); + duplicate_lies.topology_provenance = Provenance::Restored; + assert!(!duplicate_lies.is_fully_trusted()); +} + #[test] fn the_timestamp_renders_known_instants_correctly() { // Pins the hand-rolled civil-from-days conversion against instants whose From 5c505af96abe49dc92a45cd1ed2707ad2911c14a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 02:17:29 -0400 Subject: [PATCH 123/361] fix(ci): anchor the publish-tag check and validate the sibling-wait registry The tag check searched the whole workflow as raw text, so a commented-out trigger satisfied it: confirmed directly, the old pattern matches \ --- .github/workflows/publish-crate.yml | 2 +- tools/check-publishable.ps1 | 27 +++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index ee4ede19..741483cc 100644 --- a/.github/workflows/publish-crate.yml +++ b/.github/workflows/publish-crate.yml @@ -109,7 +109,7 @@ jobs: - name: Wait for workspace-sibling dependencies on crates.io shell: bash run: | - workspace_crates="windows-file-enumeration-sys windows-file-watcher windows-file-watcher-example-test-harness windows-impersonation-token-sys windows-ioring-sys windows-namespace-request-sys windows-overlapped-io-sys windows-thread-ambient-sys windows-threadpool-sys windows-topology-sys wtf-string" + workspace_crates="windows-file-enumeration-sys windows-file-watcher windows-file-watcher-example-test-harness windows-impersonation-token-sys windows-ioring-sys windows-namespace-request-sys windows-overlapped-io-sys windows-thread-ambient-sys windows-threadpool-sys windows-topology-sys windows-waitable-queues wtf-string" metadata="$(cargo metadata --no-deps --format-version 1)" # `tr -d '\r'` is load-bearing on the Windows runner: jq.exe writes # CRLF, and `read` splits on LF alone, so without this the last field diff --git a/tools/check-publishable.ps1 b/tools/check-publishable.ps1 index 8ea5df6e..72e26c75 100644 --- a/tools/check-publishable.ps1 +++ b/tools/check-publishable.ps1 @@ -41,19 +41,42 @@ $managed = (Get-Content $configPath -Raw | ConvertFrom-Json).packages.PSObject.P $workflow = Get-Content $workflowPath -Raw +# The registry the publish job consults before releasing a crate with +# workspace-sibling dependencies: each named crate must appear on crates.io at +# the required version first. A release-managed crate missing from it is not +# waited for, so a dependent can race its sibling's tag and fail `cargo publish` +# instead of pausing -- which is why this list is checked here rather than kept +# by hand and hoped for. +$registryLine = $workflow -split "`n" | Where-Object { $_ -match 'workspace_crates="' } | Select-Object -First 1 +$registry = @() +if ($registryLine -match 'workspace_crates="([^"]*)"') { + $registry = ($Matches[1] -split '\s+') | Where-Object { $_ } +} + $missing = @() +if (-not $registryLine) { + $missing += "the workflow has no workspace_crates registry, so no sibling dependency is ever waited for" +} foreach ($crate in $managed) { - $hasTag = $workflow -match [regex]::Escape("'$crate-v*'") + # **Anchored to a real YAML list item.** A bare substring search over the + # whole file is satisfied by a commented-out trigger or an incidental + # mention, so the check would pass while no tag actually starts the + # workflow. The dispatch check below was already anchored; this one was not. + $hasTag = $workflow -match ("(?m)^\s+- '" + [regex]::Escape("$crate-v*") + "'\s*$") $hasDispatch = $workflow -match ("(?m)^\s+- " + [regex]::Escape($crate) + "\s*$") if (-not $hasTag) { $missing += "$crate : no tag trigger, so its release tag would publish nothing" } if (-not $hasDispatch) { $missing += "$crate : not a workflow_dispatch choice, so it cannot be published by hand either" } + if ($registryLine -and $registry -notcontains $crate) { + $missing += "$crate : absent from workspace_crates, so a dependent can race its tag instead of waiting for it" + } } if ($missing.Count -gt 0) { Write-Host "Release-managed crates that cannot be published:" -ForegroundColor Red $missing | ForEach-Object { Write-Host " $_" -ForegroundColor Red } Write-Host '' - Write-Host "Add them to .github/workflows/publish-crate.yml, in both the tag list and the dispatch choices." + Write-Host "Add them to .github/workflows/publish-crate.yml: the tag list, the dispatch choices," + Write-Host "and the workspace_crates registry the sibling-dependency wait reads." exit 1 } From 47ec0dfd475b246205ab0df4016a43e43791744d Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 02:20:17 -0400 Subject: [PATCH 124/361] fix(ioring): require the post-start bind to have taken before drawing a conclusion Slot C is bound to the far node after it starts, and that call can fail. When it does, C was never treated: it stayed near, exactly like control B, so c == b holds for a reason with nothing to do with stack placement. Combined with a == far_node the first arm fired and printed 'binding afterwards does not move the stack' -- a claim about a bind that never happened. The interpretation now also requires every slot marked for a post-start bind to report that it succeeded, and says plainly that nothing was bound when it did not. The structured record gains a treated field, so a reader mining these lines can tell a real result from one where the treatment never ran. Verified as a before-and-after on a simulated two-node machine with the bind forced to fail: the pre-fix code prints GOVERNS, the fixed code prints INCONCLUSIVE. This is the third distinct way this spike could print a confident wrong verdict, after the unconditional vacuity token and the ungated interpretation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spikes/thread-stack-numa-spike.rs | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs index aa1328fa..b911c2cd 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs @@ -445,12 +445,28 @@ fn main() { let usable = slots .iter() .all(|s| s.shallow.queried && s.shallow.valid && s.deep.queried && s.deep.valid); + + // **The treatment has to have been applied before its result means + // anything.** Slot C is bound to the far node *after* it starts, and that + // call can fail. When it does, C was never treated -- it simply stayed + // near, exactly like control B -- so `c == b` holds for a reason that has + // nothing to do with stack placement. Combined with `a == far_node`, the + // first arm below then prints "binding afterwards does not move the + // stack", which is a claim about a bind that never happened. + let treated = slots + .iter() + .all(|s| !s.bind_far_after_start || s.bind_after_ok == Some(true)); + println!(); if vacuous { println!("=> VACUOUS: near and far are the same node. Apparatus works;"); println!(" question unanswered."); } else if !usable { println!("=> INCONCLUSIVE: a probed page was not resident or the query failed."); + } else if !treated { + println!("=> INCONCLUSIVE: the post-start bind did not take, so treatment C"); + println!(" never differed from control B. Nothing here says whether binding"); + println!(" afterwards moves a stack, because nothing was bound afterwards."); } else { let (a, b, c) = ( slots[0].shallow.node, @@ -492,7 +508,7 @@ fn main() { println!( concat!( r#"{{"reason":"x-spike-thread-stack-numa","arch":"{}","numa_nodes":{},"#, - r#""near_node":{},"far_node":{},"vacuous":{},"usable":{},"#, + r#""near_node":{},"far_node":{},"vacuous":{},"usable":{},"treated":{},"#, r#""created_far":{{"shallow":{},"deep":{}}},"#, r#""control_near":{{"shallow":{},"deep":{}}},"#, r#""bound_after":{{"shallow":{},"deep":{}}}}}"# @@ -505,6 +521,10 @@ fn main() { // cannot disagree with the text beside it. vacuous, usable, + // Whether treatment C's post-start bind actually took. Without it a + // reader mining these lines cannot tell a real result from one where + // the treatment never happened. + treated, probe_json(slots[0].shallow), probe_json(slots[0].deep), probe_json(slots[1].shallow), From cefc8240623c147e7037369a899eebb13e3fb145 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 02:24:53 -0400 Subject: [PATCH 125/361] fix(ioring): count distinct disks, and stop claiming volume locality from one file Two overclaims in the file-handle NUMA spike. NumberOfDiskExtents counts extents, not devices. A volume extended twice onto the same disk reports two extents with one DiskNumber, so `count > 1` reported a multi-device volume for an ordinary single-disk one -- in the transcript and in the structured record. The extent array is now walked and its DiskNumbers deduplicated, with a sized retry when the list did not fit and an explicit unknown when it came back incomplete, because a truncated list under-counts devices and not under-counting them is the entire point of the question. Both facts are recorded: disk_extents keeps the extent count, distinct_disks is what spans_devices derives from. The ABI offsets are named rather than written inline, and were cross-checked rather than assumed: the parsed DiskNumber matches the value IOCTL_STORAGE_GET_DEVICE_NUMBER reports independently, and the driver returned exactly ARRAY_OFFSET + ENTRY_SIZE bytes for one extent, which pins both constants. Setting ARRAY_OFFSET wrong makes the length check refuse to parse rather than read a neighbouring field, which is the intended failure direction. Separately, agreement between the volume node and the handle node was reported as establishing volume locality. It is only consistent with it: a genuinely per-file answer may equal its volume's node, and most files on a single-device volume would. Only disagreement is decisive, and the output now says so. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../spikes/file-handle-numa-spike.rs | 156 ++++++++++++++---- 1 file changed, 120 insertions(+), 36 deletions(-) diff --git a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs index 9e10935a..44dd714a 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/file-handle-numa-spike.rs @@ -80,9 +80,11 @@ //! runners are virtual; //! - the physical disk number (`IOCTL_STORAGE_GET_DEVICE_NUMBER`); //! - **how many disks back the volume** -//! (`IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS`). More than one means the volume -//! spans devices, which is exactly the Q6 hazard: a single reported node -//! for a multi-device volume is a fiction, and worse than no answer. +//! (`IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS`), counted by *distinct* +//! `DiskNumber` across the returned extents rather than by extent -- a +//! volume extended twice onto one disk reports two extents and sits on one +//! device. More than one device is exactly the Q6 hazard: a single reported +//! node for a multi-device volume is a fiction, and worse than no answer. //! //! That last one needs no NUMA hardware, so a spanned volume anywhere in a CI //! fleet is a result. @@ -139,6 +141,9 @@ struct Storage { /// Disks backing the volume. Greater than one means it spans devices, and a /// single NUMA node reported for it cannot be true of all of them. disk_extents: Option, + /// Distinct DiskNumbers among those extents. This, not the extent count, + /// is what says whether the volume spans devices. + distinct_disks: Option, } /// `STORAGE_BUS_TYPE`, as Windows defines it. @@ -357,28 +362,18 @@ fn describe_storage(path: &Path) -> Storage { out.disk_number = Some(number.DeviceNumber); } - // How many disks back this volume. This is the Q6 question, and it needs no - // NUMA hardware: more than one extent means a reported node cannot be true - // of every device the volume sits on. - let mut extents = vec![0_u8; 4096]; - let ok = unsafe { - DeviceIoControl( - handle, - IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS, - std::ptr::null(), - 0, - extents.as_mut_ptr().cast::(), - u32::try_from(extents.len()).unwrap(), - &raw mut returned, - std::ptr::null_mut(), - ) - }; - if ok != 0 && (returned as usize) >= size_of::() { - // SAFETY: the first field of VOLUME_DISK_EXTENTS is NumberOfDiskExtents. - // Unaligned for the same reason as the descriptor above: `extents` is a - // `Vec` and carries no alignment guarantee beyond one byte. - let count = unsafe { extents.as_ptr().cast::().read_unaligned() }; - out.disk_extents = Some(count); + // How many *disks* back this volume. This is the Q6 question, and it needs + // no NUMA hardware: sitting on more than one device means a single reported + // node cannot be true of all of them. + // + // **Extents are not devices, and counting them was the bug.** A volume + // extended twice onto the same disk reports two extents with one + // `DiskNumber`, so `NumberOfDiskExtents > 1` claimed a multi-device volume + // for an ordinary single-disk one. The extent array has to be walked and + // its disk numbers deduplicated. + if let Some((extent_count, disks)) = read_disk_extents(handle) { + out.disk_extents = Some(extent_count); + out.distinct_disks = Some(disks); } unsafe { CloseHandle(handle) }; @@ -406,6 +401,81 @@ fn numa_node_count() -> u32 { } } +/// `VOLUME_DISK_EXTENTS` layout, which windows-sys 0.61 does not declare. +/// +/// Named rather than written inline for the same reason as the bus tags: these +/// are the ABI's offsets, fixed by Windows, and a bare `+ 24` in a loop is a +/// number a reader cannot check. `DISK_EXTENT` is `DWORD DiskNumber` followed +/// by two `LARGE_INTEGER`s, and the eight-byte alignment those force is why the +/// array starts at 8 rather than 4 and each entry is 24 rather than 20. +mod extents { + /// Byte offset of the first `DISK_EXTENT`. + pub const ARRAY_OFFSET: usize = 8; + /// Size of one `DISK_EXTENT`. + pub const ENTRY_SIZE: usize = 24; + /// Byte offset of `DiskNumber` within a `DISK_EXTENT`. + pub const DISK_NUMBER_OFFSET: usize = 0; +} + +/// The volume's extent count and how many *distinct* disks those extents name. +/// +/// `None` when the query fails or returns less than it promised -- an +/// incomplete answer is reported as unknown rather than as a small number, +/// because a truncated extent list under-counts devices and the whole point of +/// the question is not to under-count them. +fn read_disk_extents(handle: HANDLE) -> Option<(u32, u32)> { + // One retry: the first buffer holds 170 extents, and a volume with more + // than that returns ERROR_MORE_DATA having written only the count. + let mut buf = vec![0_u8; 4096]; + for attempt in 0..2 { + let mut returned: u32 = 0; + let ok = unsafe { + DeviceIoControl( + handle, + IOCTL_VOLUME_GET_VOLUME_DISK_EXTENTS, + std::ptr::null(), + 0, + buf.as_mut_ptr().cast::(), + u32::try_from(buf.len()).unwrap(), + &raw mut returned, + std::ptr::null_mut(), + ) + }; + + // The count is written even when the buffer was too small, which is + // what makes the retry a sized one rather than a guess. + if (returned as usize) < size_of::() && ok == 0 { + return None; + } + // SAFETY: at least four bytes were written; `buf` is a `Vec` and so + // carries no alignment guarantee, hence the unaligned read. + let count = unsafe { buf.as_ptr().cast::().read_unaligned() }; + + let needed = extents::ARRAY_OFFSET + (count as usize) * extents::ENTRY_SIZE; + if ok == 0 || (returned as usize) < needed { + if attempt == 0 && needed > buf.len() { + buf = vec![0_u8; needed]; + continue; + } + return None; + } + + let mut disks: Vec = Vec::new(); + for index in 0..count as usize { + let at = extents::ARRAY_OFFSET + + index * extents::ENTRY_SIZE + + extents::DISK_NUMBER_OFFSET; + // SAFETY: `needed` bytes were returned, so this entry is present. + let disk = unsafe { buf.as_ptr().add(at).cast::().read_unaligned() }; + if !disks.contains(&disk) { + disks.push(disk); + } + } + return Some((count, u32::try_from(disks.len()).unwrap_or(u32::MAX))); + } + None +} + fn probe(label: &str, handle: HANDLE) -> (Option, Option) { println!("\n-- {label} --"); @@ -453,11 +523,19 @@ fn probe(label: &str, handle: HANDLE) -> (Option, Option) { None }; - // Q3: the discriminating comparison. Agreement means volume locality is - // what is being observed, and that there is no per-file answer here. + // Q3: the comparison, and it discriminates in one direction only. + // + // **Agreement is consistent with volume locality; it does not establish + // it.** A genuinely per-file answer may equal its volume's node -- most + // files on a single-device volume would -- so one file agreeing rules + // nothing out. Only disagreement is decisive, because a per-volume answer + // cannot differ from itself. Settling it the other way needs files known to + // have different locality, which needs a volume spanning devices. match (volume, file) { (Some(v), Some(f)) if u32::from(f) == v => { - println!(" => AGREE on {v}: this is VOLUME locality, not file locality."); + println!(" => AGREE on {v}: consistent with VOLUME locality, but one file"); + println!(" agreeing does not rule out a per-file answer that happens"); + println!(" to match. Only a DISAGREE settles this."); } (Some(v), Some(f)) => { println!(" => DISAGREE (volume {v}, handle {f}) -- interesting, investigate."); @@ -533,13 +611,16 @@ fn main() -> std::io::Result<()> { Some(n) => println!(" disk number : {n}"), None => println!(" disk number : (query failed)"), } - match storage.disk_extents { - Some(1) => println!(" disk extents : 1 (single device)"), - Some(n) => println!( - " disk extents : {n} -- THIS VOLUME SPANS {n} DEVICES, so any single \ - node reported for it cannot be true of all of them" + match (storage.disk_extents, storage.distinct_disks) { + (Some(extents), Some(1)) => { + println!(" disk extents : {extents} on 1 device (single device)"); + } + (Some(extents), Some(disks)) => println!( + " disk extents : {extents} on {disks} devices -- THIS VOLUME SPANS \ + {disks} DEVICES, so any single node reported for it cannot be true \ + of all of them" ), - None => println!(" disk extents : (query failed)"), + _ => println!(" disk extents : (query failed or returned an incomplete list)"), } // Q1-Q4: a garden-variety data file, opened the ordinary way. This is the @@ -588,7 +669,7 @@ fn main() -> std::io::Result<()> { concat!( r#"{{"reason":"x-spike-file-handle-numa","arch":"{}","volume_root":{},"#, r#""bus_type":{},"bus_name":{},"product":{},"removable":{},"disk_number":{},"#, - r#""disk_extents":{},"spans_devices":{},"fsctl_volume_node":{},"#, + r#""disk_extents":{},"distinct_disks":{},"spans_devices":{},"fsctl_volume_node":{},"#, r#""handle_node":{},"both_succeeded":{}}}"# ), std::env::consts::ARCH, @@ -601,8 +682,11 @@ fn main() -> std::io::Result<()> { .map_or("null".to_string(), |b| b.to_string()), json_opt_u32(storage.disk_number), json_opt_u32(storage.disk_extents), + json_opt_u32(storage.distinct_disks), + // Derived from the distinct device count, not the extent count: a + // volume extended twice onto one disk has two extents and one device. storage - .disk_extents + .distinct_disks .map_or("null".to_string(), |n| (n > 1).to_string()), json_opt_u32(file_volume_node), json_opt_u32(file_handle_node.map(u32::from)), From 3b09f0e2da8aa3189f10732cc8d6d82404abffd2 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 02:27:50 -0400 Subject: [PATCH 126/361] docs(waitable-queues): state the doorbell guarantee one-sidedly, as the code implements it D-5, its detail section and the README all said the event is signalled exactly when the consumer has something to observe. It is not, and the same section said so six lines later: the event stays signalled after the last take until the consumer's arm() clears it, and a late signal can arrive after a drain. Both are documented and tolerated elsewhere in the crate as spurious wakes. The guarantee is one-sided: the event is never unsignalled while there is something to take. A wake is a hint that there may be something, never a proof that there is, and what the crate actually promises is that a wake is never missing -- which is why the consumer protocol is pop, arm(), re-check rather than wait-then-take. A reader who took the old wording literally would treat the handle as a readiness predicate and skip the re-check. windows-file-watcher's D-41 makes the stronger claim for its own lock-based doorbell and is left alone; it re-establishes the predicate under the queue lock, which is a different mechanism with a different result. The same commit repoints three COMPLETED-CHECKLIST links at the modules this branch moved into the placement-probe crate, so the archive's provenance trail reaches its source. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- COMPLETED-CHECKLIST.md | 6 +++--- crates/windows-waitable-queues/DESIGN-NOTES.md | 16 ++++++++++++---- crates/windows-waitable-queues/README.md | 14 ++++++++++++-- 3 files changed, 27 insertions(+), 9 deletions(-) diff --git a/COMPLETED-CHECKLIST.md b/COMPLETED-CHECKLIST.md index 46ebab7f..d7999358 100644 --- a/COMPLETED-CHECKLIST.md +++ b/COMPLETED-CHECKLIST.md @@ -1630,7 +1630,7 @@ Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is wh ## M2: making it loud where it is read -- [x] **TP-2.1** -- `Fingerprint` in [crates/windows-platform-probes/src/fingerprint.rs](crates/windows-platform-probes/src/fingerprint.rs) +- [x] **TP-2.1** -- `Fingerprint` in [crates/windows-placement-probe/src/fingerprint.rs](crates/windows-placement-probe/src/fingerprint.rs) carries the provenance and renders it **first and unmissably** when it is not `Measured`. The fingerprint string is documented as canonical, so string equality is a usable comparison -- which means the marker must be *inside* the string, or a synthetic host could compare equal to a real one. @@ -1647,7 +1647,7 @@ Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is wh **`Slice` deliberately carries no marker of its own, and the reason is structural rather than an oversight.** A `Slice` records which processors a measurement was pinned to, and one can only exist from a real `measure()` run: `measure` takes no injected topology (and - [crates/windows-platform-probes/src/core_affinity.rs](crates/windows-platform-probes/src/core_affinity.rs) + [crates/windows-placement-probe/src/core_affinity.rs](crates/windows-placement-probe/src/core_affinity.rs) now documents why it must not), and pinning to a processor that does not exist panics. A slice is therefore always real, and it is always printed beneath the banner that carries the host's provenance. **If `measure` ever does gain such a seam, this reasoning collapses and `Slice` needs its @@ -1661,7 +1661,7 @@ Related: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M-inf.4, which is wh `discover_places` conversion; staying at `ProcessorPlace` keeps the tests pure and fast. **Decide on the evidence, and record the decision either way** -- this item is not "do it", it is "choose". Note the constraint from - [crates/windows-platform-probes/src/core_affinity.rs](crates/windows-platform-probes/src/core_affinity.rs): + [crates/windows-placement-probe/src/core_affinity.rs](crates/windows-placement-probe/src/core_affinity.rs): `measure()` must still not gain a topology-injection seam, whatever is decided here. **Decided: both, because they are tests of different units -- and the evidence that settled it was a diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index bae13209..f164d2ba 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -28,7 +28,7 @@ preferred. | D-2 | **Capabilities are sliced into narrow traits, not gathered into one.** The `std::io` shape -- `Read`, `Write`, `Seek`, `BufRead` -- rather than a single fat `WaitableQueue`. Forced by the shapes themselves: a poll-only queue cannot implement a trait containing `doorbell()`, and an unbounded one cannot implement `capacity()` meaningfully. | | D-3 | **No trait ships until a second implementation exists to validate it.** The trait *shape* is fixed now so signatures stay compatible; the traits themselves land with the second shape. | | D-4 | **Every shape is split into producer and consumer handles, and cardinality is carried by `Clone`.** Single-producer becomes a compile-time guarantee rather than a documented precondition. | -| D-5 | **The doorbell is level state owned by the queue: signalled exactly when the consumer has something to observe.** The **reset** must not be separable from the observation that there is nothing to take; the **signal** may be. Manual-reset, and created lazily. Realized without a lock by [D-9](#d-9). | +| D-5 | **The doorbell is level state owned by the queue: never unsignalled while the consumer has something to observe.** One-sided on purpose -- it may be signalled with nothing there, because the event stays set after the last take until the consumer's `arm()` clears it, and a late signal may arrive after a drain. A wake is a hint, never a proof; the guarantee is that a wake is never missing. The **reset** must not be separable from the observation that there is nothing to take; the **signal** may be. Manual-reset, and created lazily. Realized without a lock by [D-9](#d-9). | | D-9 | **Without a lock, the reset is made inseparable from the observation by two things: ordering (clear, then re-check, and never wait if the re-check finds anything) and a `SeqCst` fence on each side.** `Consumer::arm` is the ordering step; the fences defeat the store-buffer hazard that ordering alone leaves open. The natural order -- check, then clear -- is asserted to hang by deliberate sabotage; the fences are beyond any test's reach and are M31.6's target. **Amended: this decision originally claimed the ordering alone sufficed.** | | D-6 | **Overflow fails or reserves, and never overwrites.** For telemetry an overwritten entry is a lost sample; for an I/O submission it is a lost operation, and the two must not share a policy knob. | | D-7 | **Shapes are plain modules, not Cargo features, until compile time justifies otherwise.** Two features are four configurations to test, against a benefit dead-code elimination already provides. | @@ -129,9 +129,17 @@ The consumer handle also owns the doorbell, because the consumer is what waits; ## D-5: the doorbell invariant, and which half must be under a lock -The invariant is one sentence: **the event is signalled exactly when the consumer has something to -observe.** It is *level* state -- a function of the queue's contents -- rather than a record of edges, -which is why it is manual-reset. +The invariant is one sentence, and it is deliberately one-sided: **the event is never unsignalled +while the consumer has something to observe.** It is *level* state -- a function of the queue's +contents -- rather than a record of edges, which is why it is manual-reset. + +The converse does not hold, and stating it as "signalled exactly when" -- which an earlier wording +of this section and of [D-5](#d-5) both did -- promises more than the crate delivers, in a +paragraph immediately followed by the two bullets that contradict it. The event stays signalled +after the last item is taken until the consumer's own `arm()` clears it, and a late signal can +arrive after the consumer has already drained. So a wake is a **hint that there may be something**, +never a proof that there is; what the crate guarantees is that a wake is never *missing*. That is +why the consumer protocol is pop, `arm()`, re-check, and not "wait, then take". The asymmetry is the part that is easy to get wrong, and it was worked out by walking the interleavings: diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 07d75981..f7005f53 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -148,8 +148,18 @@ The alternatives are all worse in the same way: - **Move everything to async.** A real answer if the program is already async; not one for a thread whose other obligations are `HANDLE`s. -So the queue owns a manual-reset event and keeps it consistent with the queue's -state. That consistency is the hard part and is what this crate is actually for. +So the queue owns a manual-reset event and keeps it **never unsignalled while +there is something to take**. That one-sided guarantee is the hard part and is +what this crate is actually for. + +It is one-sided deliberately. The event stays signalled after the last item is +taken until the consumer clears it with `arm()`, and a producer's signal may +land after the consumer has already drained -- so a wake means *there may be +something*, never *there is something*. What the crate guarantees is the +direction that matters: a wake is never missing. Follow the protocol the +blocking receivers use -- pop, `arm()`, re-check -- rather than treating the +handle as a readiness predicate. + The event is created lazily, so a consumer that only polls never allocates a kernel object at all. From cdf760393199f43dff4781ce43d6ed174ee6f2ee Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 02:47:45 -0400 Subject: [PATCH 127/361] fix(placement-probe): assert what the build could determine, not that a repository existed `cargo mutants` builds from a scratch copy of the tree with `.git` left behind. The build script correctly reported an unknown commit there, and the_build_script_stamped_a_real_commit_here asserted one must exist -- so the test failed in the *unmutated* baseline and the run aborted before a single mutant was tested. The test's assumption was stated in its own comment ("this suite is built from a git working copy") and is not a property of the suite. A `cargo install` from a crates.io tarball and a downloaded source zip reach the same state, and for those the honest answer is None, which is exactly what this file's header says the default must be. The build script now reports whether a repository was available -- distinct from whether a commit was found, because the two differ precisely when the script is broken. The test asserts the matching outcome in each case: with a repository, a well-formed sha, a known tree state and a Local source; without one, that "unknown" means unknown all the way through rather than a guess or a stale value. Deliberately not a skip. Skipping would leave the silent-emit defect this test exists to catch unguarded in exactly the environment the skip fires in. Verified in both worlds and sabotage-checked in both branches: making the build script emit no commit fails the repository branch, and fabricating a commit where no repository exists fails the other. `cargo mutants` now clears the baseline and tests 19 mutants over this file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/build.rs | 39 ++++++++ .../src/build_identity/tests.rs | 91 ++++++++++++++----- 2 files changed, 106 insertions(+), 24 deletions(-) diff --git a/crates/windows-placement-probe/build.rs b/crates/windows-placement-probe/build.rs index 0bdece00..180f0b61 100644 --- a/crates/windows-placement-probe/build.rs +++ b/crates/windows-placement-probe/build.rs @@ -91,6 +91,45 @@ fn main() { } ); println!("cargo::rustc-env=PLACEMENT_PROBE_SOURCE_OUT={source}"); + + // **Whether a repository was there to ask, which is not the same as whether + // a commit was found.** The two differ exactly when the build script is + // broken: a repository present but no commit produced is the silent-failure + // case the tests exist to catch, and without this flag a test cannot tell + // that apart from the honest "there was no repository". + // + // It exists because the honest case is reachable in ordinary use. A + // `cargo install` from a crates.io tarball has no repository, a downloaded + // source zip has none, and `cargo mutants` builds from a scratch copy of + // the tree with `.git` left behind -- which is what surfaced this: a suite + // asserting "a commit must have been found" failed in an unmutated tree and + // stopped the run before a single mutant was tested. + // + // Not a `cfg`: this is data about the build, and a test that reads it can + // assert the *right* thing in each case rather than being skipped. + println!( + "cargo::rustc-env=PLACEMENT_PROBE_REPOSITORY_OUT={}", + if repository_present() { "1" } else { "" } + ); +} + +/// Whether this build can see a git repository at all. +/// +/// Deliberately independent of whether a commit was obtained: it answers "was +/// there something to ask", so a test can distinguish a build script that +/// failed to read an available repository from one that correctly reported an +/// unavailable one. +/// +/// `git rev-parse` rather than looking for a `.git` entry, because that entry +/// may be a file redirecting elsewhere, may name a gitdir that no longer +/// exists, and says nothing about whether `git` is on `PATH` -- which is the +/// other way the commit legitimately comes back unknown. +fn repository_present() -> bool { + Command::new("git") + .args(["rev-parse", "--git-dir"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .is_ok_and(|output| output.status.success()) } /// Ask cargo to re-run this script whenever the checked-out commit changes. diff --git a/crates/windows-placement-probe/src/build_identity/tests.rs b/crates/windows-placement-probe/src/build_identity/tests.rs index 5b8a910c..9de29361 100644 --- a/crates/windows-placement-probe/src/build_identity/tests.rs +++ b/crates/windows-placement-probe/src/build_identity/tests.rs @@ -135,41 +135,84 @@ fn this_binarys_identity_is_readable_and_names_its_version() { } #[test] -fn the_build_script_stamped_a_real_commit_here() { +fn the_build_script_stamped_what_this_build_could_determine() { // Guards against the build script silently emitting nothing: every stamp // would then be empty, `commit` would be `None` everywhere, and the shape // assertions above would all still pass while the record carried no // identity at all. // - // This suite is built from a git working copy, so the commit *is* - // determinable and must have been determined. On a machine where it is - // genuinely unavailable -- a crates.io tarball, a source zip -- this test - // is not the one that runs, because that is not where the suite runs. + // # Both outcomes are asserted, because both are correct somewhere + // + // An earlier version of this test simply required a commit, on the reasoning + // that the suite is built from a working copy. That is usually true and is + // not a property of the suite: `cargo mutants` builds from a scratch copy of + // the tree with `.git` left behind, and this test then failed in the + // *unmutated* baseline and stopped the run before a single mutant was + // tested. A `cargo install` from a crates.io tarball and a downloaded source + // zip reach the same state, and for those the honest answer is `None`. + // + // So the build script reports whether a repository was there to ask, and + // this asserts the right thing in each case rather than skipping. Skipping + // would leave the silent-emit defect uncaught in exactly the environment the + // skip fires in; requiring a commit unconditionally calls a correct build a + // failure. Neither branch is vacuous -- what is forbidden is the build + // script disagreeing with its own surroundings. let current = BuildIdentity::current(); - let commit = current - .commit - .expect("the build script must find a commit when built from a repository"); - assert!( - commit.len() == 12 && commit.chars().all(|c| c.is_ascii_hexdigit()), - "the stamped commit is not a shortened hex sha: {commit:?}" - ); - assert!( - current.dirty.is_some(), - "the tree state must be determinable from a repository" - ); - assert_eq!( - current.source, - BuildSource::Local, - "a working-copy build must report itself as local" - ); + if built_from_a_repository() { + let commit = current + .commit + .expect("a repository was available, so the commit must have been determined"); + assert!( + commit.len() == 12 && commit.chars().all(|c| c.is_ascii_hexdigit()), + "the stamped commit is not a shortened hex sha: {commit:?}" + ); + assert!( + current.dirty.is_some(), + "the tree state must be determinable from a repository" + ); + assert_eq!( + current.source, + BuildSource::Local, + "a working-copy build must report itself as local" + ); + } else { + // The honest-unknown case, and it has real content: "unknown" must mean + // unknown all the way through rather than a guess or a stale value. + assert!( + current.commit.is_none(), + "no repository was available, yet a commit was stamped: {:?}", + current.commit + ); + assert!( + current.dirty.is_none(), + "no repository was available, yet a tree state was claimed" + ); + assert_eq!( + current.source, + BuildSource::Unknown, + "a build that could not identify itself must say so" + ); + } +} + +/// Whether the build script found a repository to read. +/// +/// Set by `build.rs`, not recomputed here: what this test checks is that the +/// stamp agrees with the conditions the *build* ran under, and those are not +/// necessarily the conditions the test runs under -- a binary built in a +/// working copy can be executed anywhere. +fn built_from_a_repository() -> bool { + !env!("PLACEMENT_PROBE_REPOSITORY_OUT").is_empty() } #[test] fn a_local_development_build_does_not_claim_to_be_official() { - // This suite runs from a working copy, never from CI, so the binary under - // test must not pass as official. If this ever fails, the build script is - // claiming something it cannot know. + // This suite is never built by the release workflow, so whatever else the + // build could determine, it must not pass as official. That holds in both + // worlds the test above distinguishes: a working copy stamps `Local`, and a + // tree with no repository stamps `Unknown`. If this ever fails, the build + // script is claiming something it cannot know. assert!( !BuildIdentity::current().is_official(), "a build from a working copy claimed to be official: {}", From 521d6f8ec8588e4e619bd57af5897e80ad5181cb Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 10:09:28 -0400 Subject: [PATCH 128/361] test(waitable-queues): cover the capability traits, which nothing exercised A mutation run over windows-waitable-queues left 144 mutants alive, and 79 of the 128 in the three queue shapes were in trait impls. The cause is that every test called the inherent method, which shadows the trait one: `::len` could return 0 unconditionally and the whole suite stayed green. The trait impls are hand-written forwarders, so they are a second statement of each shape's contract, and they are the only surface a generic consumer touches -- which D-2 says is what the traits are for. traits/tests.rs had reasoned itself into the gap, saying testing them "would only assert that a delegating trait impl delegates"; that header is corrected, because the run falsified it. The helpers assert against known queue state rather than against the inherent methods. Comparing the two views would prove they agree while leaving both free to be wrong together; asserting that a queue filled to four reports a length of four fails whichever of the two broke. Coverage added: reserving_mpsc, absent from this file entirely despite carrying the largest share of the survivors; the consumer handles, which implement the same traits through their own separate forwarders; Observable in full, with the three counters asserted separately so an impl returning one number for all of them cannot pass; high_water tracked and untracked, since None and Some(0) are different answers; Reserving on both implementors, spsc's borrowing reservation and reserving_mpsc's owned one, which is the two-implementor evidence D-3 asks for; and disconnection from the consumer's end, where asserting only the pre-disconnection false left a constant-false forwarder alive on all three. Measured, not assumed: 125 trait-impl mutants across the three shapes now report 108 caught, 17 unviable, 0 missed. One limitation is recorded in the test rather than worked around: Reserving declares `Reservation<'a>` with no bound, so a caller generic over the trait can claim a slot and release it but cannot commit. That is a gap in the trait, and reaching for the concrete type to hide it would stop testing the trait. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/traits/tests.rs | 426 +++++++++++++++++- 1 file changed, 415 insertions(+), 11 deletions(-) diff --git a/crates/windows-waitable-queues/src/traits/tests.rs b/crates/windows-waitable-queues/src/traits/tests.rs index a2ad8ac8..aec189e4 100644 --- a/crates/windows-waitable-queues/src/traits/tests.rs +++ b/crates/windows-waitable-queues/src/traits/tests.rs @@ -4,19 +4,40 @@ //! //! # What these are actually for //! -//! Not to re-test the shapes -- each shape's own suite does that, through its -//! inherent methods, and repeating it here would only assert that a delegating -//! trait impl delegates. What is tested here is the claim -//! [D-3](../../DESIGN-NOTES.md#d-3) makes: that the traits are a real -//! abstraction over more than one implementation, so that a caller can be -//! written against them without knowing which shape it has. +//! Two things, and an earlier version of this header dismissed the second. //! -//! The evidence is a set of generic functions with no knowledge of either -//! shape, exercised against both. If a trait were shaped around one of them -- -//! the failure D-3 exists to prevent -- these would not compile against the -//! other, which is a stronger check than any assertion in the bodies. +//! The first is the claim [D-3](../../DESIGN-NOTES.md#d-3) makes: that the +//! traits are a real abstraction over more than one implementation, so a caller +//! can be written against them without knowing which shape it has. The evidence +//! is a set of generic functions with no knowledge of any shape, exercised +//! against all of them. If a trait were shaped around one -- the failure D-3 +//! exists to prevent -- these would not compile against the others, which is a +//! stronger check than any assertion in the bodies. +//! +//! # The delegating impls are checked here too, and that is not redundant +//! +//! This header used to say testing them "would only assert that a delegating +//! trait impl delegates", and left them alone on that reasoning. A mutation run +//! falsified it: **79 of the 128 surviving mutants in the three shapes were in +//! trait impls**, because every test called the inherent method, which shadows +//! the trait one. `::len` could return `0` unconditionally +//! and the whole suite stayed green. +//! +//! The impls are hand-written forwarders, so they are a second statement of +//! each shape's contract -- and a second statement is exactly the thing this +//! repository does not leave unchecked. They are also the *only* surface a +//! generic consumer touches, which is the surface D-2 says the crate is for. +//! +//! So the generic helpers below assert against **known queue state** rather +//! than against the inherent methods. Comparing the two views would prove they +//! agree while leaving both free to be wrong together; asserting a queue filled +//! to four reports a length of four fails a forwarder that returns a constant, +//! and fails an inherent method that does, and does not care which one broke. -use crate::{Bounded, Consumer, Producer, PushError, Waitable, slotwise_mpsc, spsc}; +use crate::{ + Bounded, Consumer, Observable, Options, Producer, PushError, Waitable, reserving_mpsc, + slotwise_mpsc, spsc, +}; /// Fills a queue through nothing but the [`Producer`] and [`Bounded`] traits, /// and reports what the refusal said. @@ -117,13 +138,71 @@ fn both_shapes_report_disconnection_through_the_traits() { assert!(!consumer_sees_it(&spsc_rx)); assert!(!consumer_sees_it(&mpsc_rx)); + // **The consumer's own view, after the producers go.** Asserting only the + // `false` before disconnection left a forwarder returning a constant + // `false` alive on both shapes -- the direction that matters, because a + // consumer that never learns the stream ended waits forever. + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + assert!(!consumer_sees_it(&res_rx)); + + drop(spsc_tx); + drop(mpsc_tx); + drop(res_tx); + assert!( + consumer_sees_it(&spsc_rx), + "the consumer must see the producer go" + ); + assert!(consumer_sees_it(&mpsc_rx)); + assert!(consumer_sees_it(&res_rx)); + + // And the converse direction, on fresh queues so the drops above do not + // decide the answer. All three shapes, because each producer's + // `is_disconnected` is its own forwarder. + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (mpsc_tx, mpsc_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + assert!(!producer_sees_it(&res_tx), "still connected"); + drop(spsc_rx); drop(mpsc_rx); + drop(res_rx); assert!(producer_sees_it(&spsc_tx)); assert!( producer_sees_it(&mpsc_tx), "one consumer gone is every consumer gone, for both shapes" ); + assert!(producer_sees_it(&res_tx), "and for the reserving shape"); +} + +#[test] +fn both_reserving_shapes_report_outstanding_claims_through_the_trait() { + // `spsc` implements `Reserving` too, with a borrowing reservation where + // `reserving_mpsc` hands out an owned one -- which is the two-implementor + // evidence D-3 asks for, and was untested until a mutation run said so. + fn claim_then_release

(producer: &P) -> (usize, usize, usize) + where + P: crate::Reserving, + { + let before = producer.outstanding_reservations(); + let reservation = producer.reserve().expect("a fresh queue has room"); + let held = producer.outstanding_reservations(); + drop(reservation); + (before, held, producer.outstanding_reservations()) + } + + let (spsc_tx, _rx) = spsc::bounded::(4).expect("4 is valid"); + assert_eq!( + claim_then_release(&spsc_tx), + (0, 1, 0), + "the borrowing reservation is counted while it is held" + ); + + let (res_tx, _rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + assert_eq!( + claim_then_release(&res_tx), + (0, 1, 0), + "and so is the owned one, through the same trait" + ); } #[test] @@ -181,3 +260,328 @@ fn drain_stops_at_the_current_end_rather_than_at_the_end_of_the_stream() { "the queue was momentarily empty, not finished" ); } + +/// Every `Bounded` reading, against a queue whose contents are known. +/// +/// Deliberately not compared against the inherent methods: see the header. A +/// filled queue of four *is* four items long, whichever implementation is +/// asked, so this fails a forwarder that returns a constant without needing to +/// know that a forwarder exists. +fn bounded_readings_match_known_state

(producer: &P, capacity: usize) +where + P: crate::Producer + Bounded, +{ + assert_eq!(producer.capacity(), capacity, "capacity as constructed"); + assert_eq!(producer.len(), 0, "a fresh queue is empty"); + assert!(producer.is_empty()); + assert_eq!(producer.remaining(), capacity); + + producer.push(1).expect("there is room"); + assert_eq!(producer.len(), 1, "one push is one item"); + assert!(!producer.is_empty(), "and one item is not empty"); + assert_eq!(producer.remaining(), capacity - 1); + assert_eq!( + producer.capacity(), + capacity, + "capacity does not move when the contents do" + ); + + for value in 1..capacity { + producer + .push(u32::try_from(value).expect("small")) + .expect("there is room"); + } + assert_eq!(producer.len(), capacity, "filled to the brim"); + assert_eq!(producer.remaining(), 0); + assert!(!producer.is_empty()); +} + +/// Every `Observable` reading, against known state. +/// +/// The three counters answer different questions and are asserted separately +/// on purpose: an implementation that returned the same number for all of them +/// would satisfy any test that only checked one had moved. +fn observable_readings_match_known_state

(producer: &P, capacity: usize) +where + P: crate::Producer + Observable, +{ + assert_eq!(producer.refused(), 0, "nothing has been refused yet"); + assert_eq!( + producer.doorbell_rings(), + 0, + "a doorbell nobody asked for has never rung" + ); + + for value in 0..capacity { + producer + .push(u32::try_from(value).expect("small")) + .expect("there is room"); + } + assert_eq!( + producer.refused(), + 0, + "filling a queue exactly refuses nothing" + ); + + producer.push(u32::MAX).expect_err("the queue is full"); + assert_eq!(producer.refused(), 1, "and one refusal is counted"); + producer.push(u32::MAX).expect_err("still full"); + assert_eq!(producer.refused(), 2, "each refusal counts separately"); +} + +/// The same `Bounded` readings, from the **consumer** handle. +/// +/// A separate helper because both handles implement the trait separately, and +/// each impl is its own forwarder: exercising only the producer's left every +/// consumer-side reading unverified, which is exactly what the first pass at +/// this file did and what a second mutation run caught. +fn consumer_bounded_readings_match_known_state(consumer: &C, producer: &P, capacity: usize) +where + C: Consumer + Bounded, + P: crate::Producer, +{ + assert_eq!(consumer.capacity(), capacity, "capacity as constructed"); + assert_eq!(consumer.len(), 0, "a fresh queue is empty"); + assert!(consumer.is_empty()); + assert_eq!(consumer.remaining(), capacity); + + producer.push(1).expect("there is room"); + producer.push(2).expect("there is room"); + assert_eq!(consumer.len(), 2, "the consumer sees what was pushed"); + assert!(!consumer.is_empty()); + assert_eq!(consumer.remaining(), capacity - 2); + + assert_eq!(consumer.pop(), Some(1)); + assert_eq!(consumer.len(), 1, "and sees the depth fall as it drains"); + assert_eq!(consumer.pop(), Some(2)); + assert!(consumer.is_empty(), "drained back to empty"); + assert_eq!(consumer.remaining(), capacity); +} + +/// The `Observable` counters from the **consumer** handle, which reports the +/// same shared state the producer does. +fn consumer_observable_readings_match_known_state(consumer: &C, producer: &P, capacity: usize) +where + C: Consumer + Observable, + P: crate::Producer, +{ + assert_eq!(consumer.refused(), 0, "nothing refused yet"); + assert_eq!( + consumer.doorbell_rings(), + 0, + "and the doorbell has not rung" + ); + + for value in 0..capacity { + producer + .push(u32::try_from(value).expect("small")) + .expect("there is room"); + } + producer.push(u32::MAX).expect_err("full"); + assert_eq!( + consumer.refused(), + 1, + "a refusal is shared state, visible from either end" + ); +} + +/// `high_water` from either end, tracked and untracked. +fn high_water_readings_match_known_state(subject: &O, expected: Option) { + assert_eq!(subject.high_water(), expected); +} + +#[test] +fn every_shape_reports_its_bounds_through_the_bounded_trait() { + // All three, including `reserving_mpsc`, which this file did not mention at + // all and which carried the largest share of the surviving mutants. + let (spsc_tx, _spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (slot_tx, _slot_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, _res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + bounded_readings_match_known_state(&spsc_tx, 4); + bounded_readings_match_known_state(&slot_tx, 4); + bounded_readings_match_known_state(&res_tx, 4); +} + +#[test] +fn every_shape_counts_refusals_through_the_observable_trait() { + let (spsc_tx, _spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (slot_tx, _slot_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, _res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + observable_readings_match_known_state(&spsc_tx, 4); + observable_readings_match_known_state(&slot_tx, 4); + observable_readings_match_known_state(&res_tx, 4); +} + +#[test] +fn high_water_distinguishes_untracked_from_a_tracked_zero() { + // `None` and `Some(0)` are different answers -- nobody counted, against + // counted and never grew -- and a forwarder returning either constant + // would satisfy a test that only looked at one configuration. + fn peak(producer: &P) -> Option { + producer.high_water() + } + + let (untracked, _rx) = spsc::bounded::(4).expect("4 is valid"); + assert_eq!(peak(&untracked), None, "tracking is off by default"); + + let (tracked, _rx) = + spsc::bounded_with::(4, Options::new().tracking_high_water()).expect("4 is valid"); + assert_eq!(peak(&tracked), Some(0), "counted, and never grown"); + + tracked.push(1).expect("there is room"); + tracked.push(2).expect("there is room"); + assert_eq!(peak(&tracked), Some(2), "the peak follows the depth up"); +} + +#[test] +fn the_reserving_shape_is_usable_through_the_reserving_trait() { + // `Reserving` has one implementor here, so this cannot show the trait spans + // shapes the way the others do. What it does show is that the trait is + // usable without naming the concrete type -- and it covers the forwarders, + // which is where the mutants survived. + // + // **Only claiming and releasing are exercised, because that is all the + // trait offers.** `Reservation<'a>` is declared with no bound, so a caller + // generic over `Reserving` can obtain a reservation and drop it and nothing + // else: `commit` is inherent to each shape's own type and is unreachable + // from here. That is a gap in the trait rather than in this test, and it is + // raised as such rather than papered over by reaching for the concrete + // type, which would stop testing the trait at all. + let (tx, rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + fn claim_then_release

(producer: &P) -> (usize, usize) + where + P: crate::Reserving, + { + let before = producer.outstanding_reservations(); + let reservation = producer.reserve().expect("a fresh queue has room"); + let held = producer.outstanding_reservations(); + drop(reservation); + (before, held) + } + + let (before, held) = claim_then_release(&tx); + assert_eq!(before, 0, "a fresh queue has nothing outstanding"); + assert_eq!(held, 1, "an open reservation is outstanding"); + assert_eq!( + tx.outstanding_reservations(), + 0, + "and dropping it returns the slot" + ); + + // The released slot is genuinely usable again, so `outstanding_reservations` + // reporting zero is not merely a constant that happens to read right. + tx.push(7).expect("the released slot is available"); + assert_eq!(rx.pop(), Some(7)); +} + +#[test] +fn every_shape_reports_its_bounds_from_the_consumer_end_too() { + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (slot_tx, slot_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + consumer_bounded_readings_match_known_state(&spsc_rx, &spsc_tx, 4); + consumer_bounded_readings_match_known_state(&slot_rx, &slot_tx, 4); + consumer_bounded_readings_match_known_state(&res_rx, &res_tx, 4); +} + +#[test] +fn every_shape_counts_refusals_from_the_consumer_end_too() { + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (slot_tx, slot_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + consumer_observable_readings_match_known_state(&spsc_rx, &spsc_tx, 4); + consumer_observable_readings_match_known_state(&slot_rx, &slot_tx, 4); + consumer_observable_readings_match_known_state(&res_rx, &res_tx, 4); +} + +#[test] +fn every_shape_reports_high_water_from_either_end() { + // Both handles and all three shapes, tracked and untracked. `None` and + // `Some(n)` are different answers, so a forwarder returning either constant + // has to fail one of these configurations. + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (slot_tx, slot_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + high_water_readings_match_known_state(&spsc_tx, None); + high_water_readings_match_known_state(&spsc_rx, None); + high_water_readings_match_known_state(&slot_tx, None); + high_water_readings_match_known_state(&slot_rx, None); + high_water_readings_match_known_state(&res_tx, None); + high_water_readings_match_known_state(&res_rx, None); + + // And with tracking on, the peak is visible from both ends and follows the + // depth up rather than sitting at a constant. + let options = || Options::new().tracking_high_water(); + let (tx, rx) = spsc::bounded_with::(4, options()).expect("4 is valid"); + let (stx, srx) = slotwise_mpsc::bounded_with::(4, options()).expect("4 is valid"); + let (rtx, rrx) = reserving_mpsc::bounded_with::(4, options()).expect("4 is valid"); + + high_water_readings_match_known_state(&tx, Some(0)); + high_water_readings_match_known_state(&rx, Some(0)); + high_water_readings_match_known_state(&stx, Some(0)); + high_water_readings_match_known_state(&srx, Some(0)); + high_water_readings_match_known_state(&rtx, Some(0)); + high_water_readings_match_known_state(&rrx, Some(0)); + + for value in 0..3 { + tx.push(value).expect("there is room"); + } + high_water_readings_match_known_state(&tx, Some(3)); + high_water_readings_match_known_state(&rx, Some(3)); + + for value in 0..3 { + stx.push(value).expect("there is room"); + rtx.push(value).expect("there is room"); + } + high_water_readings_match_known_state(&stx, Some(3)); + high_water_readings_match_known_state(&srx, Some(3)); + high_water_readings_match_known_state(&rtx, Some(3)); + high_water_readings_match_known_state(&rrx, Some(3)); +} + +#[test] +fn every_shape_counts_a_doorbell_ring_that_actually_happened() { + // `doorbell_rings` counts real `SetEvent` calls, so it stays zero until a + // consumer has armed and a producer has pushed against that armed state. + // Asserting only the zero -- which the refusal tests above do -- leaves a + // forwarder returning a constant zero alive. + fn rings(subject: &O) -> u64 { + subject.doorbell_rings() + } + + fn ring_once(producer: &P, consumer: &C) + where + P: crate::Producer, + C: Consumer + Waitable + Observable, + { + assert_eq!(rings(consumer), 0, "nothing has rung yet"); + assert!( + consumer.arm().expect("arming must succeed"), + "empty, so safe" + ); + producer.push(1).expect("there is room"); + assert!( + rings(consumer) >= 1, + "a push against an armed doorbell must ring it" + ); + } + + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (slot_tx, slot_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + ring_once(&spsc_tx, &spsc_rx); + ring_once(&slot_tx, &slot_rx); + ring_once(&res_tx, &res_rx); + + // Visible from the producer end as well, which is its own forwarder. + assert!(rings(&spsc_tx) >= 1); + assert!(rings(&slot_tx) >= 1); + assert!(rings(&res_tx) >= 1); +} From 5952167f6bf488fcbfc23426a030d8e9cdd25b69 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 10:10:26 -0400 Subject: [PATCH 129/361] docs: record how to run cargo-mutants here, and why -j 2 cargo-mutants is installed and not exposed by cargo-mcp, so it is one of the few cargo commands that legitimately runs in a terminal. It defaults to serial, and a 125-mutant sweep measured 3m30s at -j 2 against roughly six minutes serially with identical results. Two rather than one-per-core, and the reason is specific to this workspace: doorbell signalling, queue contention and the placement probe's transfer loops are timing-sensitive, and under heavy parallel load such a test can fail for want of a CPU rather than because it detected the mutant. cargo-mutants records that as caught, which inflates the score while leaving the mutant undetected in a real run. Also recorded: the narrowing flags, that a baseline failure aborts the run without naming its cause -- the scratch tree has no .git, which is what caught us -- and that missed.txt should be read grouped, since a block of survivors usually names one absent kind of test rather than many separate gaps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b396d2c6..1cb7e8e7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -410,6 +410,55 @@ written against — a decision to raise, not a gap to close in passing. `cargo_nextest_run` and `cargo_nextest_list` remain in the tool table above because the MCP server exposes them; they will fail here until cargo-nextest is installed. +### cargo-mutants — run it with `-j 2`, from the terminal + +`cargo-mutants` is installed and is **not** exposed by the cargo-mcp server, so it is one +of the few cargo commands that legitimately runs in a terminal rather than through a +`cargo_*` tool. + +**Always pass `-j 2`.** The default is serial, and a mutation run is long enough that the +difference matters: a 125-mutant sweep over this workspace took **3m30s at `-j 2` against +roughly six minutes serially**, with identical results. Two is the recommended value here +rather than "as many as there are cores": + +- Each job is a full build plus test run of a scratch copy of the tree, so the cost is + disk and RAM as much as CPU, and the builds contend for the same target directory + layout. +- **More importantly, this workspace has timing-sensitive tests** -- doorbell signalling, + queue contention, and the placement probe's transfer loops. Under heavy parallel load a + timing test can fail for want of a CPU rather than because it detected the mutant, and + cargo-mutants records that as `caught`. That is a *false* caught: it inflates the score + while leaving the mutant undetected in a real run. Two jobs on an ordinary development + machine stays well clear of that; raising it needs the result checked against a serial + run before being believed. + +Useful narrowing flags, since a full run is long: + +- `--file ` to scope to one file; repeat it for several. +- `--re ` to scope to matching function names, e.g. + `--re "impl crate::(Bounded|Observable)"` for a trait surface. +- `--timeout ` to bound each mutant, which matters because a mutant that hangs is + otherwise bounded only by cargo-mutants' own auto-timeout. + +**A baseline failure stops the whole run before any mutant is tested**, and the message +(`cargo test failed in an unmutated tree`) does not name the cause. The usual cause here +is a test that assumes something about the build environment: cargo-mutants builds from a +scratch copy of the tree **with `.git` left behind**, so anything asserting that a +repository, a commit, or a clean checkout exists will fail there and nowhere else. Fix +such a test by asserting what the build could actually determine rather than by skipping +it -- see `windows-placement-probe`'s `build_identity` tests for the worked example. + +**Read the results as a to-do list, not a score.** `mutants.out/missed.txt` is the useful +artifact; group it by file and by function to find the shape of the gap rather than +fixing mutants one at a time. A large block of survivors usually names one absent *kind* +of test -- the run that prompted this section had 79 survivors in trait impls, all from a +single missing idea (nothing exercised the traits generically), and one new test file +section killed all of them. + +Treat `timeout` and `unviable` separately from `caught`: an unviable mutant did not +compile and says nothing, and a timeout may mean the suite hangs on that mutation rather +than failing on it, which is worth knowing on its own. + ## Scratch directory for temporary files When you need to capture command output, test results, debug logs, build warnings, or any From da1ed759b4e7b6deef3a804a243921b8b8943964 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 10:31:35 -0400 Subject: [PATCH 130/361] test(file-watcher): cover the standing carve-out, and queue the dead path a mutation run exposed The run reported 281 caught and 247 missed. Most of that is not a test gap. 147 of the 247 misses are in `scenario.rs` (131), `contract.rs` (11) and `bin/run_scenario.rs` (5). `mod scenario` is `#[cfg(feature = "scenario-tool")]` and `mod contract` is `#[cfg(feature = "test-util")]`, and the run enabled neither -- the rustc line carries no `--cfg feature=` at all, only the `--check-cfg` declaration of which names are valid, which is easy to misread as the opposite. Those mutants were applied to source that was never compiled, so they passed trivially; the same invocation also compiled out the 67 tests that cover them (283 tests by default, 350 with `--all-features`). `contract.rs` is worth naming because it looked alarming: `ContractChecker` is the workspace's worked example of a derived-fact oracle, and `observe -> Ok(())` surviving would have meant the violation detector could be replaced by "everything is fine" unnoticed. It is gated, its tests are real, and the finding was an artifact. Checking that before reporting it is the whole lesson of the previous run. So the run needs both halves of the feature flags: `cargo mutants -p windows-file-watcher --all-features -- --all-features`. That leaves 100 genuine misses. This commit takes the highest-value one, in the standing-slot reservation accounting, and it turned into two findings. THE TESTABLE HALF. `unreserved() == capacity - queue.len() - reserved`, so the carve-out's release is what keeps a guaranteed slot from leaking into the best-effort pool. The six standing-slot tests beside these assert that sends *succeed*; none asserted that capacity is *conserved* across a send/drain cycle. Two tests now do, and they catch a wrong sign at the release `take` performs inline with the pop. THE HALF NO TEST CAN COVER, WHICH IS THE MORE USEFUL FINDING. The same accounting appears again in `StandingHold::drop`, and three mutants there survive: `+=` to `-=`, `+=` to `*=`, and the `!` deleted from `if !standing.slot_alive`. That body is unreachable. The only pop from `state.queue` is in `take`, which releases inline and sets `resolved = true`, so `Drop` returns at its first line for every drained entry; an undrained entry's hold is dropped only during `Shared` teardown, where `upgrade()` returns `None` and `Drop` returns at its second line. Nothing reaches the body between them. That is a reachability question, not a coverage question, so it is queued as M15.1 for the engineer rather than closed here. Deleting live-looking accounting on a hunch, or manufacturing a test that reaches code nothing calls, would both be worse than leaving it visible with the reasoning attached. A NOTE ON THE VERIFICATION. The first attempt at confirming these mutants used a whole-string replace, and ` state.reserved += 1;` occurs at four sites -- so it mutated `take`'s release as well and reported "caught" for a line that had not been tested at all. Re-done line-targeted, the truth inverted. This is precisely the failure `tools/run-sabotage.ps1` guards with its "pattern found N times, expected 1" check, reproduced by hand without the guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 24 +++++ .../windows-file-watcher/src/queue/tests.rs | 87 +++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index b0f6b5f2..2f7ba732 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -128,6 +128,30 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-25---- Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27----m14-audit-the-delivery-contract-against-the-ten-specification-gap-categories-d-84). +## M15 -- Resolve the unreachable half of `StandingHold::drop` + +- [ ] **M15.1** -- Decide whether `StandingHold::drop`'s release path is dead code, and act on the + answer. **Found by mutation testing, and it is a reachability question rather than a test gap** -- + which is why it is queued for a decision instead of being closed with a test. + Three mutants in that `Drop` survive: `state.reserved += 1` changed to `-=` and to `*=`, and the `!` + deleted from `if !standing.slot_alive`. Each was re-injected on its own line and confirmed to leave the + suite green, so this is not an artifact of the run's feature flags. + **No test can catch them as the code stands.** The only pop from `state.queue` is in `take` (one call + site), and `take` performs the release inline and sets `resolved = true`, so `Drop` returns at its first + line for every drained entry. An *undrained* entry's hold is only dropped when `Shared` itself is torn + down -- and then `self.shared.upgrade()` returns `None` and `Drop` returns at its second line. Nothing + reaches the body in between. + The doc comment says `Drop` "remains the fallback for every other discard", so either a discard path was + intended and never built, or one existed and was removed when `take` took over the release (a PR #20 + review response, per the comment beside it). Both readings are plausible from the code alone; the + engineer who made that change can tell them apart, and an assistant deleting live-looking accounting on + a hunch is exactly the wrong move. + Three outcomes are legitimate: **remove** the unreachable body if the discard path is genuinely gone; + **keep it and say why** if it guards a path that is coming (recording that here, so the next mutation + run does not re-litigate it); or **build the missing path** if its absence is itself the defect. What is + not legitimate is adding a test that reaches it artificially -- that would manufacture coverage for code + nothing calls. + ## M-inf -- Horizon (ungated, post-v1) Parked, not pending. These are the deferred seams recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> D-19, diff --git a/crates/windows-file-watcher/src/queue/tests.rs b/crates/windows-file-watcher/src/queue/tests.rs index 5f322925..ba241e36 100644 --- a/crates/windows-file-watcher/src/queue/tests.rs +++ b/crates/windows-file-watcher/src/queue/tests.rs @@ -1448,3 +1448,90 @@ fn draining_only_latched_reports_still_reaches_the_resume_edge() { "the edge must be reachable by draining latched reports alone" ); } + +#[test] +fn draining_a_standing_send_returns_the_carve_out_to_the_slot_not_to_the_pool() { + // A `cargo mutants` run flagged the reservation accounting, and chasing it + // showed the carve-out's *reachable* release -- the one `take` performs + // inline with the pop -- was covered only incidentally, by tests asserting + // that sends succeed rather than that capacity is conserved. + // + // (The copy of that accounting in `StandingHold::drop` is a different + // matter: it is not reachable in the current design, and no test here can + // cover it. See the note on that impl.) + // + // `unreserved() == capacity - queue.len() - reserved`, so getting this + // wrong does not merely lose the slot's guarantee: decrementing inflates + // the best-effort pool, handing out capacity that is supposed to be carved + // out. The assertion below is therefore about what the *general* path can + // take, which is what a wrong sign actually corrupts. + let (sender, receiver) = bounded(2); + let slot = sender.reserve_standing().expect("a slot"); + let watch = WatchId::from_raw(1); + + // One unit is carved out for the slot, so exactly one is best-effort. + let held = sender.reserve().expect("one unreserved unit exists"); + assert!( + sender.reserve().is_none(), + "the standing slot's carve-out is not available to the best-effort path" + ); + drop(held); + + // Send through the slot and drain it: the queued entry stands in for the + // reservation while it is queued, and dropping the hold on drain must give + // the carve-out back to the slot. + slot.send(Notification::RetryQuestion { + watch, + operation: crate::retry::FaultOperation::Open, + detail: test_detail(), + }); + assert!(receiver.try_recv().is_some(), "the standing send arrives"); + + // The state must be exactly what it was before the send. + let held = sender.reserve().expect("the one best-effort unit is back"); + assert!( + sender.reserve().is_none(), + "the carve-out must return to the slot, not to the best-effort pool -- \ + a second unit here means the reservation was released twice" + ); + drop(held); + + // And the slot must still be able to use it. + slot.send(Notification::RetryQuestion { + watch, + operation: crate::retry::FaultOperation::Open, + detail: test_detail(), + }); + assert!( + receiver.try_recv().is_some(), + "the slot can send again, which is what the carve-out is for" + ); +} + +#[test] +fn a_standing_slot_keeps_its_carve_out_across_many_send_drain_cycles() { + // The accounting must be stable rather than merely correct once: a sign + // error that happened to cancel out over one cycle would still drift here. + let (sender, receiver) = bounded(2); + let slot = sender.reserve_standing().expect("a slot"); + let watch = WatchId::from_raw(1); + + for cycle in 0..8 { + slot.send(Notification::RetryQuestion { + watch, + operation: crate::retry::FaultOperation::Open, + detail: test_detail(), + }); + assert!( + receiver.try_recv().is_some(), + "cycle {cycle}: the standing send arrives" + ); + + let held = sender.reserve().expect("one best-effort unit, every cycle"); + assert!( + sender.reserve().is_none(), + "cycle {cycle}: the carve-out must still be carved out" + ); + drop(held); + } +} From da1d082ebb3f661a4bd332717b607e8e7b94bcc6 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 10:32:46 -0400 Subject: [PATCH 131/361] docs: record the feature-flag trap that invalidated 60% of two mutation runs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1cb7e8e7..e89729d1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -448,6 +448,55 @@ repository, a commit, or a clean checkout exists will fail there and nowhere els such a test by asserting what the build could actually determine rather than by skipping it -- see `windows-placement-probe`'s `build_identity` tests for the worked example. +**Pass the crate's features on BOTH sides, or most of what you find is fiction.** +cargo-mutants mutates the *source*, so it happily mutates a module gated behind a feature +that is switched off -- the mutation lands in code that is never compiled, the suite passes +trivially, and the result is recorded as `missed`. It compiles out that module's tests at +the same time, so both halves of the evidence disappear together. + +This is not a small correction. Measured on two runs here: a `windows-topology-sys` sweep +reported 61 survivors of which **57 were in `#[cfg(feature = "serde")]` code**, and a +`windows-file-watcher` sweep reported 247 of which **147 were in `scenario-tool` and +`test-util` modules**. In both cases roughly 60% of the "gaps" were artifacts of the +invocation. So: + +``` +cargo mutants -p --all-features -- --all-features +``` + +The flag is needed twice because the first governs cargo-mutants' own build and the one +after `--` is passed to `cargo test`. + +**Verify which features were actually on before trusting a miss**, and do not do it by +eye: `--check-cfg cfg(feature, values("scenario-tool", ...))` appears on every rustc line +and merely *declares which names are valid*, so grepping the baseline log for a feature +name matches whether or not it was enabled. The thing to look for is an explicit +`--cfg feature="..."` flag; its absence means no features were on. Comparing the baseline's +test count against a local `--all-features` run is the quicker check -- 283 against 350 on +the file-watcher was the tell. + +**When a survivor looks alarming, check the gating before reporting it.** A +`windows-file-watcher` run showed `ContractChecker::observe -> Ok(())` surviving, which +would have meant the workspace's contract oracle could be replaced by "everything is fine" +unnoticed. `mod contract` is `test-util`-gated; its tests are real and thorough; the +finding was an artifact. The alarming ones are exactly where the check is cheapest and the +cost of skipping it is highest. + +**A surviving mutant is not always a missing test -- sometimes it is unreachable code.** +Before writing a test, check that a test *could* reach the line. Three survivors in +`windows-file-watcher`'s `StandingHold::drop` turned out to sit past two early returns that +between them cover every path: the drained case resolves the hold inline, and the +undrained case only happens during teardown, when the `Weak` upgrade fails. No test can +cover that body, and manufacturing one that reaches code nothing calls would be worse than +leaving the gap visible. That is a design question for the engineer (queued as a checklist +item), not something to close with a test. + +**Verify a fix by re-injecting the mutant on its own line, not by string replace.** A +whole-file `.Replace` of ` state.reserved += 1;` matched four sites in one file, so +it mutated a *different, tested* line and reported the untested one as caught -- inverting +the conclusion. This is the same defect `tools/run-sabotage.ps1` guards with its "pattern +found N times, expected 1" check; done by hand, nothing guards it. + **Read the results as a to-do list, not a score.** `mutants.out/missed.txt` is the useful artifact; group it by file and by function to find the shape of the gap rather than fixing mutants one at a time. A large block of survivors usually names one absent *kind* From 7fd00aadf6be5b9fbf9c97a5f4a30b4a38d51e45 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 10:49:08 -0400 Subject: [PATCH 132/361] docs(waitable-queues): record SH-1.5, a bound on Reserving::Reservation, as a merge blocker The associated type is declared with no bound, so a caller generic over Reserving can claim a slot and drop it but never redeem it: send is inherent to each concrete type and unreachable through the trait. reserve is #[must_use] because a held claim withholds capacity from every other producer, and the trait cannot express the operation that discharges it. Additive, because the two implementors already agree exactly -- spsc::Reservation and reserving_mpsc::Reservation both carry send(self, item) -> Result<(), Disconnected>, is_disconnected(&self) -> bool, and a Drop that returns the slot. No concrete signature changes. Blocking rather than queued because adding a bound to an associated type breaks every implementor, which is free while the crate is unpublished and a major bump afterwards. M1 exists for exactly this class of change and SH-1.1 and SH-1.3 both landed on the same reasoning; D-3 already argues it, and this is the piece it missed. Pull request #56 is what puts these traits in front of consumers, so the window closes when it merges. Recorded as D-32, noted in PLANS.md, and cross-referenced from the checklist so the block is visible from the tracker rather than only from the item. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 38 +++++++++++++++++++ PLANS.md | 2 +- .../windows-waitable-queues/DESIGN-NOTES.md | 1 + 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 2cc3869b..65685da4 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -113,6 +113,44 @@ release-blocking rather than restating the decision itself. however rich its own `select`, which can only select over its own channels. Written into both the crate docs and the README, because docs.rs shows one and crates.io the other. +- [ ] **SH-1.5** -- **BLOCKS MERGING [pull request #56](https://github.com/MikeGrier/windows-threadpool-sys/pull/56).** + **Bound `Reserving::Reservation<'a>` so a generic caller can redeem what it claims.** + The associated type is declared with no bound at all, so a caller generic over + [`Reserving`](crates/windows-waitable-queues/src/traits.rs) can call `reserve()` and then do + nothing with the result except drop it. `reserve` is `#[must_use]` precisely because a held claim + withholds capacity from every other producer -- and the one operation that discharges it, `send`, + is inherent to each shape's concrete type and unreachable through the trait. The trait cannot + express the operation it exists for. + + **The two implementors already agree exactly, so this is additive**: both + `spsc::Reservation<'a, T>` and `reserving_mpsc::Reservation` already have + `send(self, item: T) -> Result<(), Disconnected>`, `is_disconnected(&self) -> bool`, and a + `Drop` that returns the slot. No concrete signature changes; nothing to migrate. + + Add a `Reservation` trait carrying `send` and `is_disconnected`, bound the associated type on it, + and implement it for both types. `is_disconnected` is included rather than deferred for the reason + the `Reserving` docs give at length -- a caller needs to learn the stream ended *before* doing the + work the claim was taken for -- and because `reserving_mpsc`'s reservation is `Send`, so it may be + redeemed on a thread holding no producer handle to ask instead. Adding it later is the same + breaking change, merely deferred. + + **Why this blocks rather than waits.** Adding a bound to an associated type is a breaking change + to the trait: every implementor must then satisfy it. It is free while the crate is unpublished + and a major bump with a migration afterwards, and this is the milestone that exists to settle + exactly that -- see SH-1.1 and SH-1.3, both landed on the same "free before the first publish" + reasoning. D-3 already makes this argument ("the trait *shape* is fixed now so signatures stay + compatible"); this is the same reasoning applied to a piece it missed. Pull request #56 is what + puts these traits in front of consumers, so the window closes when it merges. + + **How it surfaced**, recorded because the route is the useful part: not from review and not from a + failing test, but from a `cargo mutants` run showing that nothing exercised the capability traits + at all, and then from being unable to write the obvious generic test for `Reserving` -- the test + in [traits/tests.rs](crates/windows-waitable-queues/src/traits/tests.rs) is scoped to claim-and-release + and says so. A contract gap presenting as an untestable API is a signal worth keeping. + + Extend that test to claim-and-redeem through the trait as part of this item, since it is the + check that would have caught the gap in the first place. + ## M2: repair the release plumbing before relying on it - [x] **SH-2.1** -- **Add `windows-waitable-queues-v*` to the tag trigger list in diff --git a/PLANS.md b/PLANS.md index e649f1bc..0d55aa50 100644 --- a/PLANS.md +++ b/PLANS.md @@ -19,7 +19,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil |---|---|---|---| | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | -| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | +| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later; **SH-1.5 blocks merging pull request #56**, because the `Reserving` associated type needs a bound and adding one after publication breaks every implementor); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | | [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | in progress | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Only the crates.io publication is gated on releasing `windows-topology-sys` and `windows-waitable-queues`** -- an earlier version of this row gated the whole tool on them, which was wrong: the canonical artifact is a CI-built binary compiled from this repository through `path` dependencies, and it needs nothing published. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M1B keys a processor by `(group, number)` so a machine with more than 64 of them is not silently miscounted; M1C measures each NUMA hop in both directions and places the ring deliberately; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 distributes the binary; M5+ publishes to crates.io once the download path is established. | [crates/windows-placement-probe/DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index f164d2ba..8beccbbf 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -55,6 +55,7 @@ preferred. | D-29 | **Both multi-producer shapes ship. The crate publishes what it measured and declines to choose for the caller.** [D-26](#d-26) falsified [D-16](#d-16)'s cost premise, which reopened merge-or-delete; the answer is neither. Vyukov's sequence protocol and the head-based one are independently researched designs, both in production use, and our own workload having settled which *we* want is not evidence about anyone else's. Deleting a shape because no visible consumer wants it is what PLATFORM INTEGRITY forbids. What the crate owes instead is the data and, through `probe-core-affinity`, the means to gather it on the caller's own hardware. | | D-30 | **Both MPSC shapes are qualified by name; neither is `mpsc`.** A bare `mpsc` beside `reserving_mpsc` makes one canonical by implication, which contradicts this crate's own "no shape is the canonical one" and, after [D-29](#d-29), is simply false. `slotwise_mpsc` names its claim protocol -- it claims slot by slot, with no shared counter -- and avoids the reading `sequence_mpsc` invites, that it alone preserves FIFO order when both shapes do. Renamed before first publish, where it is free. | | D-31 | **0.1.0 ships without machine-checked memory orderings, and says so in its own documentation.** Model-checking gates 1.0, not 0.1.0. It would close the *demonstrated* gap -- a weakened `Acquire` survives the whole suite -- but not the dangerous one: it cannot model `SetEvent`/`ResetEvent`, so it cannot cover the doorbell, and [D-15](#d-15)'s lost wakeup, the only ordering bug this crate has had, was found by sabotage instead. The risk it addresses is mostly regression risk, which is lowest before there are consumers. The disclosure, not the deferral, is the decision. | +| D-32 | **`Reserving::Reservation<'a>` gains a bound, before the crate publishes.** The associated type is currently unbounded, so a caller generic over the trait can claim a slot and drop it but never redeem it -- the trait cannot express the operation it exists for. Both implementors already have identical `send` and `is_disconnected` signatures, so the bound is additive; adding it after publication is a breaking change to every implementor. Tracked as SH-1.5 in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md), which **blocks merging pull request #56**. | ## D-2: capabilities are sliced, not gathered From 81737ff45e96a56596f9cec024e73e0f076ded65 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 11:54:38 -0400 Subject: [PATCH 133/361] test(file-watcher): cover directory.rs's pure helpers, and pin down which survivors are uncatchable Working the genuine survivors from the mutation run, starting with the ones that need no Win32 call. Every test in `directory/tests.rs` drives a real `CreateFileW`, which is right for the open/classify contract but left the pure helpers underneath it exercised only incidentally, on whatever values a real handle happened to produce. Ten survivors killed, each confirmed by re-injecting the mutant on its own line: - `trim_nul`: `== 0` inverted to `!= 0` survived, which turns "stop at the first NUL" into "stop at the first non-NUL" and returns an empty slice for every real buffer. Five tests now cover the terminator, the no-terminator case a buffer filled to its length produces, a leading NUL, the empty slice, and the residue of a previous longer write. - `classify`: deleting the `ERROR_DIRECTORY` and `ERROR_INVALID_NAME` arms both survived. Every arm falls through to `Retryable`, so dropping one turns a permanent failure into one the retry machinery would chase forever. Now asserted per-code, plus the fallback and the no-OS-code path, because a real open cannot be made to produce each of these on demand. - `VolumeIdentity::filesystem_name`/`volume_label`: replacing either body with `String::new()` or a constant survived. The accessors are plain `pub fn`, but their only test was `#[cfg(feature = "test-util")]` -- so under default features they ship untested. The new tests use the crate-internal `synthetic` seam and run in every configuration, and one asserts the two fields do not alias, which no equality-only test could notice since identity compares by serial alone. WHICH SURVIVORS ARE UNCATCHABLE, MEASURED RATHER THAN ASSUMED. Eight of `directory.rs`'s survivors replace `|` with `^` in Win32 flag arguments. For disjoint bits those are the same operation, so no test can distinguish them -- confirmed by printing the constants rather than reasoning about them: FILE_SHARE_READ|WRITE, FILE_FLAG_BACKUP_SEMANTICS|OVERLAPPED and FILE_SHARE_WRITE|DELETE all give `or == xor`. The same check turned up something worth knowing on its own: VOLUME_NAME_DOS and FILE_NAME_NORMALIZED are **both 0x0**, so on that line `&` is equivalent too and all of its mutants are uncatchable. The one non-equivalent case, `FILE_FLAG_BACKUP_SEMANTICS & FILE_FLAG_OVERLAPPED` (which zeroes both flags and would stop a directory opening at all), is already caught by the existing tests. That last point is a discrepancy with the recorded run, which listed it as missed while `directory.rs` has not changed since. Not line drift, then, and not explained yet -- the fresh run will settle it. A TOOL, BECAUSE THE VERIFICATION KEEPS BEING THE HARD PART. `tools/inject-mutant.ps1` injects a mutant at a named file and line, judging by exit code and treating a hang as caught. It exists because the previous attempt used a whole-file string replace that matched four sites and inverted its own conclusion; cargo-mutants names a line, so use it. Writing it reproduced two more instances of the same class of bug, both fixed here: `WriteAllLines` emits CRLF on Windows, so the "restore" left every touched file dirty in this LF-only repo, and a `[string]` parameter rejects the empty replacement that a delete-match-arm mutant needs. The script now restores byte-identically, which `git status` confirms after each run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/directory/tests.rs | 132 ++++++++++++++++++ tools/inject-mutant.ps1 | 75 ++++++++++ 2 files changed, 207 insertions(+) create mode 100644 tools/inject-mutant.ps1 diff --git a/crates/windows-file-watcher/src/directory/tests.rs b/crates/windows-file-watcher/src/directory/tests.rs index e701496d..bf6e0d23 100644 --- a/crates/windows-file-watcher/src/directory/tests.rs +++ b/crates/windows-file-watcher/src/directory/tests.rs @@ -447,3 +447,135 @@ fn volume_identity_for_test_is_the_public_synthetic_seam() { assert_eq!(a, b); assert_ne!(a, c); } + +// --- pure helpers and accessors (mutation-testing gaps) --- +// +// A `cargo mutants` run left survivors in three places that need no Win32 call +// at all. Every test in this file above drives a real `CreateFileW`, which is +// right for the open/classify contract but meant the pure helpers underneath it +// were only ever exercised incidentally, on whatever values a real handle +// happened to produce. + +#[test] +fn trim_nul_stops_at_the_first_nul() { + // `replace == with !=` survived here, which inverts the search into "stop + // at the first non-NUL" -- returning an empty slice for every real buffer. + let units: Vec = "AB\0CD".encode_utf16().collect(); + assert_eq!( + super::trim_nul(&units), + &"AB".encode_utf16().collect::>()[..], + "the content before the first NUL is the string" + ); +} + +#[test] +fn trim_nul_keeps_a_slice_with_no_nul_whole() { + // The `map_or` default. A fixed-size Win32 buffer filled exactly to its + // length has no terminator to find, and truncating it would silently drop + // the last unit. + let units: Vec = "ABCD".encode_utf16().collect(); + assert_eq!(super::trim_nul(&units), &units[..]); +} + +#[test] +fn trim_nul_of_a_leading_nul_is_empty() { + let units = [0u16, 65, 66]; + assert!( + super::trim_nul(&units).is_empty(), + "a buffer Win32 wrote nothing into is the empty string, not its residue" + ); +} + +#[test] +fn trim_nul_of_an_empty_slice_is_empty() { + assert!(super::trim_nul(&[]).is_empty()); +} + +#[test] +fn trim_nul_ignores_everything_after_the_first_nul() { + // Two NULs with content between them: the residue of a previous, longer + // write is exactly what a reused buffer holds. + let units = [65u16, 0, 66, 0, 67]; + assert_eq!(super::trim_nul(&units), &[65u16][..]); +} + +#[test] +fn each_open_failure_code_classifies_to_its_own_outcome() { + // Deleting the `ERROR_DIRECTORY` and `ERROR_INVALID_NAME` match arms both + // survived: every arm falls through to `Retryable`, so dropping one turns a + // permanent failure into one the retry machinery would chase forever. + // + // Asserted per-code rather than through a real failing open, because a real + // open cannot be made to produce each of these on demand. + use super::{OpenFailure, classify}; + use windows_sys::Win32::Foundation::{ + ERROR_DIRECTORY, ERROR_FILE_NOT_FOUND, ERROR_INVALID_FUNCTION, ERROR_INVALID_NAME, + ERROR_NOT_SUPPORTED, ERROR_PATH_NOT_FOUND, + }; + + let cases: [(u32, OpenFailure); 6] = [ + (ERROR_FILE_NOT_FOUND, OpenFailure::NotFound), + (ERROR_PATH_NOT_FOUND, OpenFailure::NotFound), + (ERROR_DIRECTORY, OpenFailure::NotADirectory), + (ERROR_INVALID_FUNCTION, OpenFailure::Unsupported), + (ERROR_NOT_SUPPORTED, OpenFailure::Unsupported), + (ERROR_INVALID_NAME, OpenFailure::InvalidPath), + ]; + + for (code, expected) in cases { + let error = std::io::Error::from_raw_os_error(code as i32); + assert_eq!( + classify(&error), + expected, + "error {code} must classify as {expected:?}" + ); + } +} + +#[test] +fn an_unrecognised_code_is_retryable() { + // The fallback arm, asserted beside the named ones so a classifier that + // returned `Retryable` for everything could not pass the group above. + let error = std::io::Error::from_raw_os_error(0x0000_DEAD); + assert_eq!(super::classify(&error), OpenFailure::Retryable); +} + +#[test] +fn an_error_with_no_os_code_is_retryable() { + // The `raw_os_error()` guard: a synthesised error carries no code, and + // guessing a permanent classification from one would strand a watch. + let error = std::io::Error::other("no OS code behind this one"); + assert_eq!(super::classify(&error), OpenFailure::Retryable); +} + +#[test] +fn volume_identity_reports_the_descriptive_fields_it_was_built_with() { + // The accessors are plain `pub fn`, but their only test was behind + // `#[cfg(feature = "test-util")]` -- so with default features they shipped + // untested, and replacing either body with `String::new()` or a constant + // survived. This test uses the crate-internal `synthetic` seam so it runs + // in every configuration. + let identity = VolumeIdentity::synthetic(0x1234, "ReFS", "Data"); + assert_eq!(identity.filesystem_name(), "ReFS"); + assert_eq!(identity.volume_label(), "Data"); +} + +#[test] +fn volume_identitys_two_descriptive_fields_do_not_alias() { + // Distinct values, so an accessor returning the *other* field -- which no + // equality-only test could notice, since identity compares by serial -- + // fails here. + let identity = VolumeIdentity::synthetic(7, "NTFS", "System"); + assert_ne!(identity.filesystem_name(), identity.volume_label()); + assert_eq!(identity.filesystem_name(), "NTFS"); + assert_eq!(identity.volume_label(), "System"); +} + +#[test] +fn volume_identity_accepts_empty_descriptive_fields() { + // An unlabelled volume is ordinary, and the empty string must come back as + // itself rather than being confused with "not read". + let identity = VolumeIdentity::synthetic(1, "", ""); + assert_eq!(identity.filesystem_name(), ""); + assert_eq!(identity.volume_label(), ""); +} diff --git a/tools/inject-mutant.ps1 b/tools/inject-mutant.ps1 new file mode 100644 index 00000000..a8aa2f9f --- /dev/null +++ b/tools/inject-mutant.ps1 @@ -0,0 +1,75 @@ +# Copyright (c) Mike Grier. +# +# Line-targeted mutant injection, for confirming a test actually kills a mutant. +# +# Deliberately NOT a string replace: ` state.reserved += 1;` occurs four +# times in one file here, and replacing all of them mutated a tested line while +# reporting an untested one as caught -- inverting the conclusion. cargo-mutants +# names a file, a line and a column, so use them. +# +# Judges by exit code, and treats a hang as caught, for the reasons in +# tools/README-sabotage.md. + +param( + [Parameter(Mandatory = $true)][string] $File, + [Parameter(Mandatory = $true)][int[]] $Line, + [Parameter(Mandatory = $true)][string] $Find, + [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Replace, + [string] $TestFilter = '', + [string] $Package = 'windows-file-watcher', + [int] $TimeoutSeconds = 180 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +$utf8NoBom = [System.Text.UTF8Encoding]::new($false) +$repo = (git rev-parse --show-toplevel).Replace('/', '\') +$path = Join-Path $repo $File + +$original = Get-Content -LiteralPath $path +foreach ($n in $Line) { + $index = $n - 1 + if ($original[$index] -notmatch [regex]::Escape($Find)) { + Write-Host ("line {0} does not contain '{1}': {2}" -f $n, $Find, $original[$index].Trim()) -ForegroundColor Red + exit 2 + } +} + +foreach ($n in $Line) { + $mutated = [System.Collections.ArrayList]::new($original) + $index = $n - 1 + # Replace only the first occurrence on that one line. + $pattern = [regex]::Escape($Find) + $mutated[$index] = [regex]::Replace($mutated[$index], $pattern, $Replace.Replace('$', '$$'), 1) + + [System.IO.File]::WriteAllText($path, (($mutated -join "`n") + "`n"), $utf8NoBom) + try { + $args = @('test', '-p', $Package, '--locked') + if ($TestFilter) { $args += $TestFilter } + $out = Join-Path $env:TEMP 'mutline.txt' + $proc = Start-Process -FilePath 'cargo' -ArgumentList $args -WorkingDirectory $repo ` + -PassThru -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError "$out.err" + if ($proc.WaitForExit($TimeoutSeconds * 1000)) { + $verdict = if ($proc.ExitCode -eq 0) { '*** SURVIVED ***' } else { 'caught' } + $detail = "exit $($proc.ExitCode)" + } + else { + Get-CimInstance Win32_Process -Filter "ParentProcessId=$($proc.Id)" -ErrorAction SilentlyContinue | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + $verdict = 'caught' + $detail = "HUNG past ${TimeoutSeconds}s" + } + } + finally { + [System.IO.File]::WriteAllText($path, (($original -join "`n") + "`n"), $utf8NoBom) + if ((Get-Content -LiteralPath $path -Raw) -ne (($original -join "`n") + "`n")) { + # Content compare is approximate across line-ending conventions, so + # only warn; the authoritative check is the caller's `git status`. + Write-Host " (verify $File is restored)" -ForegroundColor DarkYellow + } + } + + $colour = if ($verdict -eq 'caught') { 'Green' } else { 'Red' } + Write-Host ("{0}:{1} '{2}' -> '{3}' {4} ({5})" -f $File, $n, $Find, $Replace, $verdict, $detail) -ForegroundColor $colour +} From e5f0a946e9e73db0698ab5b1e4fd6d862884ef3c Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 12:17:12 -0400 Subject: [PATCH 134/361] docs(file-watcher): record that a reopened handle rejects the watcher's own read Chasing the surviving 'replace | with &' mutant at directory.rs:457 turned up something larger than the mutant. Issuing the same overlapped ReadDirectoryChangesW on a handle from DirectoryHandle::open and on one from reopen_by_id, in the same process: the original accepts it, the reopened one fails with ERROR_INVALID_PARAMETER. That is why the mutant survives -- every reopen_by_id test asserts which directory came back, never that the handle is usable afterwards -- but the mutant is the symptom. Queued as M15.2 with the measurement, the three readings that could explain it, and the note that the test written to assert handle usability was removed rather than committed red. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 2f7ba732..456b09ca 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -152,6 +152,32 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- not legitimate is adding a test that reaches it artificially -- that would manufacture coverage for code nothing calls. +- [ ] **M15.2** -- Explain, then either fix or document, why a handle from `reopen_by_id` **rejects the + very read the watcher exists to issue**. Found while chasing a surviving mutant; the mutant is the + symptom and this is the disease. + **The measurement.** Open a temp directory with `DirectoryHandle::open`, reopen it with + `DirectoryHandle::reopen_by_id`, and issue the same overlapped `ReadDirectoryChangesW` on each + (DWORD-aligned buffer, `FILE_NOTIFY_CHANGE_FILE_NAME`, null `lpBytesReturned`, an `OVERLAPPED`, no + completion routine). The **original** handle accepts it -- returning TRUE with the operation pending, + which the call site in `watcher.rs` documents as normal. The **reopened** handle fails it with + `ERROR_INVALID_PARAMETER` (87). + **Why it matters.** `reopen_by_id` requests `FILE_LIST_DIRECTORY` and + `FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED` through `OpenFileById`, which reads as a handle + fit for watching. If it is not, the reopen path either cannot serve a watch at all, or serves it only + because some later step re-derives a usable handle -- and which of those is true is not visible from + the code. + **Three readings, and the engineer can tell them apart faster than a probe can.** (a) A real defect in + the reopen path. (b) A legitimate difference in what `OpenFileById` returns (an access right such as + `SYNCHRONIZE`, or volume-hint semantics) that the reopen path compensates for elsewhere. (c) A defect + in the measurement above, though it was run as a control against the original handle in the same + process and the original passed. + **What this explains.** `directory.rs:457`'s `|` -> `&` mutant survives -- the one that zeroes both + flags -- because every `reopen_by_id` test asserts only *which* directory came back, never that the + handle is usable afterwards. No test can close that gap until the behaviour above is understood, so + writing one now would encode whichever answer happened to be true. + A test asserting handle usability was written and then **removed rather than committed red**; it is + reconstructible from the measurement recorded here. + ## M-inf -- Horizon (ungated, post-v1) Parked, not pending. These are the deferred seams recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> D-19, From ff1de58c31dd0bd8a97d02945ed0da65c09fcc51 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 12:18:53 -0400 Subject: [PATCH 135/361] test(waitable-queues): give the error module the tests it never had error.rs had no test file at all, and a mutation run named the cost: ten survivors covering every Display, both source implementations, and both is_retryable predicates -- each of which could be replaced by a constant true or a constant false with the whole suite green. is_retryable is not decoration. A caller branches on it to choose between backing off and giving up, so a constant answer is either an infinite retry against a dead queue or an item dropped that would have gone through a moment later. What let the constant survive is that the shapes' own suites assert only the happy direction: a full queue is retryable. Nothing ever asked what a disconnected one says. Both directions are now asserted for both predicates, source is checked to return the right error rather than merely some error, and each Display is asked for a word only that rendering uses -- a formatter that writes nothing passes any test that only checks it does not panic. Measured: error.rs goes from 10 missed to 0, with 40 of 50 mutants caught and the rest unviable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-waitable-queues/src/error.rs | 3 + .../src/error/tests.rs | 135 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 crates/windows-waitable-queues/src/error/tests.rs diff --git a/crates/windows-waitable-queues/src/error.rs b/crates/windows-waitable-queues/src/error.rs index c063152c..c8503ddd 100644 --- a/crates/windows-waitable-queues/src/error.rs +++ b/crates/windows-waitable-queues/src/error.rs @@ -395,3 +395,6 @@ impl core::error::Error for RecvTimeoutError { } } } + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/error/tests.rs b/crates/windows-waitable-queues/src/error/tests.rs new file mode 100644 index 00000000..9f75367a --- /dev/null +++ b/crates/windows-waitable-queues/src/error/tests.rs @@ -0,0 +1,135 @@ +// Copyright (c) Mike Grier. + +//! Tests for the error types. +//! +//! # Why these exist as their own file +//! +//! This module had no tests at all, and a mutation run said so: ten survivors, +//! covering every `Display`, both `source` implementations, and -- the ones +//! that matter -- both `is_retryable` predicates, which could be replaced by a +//! constant `true` or a constant `false` with the whole suite still green. +//! +//! `is_retryable` is not decoration. It is what a caller branches on to decide +//! between backing off and giving up, so a constant answer is either an +//! infinite retry against a dead queue or a dropped item that would have gone +//! through a moment later. That the shapes' own suites exercise the *happy* +//! direction is what let the constant survive: asserting only that a full +//! queue is retryable never asks what a disconnected one says. + +use std::io; + +use super::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; +use crate::capacity::Bounds; + +/// An `io::Error` distinguishable from any other, so a `source` that returns +/// the wrong one is not mistaken for a right one. +fn io_error() -> io::Error { + io::Error::new(io::ErrorKind::BrokenPipe, "the doorbell broke") +} + +#[test] +fn a_push_refused_for_room_is_retryable_and_one_refused_for_disconnection_is_not() { + // Both directions, because a predicate asserted in one direction only is + // satisfied by the constant that agrees with it. + assert!( + PushError::Full(1_u32).is_retryable(), + "a full queue may have room later" + ); + assert!( + !PushError::Disconnected(1_u32).is_retryable(), + "a queue with no consumers will never take this item" + ); +} + +#[test] +fn a_refused_push_hands_back_the_item_whichever_way_it_failed() { + // The item is the caller's, and losing it is the failure this type exists + // to prevent: `into_inner` is the only way back. + assert_eq!(PushError::Full(7_u32).into_inner(), 7); + assert_eq!(PushError::Disconnected(9_u32).into_inner(), 9); + assert_eq!(Disconnected(11_u32).into_inner(), 11); +} + +#[test] +fn only_a_timeout_is_retryable_among_the_timed_receive_failures() { + assert!( + RecvTimeoutError::Timeout.is_retryable(), + "nothing arrived in time, and something still might" + ); + assert!( + !RecvTimeoutError::Disconnected.is_retryable(), + "no further item will ever arrive" + ); + assert!( + !RecvTimeoutError::from(io_error()).is_retryable(), + "a failed wait is not a reason to spin on the same call" + ); +} + +#[test] +fn only_the_io_failures_carry_a_source() { + use core::error::Error as _; + + assert!( + RecvError::Disconnected.source().is_none(), + "an ended stream is not caused by anything else" + ); + assert!( + RecvError::from(io_error()).source().is_some(), + "a failed wait must expose the failure underneath it" + ); + + assert!(RecvTimeoutError::Timeout.source().is_none()); + assert!(RecvTimeoutError::Disconnected.source().is_none()); + assert!(RecvTimeoutError::from(io_error()).source().is_some()); + + // And the source is the *right* error, not merely some error. + let source = RecvError::from(io_error()) + .source() + .expect("just asserted") + .to_string(); + assert!(source.contains("the doorbell broke"), "got {source}"); +} + +#[test] +fn every_error_renders_something_that_names_its_cause() { + // A `Display` that writes nothing satisfies any test that only checks it + // does not panic, so each rendering is asked for a word only it would use. + let cases: Vec<(String, &str)> = vec![ + (PushError::Full(1_u32).to_string(), "capacity"), + (PushError::Disconnected(1_u32).to_string(), "consumer"), + (Disconnected(1_u32).to_string(), "consumer"), + (RecvError::Disconnected.to_string(), "producer"), + (RecvError::from(io_error()).to_string(), "doorbell"), + (RecvTimeoutError::Timeout.to_string(), "deadline"), + (RecvTimeoutError::Disconnected.to_string(), "producer"), + (RecvTimeoutError::from(io_error()).to_string(), "doorbell"), + ]; + + for (rendered, expected) in cases { + assert!( + rendered.to_lowercase().contains(expected), + "{rendered:?} does not mention {expected:?}" + ); + } +} + +#[test] +fn a_capacity_error_renders_the_numbers_a_caller_needs_to_correct_the_call() { + // The whole value of this error is the three numbers, so a rendering that + // omits them leaves the caller guessing at a legal capacity. + let bounds = Bounds { + min: 2, + max: 1 << 20, + }; + let too_large = CapacityError::too_large(usize::MAX, bounds); + let rendered = too_large.to_string(); + + assert!( + rendered.contains(&usize::MAX.to_string()), + "the rejected capacity must appear: {rendered}" + ); + assert_eq!(too_large.requested(), usize::MAX); + assert_eq!(too_large.min_valid(), bounds.min); + assert_eq!(too_large.max_valid(), bounds.max); +} From fa14b12230d0c675d8174641908c999abbddcc6d Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 12:52:02 -0400 Subject: [PATCH 136/361] test(waitable-queues): cover the Parked protocol on every shape A mutation run left the whole group unguarded: `finish` could return None and `arm` could return Ok(true) on all three shapes with the suite green. Neither is cosmetic. `finish` is the last take before the end of a stream is reported, so a None silently discards an item that was successfully sent -- the push-then-drop race the method exists to close. `arm` reports whether parking is safe, so an unconditional true blesses a wait over a queue that already holds an item, which is a lost wakeup: the shape of D-15, the one ordering bug this crate has had. They survived for the same reason the capability-trait forwarders did. Every existing test reaches these through the inherent method, which shadows the trait one, so the impl the blocking loop actually calls is never exercised. The new tests go through `Parked` explicitly. `finish` is called directly rather than by scheduling the race it guards, which is the stated reason it exists as a named step: the window between a receive's first pop and its disconnection check cannot be hit reliably from a test. `pop` and `is_disconnected` are covered alongside them, so the contract is tested as a whole rather than only where mutants happened to survive. Measured: 47 mutants over Parked/finish/arm across the three shapes now report 0 missed, against 6 before. Worth recording, because it is a property of the area rather than a gap: 18 of those 47 register as timeouts rather than clean failures. A broken `arm` or `is_disconnected` does not produce a wrong answer, it produces a spin or a permanent park, so the suite detects it by hanging. They are genuinely detected -- at 120s each rather than milliseconds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/blocking/tests.rs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/crates/windows-waitable-queues/src/blocking/tests.rs b/crates/windows-waitable-queues/src/blocking/tests.rs index 7b2b0739..fd906824 100644 --- a/crates/windows-waitable-queues/src/blocking/tests.rs +++ b/crates/windows-waitable-queues/src/blocking/tests.rs @@ -112,3 +112,137 @@ fn no_duration_ever_produces_a_zero_wait() { ); } } + +// The `Parked` protocol itself, across every shape that implements it. +// +// # Why this is here and not in each shape's suite +// +// `Parked` is what `recv` and `recv_timeout` are written against, and its four +// methods are a contract each shape restates. A mutation run found the whole +// group unguarded: `finish` could return `None` and `arm` could return +// `Ok(true)` on all three shapes with the suite green. +// +// Neither is cosmetic. `finish` is the last take before the end of a stream is +// reported, so a `None` silently discards an item that was successfully sent. +// `arm` reports whether parking is safe, so an unconditional `true` blesses a +// wait over a queue that already has an item -- which is a lost wakeup, the one +// ordering bug this crate has actually had (D-15). +// +// The methods are exercised **through the trait**, because that is the surface +// the loop uses. Calling the inherent method instead is what left these alive: +// it shadows the trait one, so a broken forwarder is never reached. + +use super::Parked; +use crate::{reserving_mpsc, slotwise_mpsc, spsc}; + +/// `finish` must hand back an item that arrived before disconnection was seen. +/// +/// Called directly rather than by scheduling the race it guards. That is the +/// stated reason it exists as a named step: the window between a receive's +/// first `pop` and its disconnection check cannot be hit reliably from a test, +/// so the step is reachable on its own instead. +fn finish_returns_the_owed_item(consumer: &C) +where + C: Parked, +{ + assert_eq!( + Parked::finish(consumer), + Some(1), + "an item pushed before the producer went is still owed to the consumer" + ); + assert_eq!( + Parked::finish(consumer), + None, + "and once taken it is gone, so the stream really has ended" + ); +} + +/// `arm` must refuse to bless a wait while an item is sitting there. +fn arm_refuses_to_park_over_an_item(consumer: &C, has_item: bool) +where + C: Parked, +{ + let safe = Parked::arm(consumer).expect("arming must succeed"); + if has_item { + assert!( + !safe, + "parking over a queued item is a wait nothing will wake" + ); + } else { + assert!(safe, "an empty queue is safe to park on"); + } +} + +#[test] +fn every_shape_hands_back_the_last_item_through_parked_finish() { + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (slot_tx, slot_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + // Push, then drop the producer: the item is owed even though the stream has + // ended, which is exactly the state `finish` exists to resolve. + spsc_tx.push(1).expect("there is room"); + slot_tx.push(1).expect("there is room"); + res_tx.push(1).expect("there is room"); + drop(spsc_tx); + drop(slot_tx); + drop(res_tx); + + finish_returns_the_owed_item(&spsc_rx); + finish_returns_the_owed_item(&slot_rx); + finish_returns_the_owed_item(&res_rx); +} + +#[test] +fn every_shape_refuses_to_park_over_an_item_through_parked_arm() { + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (slot_tx, slot_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + // Empty first, so the `true` answer is shown to be a real reading rather + // than the only answer this method ever gives. + arm_refuses_to_park_over_an_item(&spsc_rx, false); + arm_refuses_to_park_over_an_item(&slot_rx, false); + arm_refuses_to_park_over_an_item(&res_rx, false); + + spsc_tx.push(1).expect("there is room"); + slot_tx.push(1).expect("there is room"); + res_tx.push(1).expect("there is room"); + + arm_refuses_to_park_over_an_item(&spsc_rx, true); + arm_refuses_to_park_over_an_item(&slot_rx, true); + arm_refuses_to_park_over_an_item(&res_rx, true); +} + +#[test] +fn every_shape_reports_disconnection_and_pops_through_parked() { + // The other two methods of the same contract, so the trait is covered as a + // whole rather than only where mutants happened to survive. + fn pop_and_disconnection>( + consumer: &C, + expect_item: Option, + ) -> bool { + assert_eq!(Parked::pop(consumer), expect_item); + Parked::is_disconnected(consumer) + } + + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + let (slot_tx, slot_rx) = slotwise_mpsc::bounded::(4).expect("4 is valid"); + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + + spsc_tx.push(5).expect("there is room"); + slot_tx.push(5).expect("there is room"); + res_tx.push(5).expect("there is room"); + + assert!(!pop_and_disconnection(&spsc_rx, Some(5))); + assert!(!pop_and_disconnection(&slot_rx, Some(5))); + assert!(!pop_and_disconnection(&res_rx, Some(5))); + + drop(spsc_tx); + drop(slot_tx); + drop(res_tx); + + assert!(pop_and_disconnection(&spsc_rx, None)); + assert!(pop_and_disconnection(&slot_rx, None)); + assert!(pop_and_disconnection(&res_rx, None)); +} From afd89c8e03939f199f2436fd4d1730130e93220d Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 12:47:28 -0400 Subject: [PATCH 137/361] test(file-watcher): read case sensitivity from a real case-sensitive directory The scoped mutation run on directory.rs took it from 32 survivors to 21, and this closes four more. is_case_sensitive_dir and the is_case_sensitive accessor both survived being replaced with a constant, and every operator in the flag test survived too -- all of it covered only by directories that happen to be case-insensitive, where a hard-coded false is indistinguishable from a real read. The test now marks a directory with 'fsutil file setCaseSensitiveInfo' (no elevation needed on NTFS) and asserts both kinds, so the positive half is exercised rather than assumed. It fails loudly if fsutil is unavailable rather than passing on half the evidence. Four mutants confirmed caught by line-targeted re-injection. Of the 21, nine are provably equivalent: '|' and '^' agree on disjoint bits, and VOLUME_NAME_DOS and FILE_NAME_NORMALIZED are both 0x0 so '&' agrees there too. No test can distinguish those. M15.3 records the rest. Building a fixture for canonical_path's retry showed that wide_path passes the path to CreateFileW verbatim with no '\\\\?\\' prefix, so a directory deeper than MAX_PATH cannot be opened at all even though create_dir_all will create it -- and the retry needs a 512-unit path, so it is unreachable through open. The failing fixture was removed rather than committed red. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 19 ++++++++ .../src/directory/tests.rs | 43 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 456b09ca..4f39ca73 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -178,6 +178,25 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- A test asserting handle usability was written and then **removed rather than committed red**; it is reconstructible from the measurement recorded here. +- [ ] **M15.3** -- Decide whether this crate should open paths longer than `MAX_PATH`, and note the + consequence for `canonical_path`'s retry either way. + **`wide_path` passes the caller's path to `CreateFileW` verbatim, with no `\\?\` prefix**, so a + directory deeper than `MAX_PATH` fails to open with `ERROR_PATH_NOT_FOUND` even though + `std::fs::create_dir_all` will happily create it (Rust prefixes internally). Measured while building a + fixture for the item below: the directory existed on disk and `DirectoryHandle::open` refused it. + **The knock-on.** `canonical_path` sizes a 512-unit buffer and retries when + `GetFinalPathNameByHandleW` says the path did not fit. Reaching that retry needs a canonical path of + 512+ units, and the only way in through `open` is a path longer than `MAX_PATH` -- which cannot be + opened. So the retry is unreachable via the crate's own API, which is why its `<` survives being + changed to `>` and `<=` (the `>` case loops forever and shows up as a timeout rather than a failure). + There *is* a back door -- a short junction pointing at a deep target, since + `GetFinalPathNameByHandleW` returns the resolved target -- so the code is not dead, merely unreachable + by the obvious route. That is the fixture to build if the retry is worth testing as it stands. + **Two coherent outcomes.** Support long paths (prefix `\\?\` in `wide_path`, which also makes the + retry reachable and testable), or state the `MAX_PATH` limit as deliberate and note that the retry + covers only the junction case. Silence is the one option that leaves a caller to discover the limit + from a `NotFound` that names nothing. + ## M-inf -- Horizon (ungated, post-v1) Parked, not pending. These are the deferred seams recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> D-19, diff --git a/crates/windows-file-watcher/src/directory/tests.rs b/crates/windows-file-watcher/src/directory/tests.rs index bf6e0d23..d8eb1fe7 100644 --- a/crates/windows-file-watcher/src/directory/tests.rs +++ b/crates/windows-file-watcher/src/directory/tests.rs @@ -579,3 +579,46 @@ fn volume_identity_accepts_empty_descriptive_fields() { assert_eq!(identity.filesystem_name(), ""); assert_eq!(identity.volume_label(), ""); } + +#[test] +fn case_sensitivity_is_read_from_the_directory_rather_than_assumed() { + // `is_case_sensitive_dir` and the `is_case_sensitive` accessor both survived + // being replaced with a constant, and every operator in the flag test + // survived too. All of it was covered only by directories that happen to be + // case-insensitive, where a hard-coded `false` is indistinguishable from a + // real read. + // + // The fix is a directory of each kind. `fsutil file setCaseSensitiveInfo` + // needs no elevation on NTFS, and if it is unavailable this test says so + // rather than passing quietly on half the evidence. + let insensitive = TempDir::new("case-insensitive"); + let handle = DirectoryHandle::open(insensitive.path()).expect("opens"); + assert!( + !handle.is_case_sensitive(), + "a directory with the flag clear must report insensitive" + ); + + let sensitive = TempDir::new("case-sensitive"); + let marked = std::process::Command::new("fsutil.exe") + .args([ + "file", + "setCaseSensitiveInfo", + &sensitive.path().display().to_string(), + "enable", + ]) + .output(); + + let enabled = matches!(&marked, Ok(output) if output.status.success()); + assert!( + enabled, + "could not mark a directory case-sensitive, so the positive half of \ + this contract went untested: {marked:?}" + ); + + let handle = DirectoryHandle::open(sensitive.path()).expect("opens"); + assert!( + handle.is_case_sensitive(), + "a directory with FILE_CS_FLAG_CASE_SENSITIVE_DIR set must report \ + sensitive -- this is the half a hard-coded `false` passes" + ); +} From 79de46a52612c78cfe897e58a797be79f6de55b0 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 13:20:02 -0400 Subject: [PATCH 138/361] test(waitable-queues): assert is_full and the capacity ceiling in both directions Every existing is_full assertion was positive -- assert!(tx.is_full()) after filling -- so an is_full stuck at true satisfied all of them, on all three shapes. That constant is a queue which refuses every push. The same one-directional pattern left PushError::is_retryable open, and it is the pattern worth naming: a predicate asserted in one direction only is satisfied by the constant that agrees with it. The reserving shape's case is the substantive one: is_full counts held reservations as occupied, so a four-slot queue holding one reservation is full at three items. That is now asserted from empty, through the held reservation, to full. Separately, validate_capacity's ceiling comparison could be widened from > to >= undetected, because every test here uses WIDEST, whose max is usize::MAX / 2 -- not a power of two, so the power-of-two rule refuses everything near it and the comparison is never asked about equality. A ceiling that is itself a legal capacity distinguishes them, and the largest capacity a shape offers has to be constructible. Measured: is_full 13 of 13 caught across the three shapes, capacity.rs 0 missed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/capacity/tests.rs | 22 +++++++++++++++++++ .../src/reserving_mpsc/tests.rs | 9 ++++++++ .../src/slotwise_mpsc/tests.rs | 4 ++++ .../windows-waitable-queues/src/spsc/tests.rs | 6 +++++ 4 files changed, 41 insertions(+) diff --git a/crates/windows-waitable-queues/src/capacity/tests.rs b/crates/windows-waitable-queues/src/capacity/tests.rs index 8b5cc8e2..89fab43e 100644 --- a/crates/windows-waitable-queues/src/capacity/tests.rs +++ b/crates/windows-waitable-queues/src/capacity/tests.rs @@ -90,3 +90,25 @@ fn a_capacity_that_is_not_a_power_of_two_is_refused_whatever_its_size() { ); } } + +#[test] +fn a_capacity_exactly_at_the_ceiling_is_accepted() { + // **The boundary the other tests here cannot reach.** They all use + // `WIDEST`, whose `max` is `usize::MAX / 2` -- not a power of two, so the + // power-of-two rule refuses every capacity near it and the `>` in + // `validate_capacity` is never asked about equality. Widening `>` to `>=` + // therefore changed nothing observable, and a mutation run found that + // comparison unguarded. + // + // With a ceiling that is itself a legal capacity, the two differ: the + // largest capacity a shape offers must be constructible, and off by one + // here would refuse it. + let bounds = Bounds { min: 2, max: 8 }; + + validate_capacity(8, bounds).expect("the ceiling itself must be accepted"); + validate_capacity(16, bounds).expect_err("one power of two above it must not be"); + + // The same at the other end, so the floor is not off by one either. + validate_capacity(2, bounds).expect("the floor itself must be accepted"); + validate_capacity(1, bounds).expect_err("below the floor must not be"); +} diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index 91c89b83..7f51f929 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -128,10 +128,19 @@ fn a_reserved_slot_is_delivered_into_a_queue_that_is_otherwise_full() { // The whole contract in one test: reserve, let the best-effort path fill // everything it is allowed to, and redeem anyway. let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + assert!(!tx.is_full(), "an empty queue is not full"); let slot = tx.reserve().expect("a fresh queue has room"); + assert!( + !tx.is_full(), + "nor is one holding a single reservation against four slots" + ); let pushed = fill(&tx, 1); assert_eq!(pushed, 3, "the reservation withheld exactly one slot"); + // Both directions, and this shape's is the interesting one: `is_full` + // counts held reservations as occupied, so the queue is full at three + // items rather than four. An `is_full` stuck at either constant would + // report that wrongly, and only the positive case was ever asserted. assert!(tx.is_full(), "and now nothing more may be pushed"); slot.send(99).expect("the room was already ours"); diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs b/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs index 48b24439..8ddbbda3 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs @@ -86,7 +86,11 @@ fn items_come_out_in_the_order_they_went_in() { #[test] fn a_full_queue_refuses_and_hands_the_item_back() { let (tx, rx) = bounded::(2).expect("a power-of-two capacity"); + // Both directions: an `is_full` that always says so refuses every push, and + // asserting only the positive case cannot tell the two apart. + assert!(!tx.is_full(), "an empty queue is not full"); tx.push(1).expect("room"); + assert!(!tx.is_full(), "nor is a partly filled one"); tx.push(2).expect("room"); assert!(tx.is_full()); diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index b5256d41..c1519398 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -59,7 +59,13 @@ fn items_come_out_in_the_order_they_went_in() { #[test] fn a_full_queue_refuses_and_hands_the_item_back() { let (tx, rx) = bounded::(2).expect("a power-of-two capacity"); + // **Both directions.** Asserting only that a full queue says so is + // satisfied by an `is_full` that always says so -- which is a queue that + // refuses every push, and a mutation run found exactly that constant alive + // on all three shapes. + assert!(!tx.is_full(), "an empty queue is not full"); tx.push(1).expect("room"); + assert!(!tx.is_full(), "nor is a partly filled one"); tx.push(2).expect("room"); assert!(tx.is_full()); From a6e598f26c9c69b2b58d73f53f365809bbfcc12a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 13:24:34 -0400 Subject: [PATCH 139/361] test(waitable-queues): assert the Debug renderings say something Every Debug impl in the crate could return Ok(default) -- rendering nothing at all -- with the suite green, because nothing formatted these types and a formatter that writes nothing passes any test that only checks formatting does not panic. options.rs had no test file at all. These are the diagnostic surface someone reaches for when a queue is stuck, so an empty rendering fails at the moment it is least affordable: a handle that will not accept a push prints its capacity, length and disconnection state, and that is the whole reason to look. The doorbell's is the substantive one, and writing it corrected my own assumption. Signalling a doorbell nobody has asked a handle for is a no-op by design -- the laziness D-5 describes -- so the rendering still reads false, and only after handle() creates the event does a signal land. The test now asserts that distinction rather than the naive signal-then-expect-true I first wrote, which failed. Measured: 13 of 13 Debug mutants caught, against 12 alive before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/disposal/tests.rs | 10 +++++ .../src/doorbell/tests.rs | 39 +++++++++++++++++++ crates/windows-waitable-queues/src/options.rs | 3 ++ .../src/options/tests.rs | 38 ++++++++++++++++++ .../src/reserving_mpsc/tests.rs | 27 +++++++++++++ .../src/slotwise_mpsc/tests.rs | 21 ++++++++++ .../windows-waitable-queues/src/spsc/tests.rs | 22 +++++++++++ 7 files changed, 160 insertions(+) create mode 100644 crates/windows-waitable-queues/src/options/tests.rs diff --git a/crates/windows-waitable-queues/src/disposal/tests.rs b/crates/windows-waitable-queues/src/disposal/tests.rs index 3abaad15..d0d991d3 100644 --- a/crates/windows-waitable-queues/src/disposal/tests.rs +++ b/crates/windows-waitable-queues/src/disposal/tests.rs @@ -212,3 +212,13 @@ fn a_panicking_destructor_still_destroys_the_item_it_panicked_on() { assert_eq!(dropped.load(Ordering::Relaxed), 1); } + +#[test] +fn the_debug_rendering_names_the_type() { + // `Disposal` holds a boxed closure, so its rendering is deliberately opaque + // -- but opaque is not the same as empty. A `Debug` returning `Ok(default)` + // writes nothing at all and passes any test that only checks it does not + // panic, which is what a mutation run found here. + let rendered = format!("{:?}", Disposal::new(|_: u32| {})); + assert!(rendered.contains("Disposal"), "got {rendered}"); +} diff --git a/crates/windows-waitable-queues/src/doorbell/tests.rs b/crates/windows-waitable-queues/src/doorbell/tests.rs index 659a2bc5..044a5102 100644 --- a/crates/windows-waitable-queues/src/doorbell/tests.rs +++ b/crates/windows-waitable-queues/src/doorbell/tests.rs @@ -384,3 +384,42 @@ fn a_clear_with_nothing_racing_it_still_re_arms() { doorbell.signal(); assert!(is_signalled(&doorbell), "and the next signal rings"); } + +#[test] +fn the_debug_rendering_shows_whether_the_event_exists_and_its_state() { + // Both fields, and both values of the one that moves. This rendering is how + // a reader tells "the doorbell was never created" from "it was created and + // is unsignalled" -- the laziness being visible, per D-5 -- so an empty + // rendering loses exactly the distinction it exists to show. + let doorbell = Doorbell::new(); + let before = format!("{doorbell:?}"); + assert!(before.contains("Doorbell"), "got {before}"); + assert!( + before.contains("false"), + "an untouched doorbell is neither created nor signalled: {before}" + ); + + // Signalling one nobody has asked a handle for is a no-op by design -- the + // laziness D-5 describes -- so the rendering must still read false. This is + // the distinction the rendering exists to show, and it is why the test + // cannot simply signal and look for "true". + doorbell.signal(); + let unheard = format!("{doorbell:?}"); + assert!( + !unheard.contains("true"), + "a doorbell nobody is listening to was not created or signalled: {unheard}" + ); + + // Asking for the handle is what creates it; only then does a signal land. + doorbell.handle().expect("the doorbell must be creatable"); + doorbell.signal(); + let after = format!("{doorbell:?}"); + assert!( + after.contains("created: true"), + "the event now exists: {after}" + ); + assert!( + after.contains("signalled: true"), + "and has been rung: {after}" + ); +} diff --git a/crates/windows-waitable-queues/src/options.rs b/crates/windows-waitable-queues/src/options.rs index aaf1d380..c6bd582d 100644 --- a/crates/windows-waitable-queues/src/options.rs +++ b/crates/windows-waitable-queues/src/options.rs @@ -108,3 +108,6 @@ impl fmt::Debug for Options { .finish() } } + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/options/tests.rs b/crates/windows-waitable-queues/src/options/tests.rs new file mode 100644 index 00000000..b569aec0 --- /dev/null +++ b/crates/windows-waitable-queues/src/options/tests.rs @@ -0,0 +1,38 @@ +// Copyright (c) Mike Grier. + +//! Tests for the shape-construction options. +//! +//! The builder itself is exercised wherever a shape is built with options; what +//! is tested here is the rendering, which a mutation run found unguarded: a +//! `Debug` returning `Ok(default)` writes nothing and passes any test that only +//! checks formatting does not panic. + +use super::Options; +use crate::Disposal; + +#[test] +fn the_debug_rendering_shows_both_options_and_tracks_them_changing() { + // Both fields and both states of each, so a rendering stuck at one constant + // cannot satisfy this. + let bare = format!("{:?}", Options::::new()); + assert!(bare.contains("Options"), "got {bare}"); + assert!( + bare.contains("false"), + "a fresh Options has no disposal and no tracking: {bare}" + ); + + let configured = format!( + "{:?}", + Options::::new() + .tracking_high_water() + .disposal(Disposal::new(|_: u32| {})) + ); + assert!( + configured.contains("true"), + "a configured Options must show it: {configured}" + ); + assert!( + !configured.contains("false"), + "both fields were set, so neither should still read false: {configured}" + ); +} diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index 7f51f929..8a3d1837 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -1048,3 +1048,30 @@ fn a_reserved_delivery_rings_like_any_other() { "the message a reservation exists to protect must wake a parked consumer" ); } + +#[test] +fn the_debug_renderings_name_the_type_and_its_state() { + // See the same test in the other shapes. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + + let producer = format!("{tx:?}"); + assert!( + producer.contains("reserving_mpsc::Producer"), + "got {producer}" + ); + assert!(producer.contains('4'), "the capacity must show: {producer}"); + + let consumer = format!("{rx:?}"); + assert!( + consumer.contains("reserving_mpsc::Consumer"), + "got {consumer}" + ); + + let reservation = tx.reserve().expect("there is room"); + let rendered = format!("{reservation:?}"); + assert!( + rendered.contains("reserving_mpsc::Reservation"), + "got {rendered}" + ); +} diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs b/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs index 8ddbbda3..bebd01e1 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs @@ -1274,3 +1274,24 @@ fn a_poll_only_consumer_rings_no_doorbells() { while rx.pop().is_some() {} assert_eq!(rx.doorbell_rings(), 0); } + +#[test] +fn the_debug_renderings_name_the_type_and_its_state() { + // See the same test in the other shapes: a `Debug` returning `Ok(default)` + // renders nothing and passes any test that only checks it does not panic. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + + let producer = format!("{tx:?}"); + assert!( + producer.contains("slotwise_mpsc::Producer"), + "got {producer}" + ); + assert!(producer.contains('4'), "the capacity must show: {producer}"); + + let consumer = format!("{rx:?}"); + assert!( + consumer.contains("slotwise_mpsc::Consumer"), + "got {consumer}" + ); +} diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index c1519398..6be1d2a7 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -1663,3 +1663,25 @@ fn clearing_the_doorbell_makes_the_next_push_ring_again() { assert!(rx.arm().expect("arming must succeed")); } } + +#[test] +fn the_debug_renderings_name_the_type_and_its_state() { + // A `Debug` that writes nothing satisfies any test which only checks that + // formatting does not panic, and a mutation run found exactly that constant + // alive on every handle in this crate. These are the diagnostic surface a + // reader reaches for when a queue is stuck, so an empty rendering is the + // moment it is least affordable. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + + let producer = format!("{tx:?}"); + assert!(producer.contains("spsc::Producer"), "got {producer}"); + assert!(producer.contains('4'), "the capacity must show: {producer}"); + + let consumer = format!("{rx:?}"); + assert!(consumer.contains("spsc::Consumer"), "got {consumer}"); + + let reservation = tx.reserve().expect("there is room"); + let rendered = format!("{reservation:?}"); + assert!(rendered.contains("spsc::Reservation"), "got {rendered}"); +} From da8fe04fdd676f4d0c1ac19ffd863a97c965a5f3 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 13:25:49 -0400 Subject: [PATCH 140/361] test(file-watcher): assert the opaque queue handles name themselves The scoped run on queue.rs left six survivors. Four are the unreachable half of StandingHold::drop already queued as M15.1 -- confirmed here by a run with the features actually enabled, so that diagnosis no longer rests on the earlier mis-configured sweep. The other two are the hand-written Debug impls for StandingSlot and Reservation, which survived being replaced with a body that writes nothing. Both types are deliberately opaque and say so with finish_non_exhaustive, but opaque is not empty: a handle that formats as nothing names no type in a panic message or a log line, which is the only job these impls have. Both mutants confirmed caught by line-targeted re-injection. queue.rs is now fully accounted for: 4 blocked on M15.1, 2 closed here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-file-watcher/src/queue/tests.rs | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/windows-file-watcher/src/queue/tests.rs b/crates/windows-file-watcher/src/queue/tests.rs index ba241e36..26e8b303 100644 --- a/crates/windows-file-watcher/src/queue/tests.rs +++ b/crates/windows-file-watcher/src/queue/tests.rs @@ -1535,3 +1535,28 @@ fn a_standing_slot_keeps_its_carve_out_across_many_send_drain_cycles() { drop(held); } } + +#[test] +fn the_opaque_handles_name_themselves_when_formatted() { + // Both `Debug` impls survived being replaced with a body that writes + // nothing. They are hand-written and deliberately opaque -- neither type + // can usefully show its interior, and `finish_non_exhaustive` says so -- + // but "opaque" is not "empty": a `StandingSlot` that formats as nothing at + // all makes a panic message or a log line name no type, which is the one + // job these impls have. + let (sender, _receiver) = bounded(2); + let slot = sender.reserve_standing().expect("a slot"); + let reservation = sender.reserve().expect("a reservation"); + + let rendered = format!("{slot:?}"); + assert!( + rendered.contains("StandingSlot"), + "a standing slot must name itself when formatted, got {rendered:?}" + ); + + let rendered = format!("{reservation:?}"); + assert!( + rendered.contains("Reservation"), + "a reservation must name itself when formatted, got {rendered:?}" + ); +} From b697312c04ce42f3e3263c1b3d05c08a1ee089d3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 13:37:32 -0400 Subject: [PATCH 141/361] feat(waitable-queues)!: mark PushError non_exhaustive and disclose the one-directional doorbell Two pre-release decisions, taken now because both are free today and one is breaking after publication. PushError was not #[non_exhaustive] while RecvError and RecvTimeoutError both were. That asymmetry was omission rather than intent -- the receive side was made extensible and the send side was not -- and it matters because adding the attribute later is itself a breaking change: every caller's exhaustive match would need the wildcard it does not have. No crate outside this one matches on it yet, so the window is open now and closes at 0.1.0. The concrete reason to keep the room open is M32.3, the open question of whether a producer can wait for capacity rather than only being refused it. The crate's own precedent suggests that would arrive as a separate error type, the way RecvError and RecvTimeoutError are separate rather than one extended enum, so a new variant here may never be needed. Deciding that with the choice already foreclosed is the outcome this avoids. Separately, the absence of producer-side waiting is now stated in both the crate docs and the README, per the SH-1.4 rule that docs.rs shows one and crates.io the other. It needs saying because the obvious comparison misleads: crossbeam-channel's send blocks on a full bounded channel, so a reader arriving from it will expect the same and get an immediate refusal. The refusal is the backpressure (D-6), and a producer with nowhere to go has to decide for itself. The disclosure records the two properties that already shape M32.3's answer: a bounded queue can offer such a wait and an unbounded one never can, so it belongs in its own capability trait rather than in Waitable; and while every shape here has a single consumer, two have many producers, so a "there is room" signal has N waiters and is not the doorbell mirrored. Recorded as D-33. The capability itself is additive and does not gate the release, so it stays open rather than blocking it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-waitable-queues/DESIGN-NOTES.md | 1 + crates/windows-waitable-queues/README.md | 23 +++++++++++++++++ crates/windows-waitable-queues/src/error.rs | 18 +++++++++++++ crates/windows-waitable-queues/src/lib.rs | 25 +++++++++++++++++++ 4 files changed, 67 insertions(+) diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 8beccbbf..c2ea1cb1 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -56,6 +56,7 @@ preferred. | D-30 | **Both MPSC shapes are qualified by name; neither is `mpsc`.** A bare `mpsc` beside `reserving_mpsc` makes one canonical by implication, which contradicts this crate's own "no shape is the canonical one" and, after [D-29](#d-29), is simply false. `slotwise_mpsc` names its claim protocol -- it claims slot by slot, with no shared counter -- and avoids the reading `sequence_mpsc` invites, that it alone preserves FIFO order when both shapes do. Renamed before first publish, where it is free. | | D-31 | **0.1.0 ships without machine-checked memory orderings, and says so in its own documentation.** Model-checking gates 1.0, not 0.1.0. It would close the *demonstrated* gap -- a weakened `Acquire` survives the whole suite -- but not the dangerous one: it cannot model `SetEvent`/`ResetEvent`, so it cannot cover the doorbell, and [D-15](#d-15)'s lost wakeup, the only ordering bug this crate has had, was found by sabotage instead. The risk it addresses is mostly regression risk, which is lowest before there are consumers. The disclosure, not the deferral, is the decision. | | D-32 | **`Reserving::Reservation<'a>` gains a bound, before the crate publishes.** The associated type is currently unbounded, so a caller generic over the trait can claim a slot and drop it but never redeem it -- the trait cannot express the operation it exists for. Both implementors already have identical `send` and `is_disconnected` signatures, so the bound is additive; adding it after publication is a breaking change to every implementor. Tracked as SH-1.5 in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md), which **blocks merging pull request #56**. | +| D-33 | **`PushError` is `#[non_exhaustive]`, and the one-directional doorbell is disclosed rather than fixed before 0.1.0.** The receive-side errors already carried the attribute and the send side lacked it by omission; adding it after publication is itself breaking, so it is taken now while the crate has no external consumers. Whether a producer can *wait* for room stays open as [M32.3](../../CHECKLIST-io-domains.md) -- it is additive, so it does not gate the release -- but the absence is stated in both the crate docs and the README, because `crossbeam-channel`'s `send` blocks and a reader arriving from it will assume this one does too. | ## D-2: capabilities are sliced, not gathered diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index f7005f53..6ee13af0 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -217,6 +217,29 @@ Two things that look like reasons to choose and are not: slot. Overwrite-oldest is right for telemetry, where a lost entry is a lost sample; here an entry may be an I/O submission, where a lost entry is a lost operation. +- **It will not let a producer wait for room.** The doorbell is one-directional: + a consumer can park until there is something to take, and there is no + equivalent for a producer waiting until there is somewhere to put. `push` + refuses immediately with `PushError::Full`, and `reserve` returns `None`; + neither blocks, and no handle is offered to wait on. + + **Said plainly because the obvious comparison misleads.** `crossbeam-channel`'s + `send` blocks on a full bounded channel, so a reader arriving from it will + expect the same here and get a refusal instead. A producer with nowhere to go + must decide what to do -- shed the item, retry on its own schedule, or grow a + buffer of its own -- rather than being parked by the queue. + + This is a deliberate absence rather than an oversight, and it is not + permanent: whether a producer can wait, and **what it would wait on**, is the + open question in [M32.3](../../CHECKLIST-io-domains.md). The constraint that + makes it non-trivial is the one this crate exists for -- a blocking send that + parks on something `WaitForMultipleObjects` cannot see would reintroduce + exactly the composition problem that ruled out the existing channel crates. + Two further wrinkles, recorded so the shape of the problem is visible: a + bounded queue can offer this and an unbounded one never can, so it belongs in + its own capability trait rather than in `Waitable`; and while every shape here + has one consumer, two have many *producers*, so a room signal has N waiters + and is not the mirror image of the doorbell. - **It will not decide between two real queue designs on your behalf.** `slotwise_mpsc` and `reserving_mpsc` are different claim protocols, both well studied and both used in production and in research. `slotwise_mpsc` asks each slot's own sequence diff --git a/crates/windows-waitable-queues/src/error.rs b/crates/windows-waitable-queues/src/error.rs index c8503ddd..ac5f8a3e 100644 --- a/crates/windows-waitable-queues/src/error.rs +++ b/crates/windows-waitable-queues/src/error.rs @@ -215,7 +215,25 @@ impl core::error::Error for CapacityError {} /// /// The item is returned rather than dropped, because a queue that swallows what /// it refuses gives a caller no way to retry, redirect, or account for it. +/// +/// # Why this is `#[non_exhaustive]` +/// +/// Match it with a wildcard arm. Both receive-side errors already carry this +/// attribute, and the send side lacked it only by omission -- which mattered +/// more than an inconsistency, because **adding it later is itself a breaking +/// change**: every caller's exhaustive `match` would need the wildcard it does +/// not have. Free before the first publish, and a major bump after it. +/// +/// The concrete reason to keep the room open is +/// [M32.3](../../CHECKLIST-io-domains.md), the open decision on whether a +/// producer can *wait* for capacity rather than only being refused it. If that +/// lands, the send side may need to report something this enum cannot express +/// today. The crate's own precedent suggests a separate error type instead -- +/// [`RecvError`] and [`RecvTimeoutError`] are distinct rather than one extended +/// enum -- so a new variant here may never be needed. Deciding that under time +/// pressure, with the choice already foreclosed, is the outcome this avoids. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum PushError { /// The queue is at capacity. /// diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 00904621..5c0c9096 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -46,6 +46,31 @@ //! [`Reserving`] -- each naming one thing a queue can do, so a caller can be //! generic over exactly what it needs and nothing more. //! +//! # Waiting is one-directional: a producer cannot park on a full queue +//! +//! [`Waitable`] is a *consumer's* capability. A consumer can park until there +//! is something to take; there is **no equivalent for a producer waiting until +//! there is somewhere to put**. [`Producer::push`] refuses immediately with +//! [`PushError::Full`], [`Reserving::reserve`] returns `None`, and neither +//! offers a handle to wait on. +//! +//! **Stated here because the obvious comparison misleads.** `crossbeam-channel` +//! blocks in `send` on a full bounded channel, so a reader arriving from it +//! will expect the same and get a refusal. A producer with nowhere to go has to +//! decide for itself -- shed the item, retry on its own schedule, or buffer -- +//! rather than being parked by the queue. The refusal *is* the backpressure +//! (D-6), and it is typed so the item comes back rather than being swallowed. +//! +//! The absence is deliberate and not permanent. Whether a producer can wait, +//! and **what it would wait on**, is open: a blocking send that parks on +//! something `WaitForMultipleObjects` cannot see would reintroduce the very +//! composition problem that ruled out the existing channel crates, which is the +//! reason this one exists. Two properties already shape the answer -- a bounded +//! queue can offer such a wait and an unbounded one never can, so it belongs in +//! its own capability trait rather than in [`Waitable`]; and while every shape +//! here has a single consumer, two of them have many *producers*, so a +//! "there is room" signal has N waiters and is not the doorbell mirrored. +//! //! # How far the memory orderings are verified, and how far they are not //! //! Stated plainly because a lock-free queue that is vague about this is asking From 4a4cb264b760818ce85aee3ab8325b65882dda0b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 13:58:50 -0400 Subject: [PATCH 142/361] feat(waitable-queues)!: bound Reserving::Reservation so a generic caller can redeem a claim Completed item: SH-1.5: Bound Reserving::Reservation<'a> so a generic caller can redeem what it claims The associated type was declared with no bound, so a caller generic over Reserving could call reserve() and then do nothing with the result except drop it. reserve is #[must_use] precisely because a held claim withholds capacity from every other producer, and the operation that discharges it -- send -- was inherent to each shape's concrete type and unreachable through the trait. The trait could not express the operation it exists for. The new Claim trait carries send and is_disconnected, Reservation<'a> is bound on it, and both reservation types implement it as forwarders. No concrete signature changed. slotwise_mpsc is untouched, as it must be: it does not implement Reserving at all, which is the narrow-traits argument (D-2) holding up under a change a fat trait would have strained. is_disconnected is on the claim rather than only on the producer that made it, because a reservation may outlive the moment the producer was last consulted -- and reserving_mpsc's is Send, so it may be redeemed on a thread holding no producer handle to ask. Taken before publication because adding a bound to an associated type breaks every implementor: free while the crate is unpublished, a major bump after. That is what M1 exists to settle, and D-3 already argued it -- the trait shape is fixed now so signatures stay compatible. This was the piece it missed. Mutation-tested rather than assumed. The first run over the new surface found a real gap: is_disconnected stuck at false survived on spsc, because the connected case was asserted there and the disconnected case only on the other shape -- the same one-directional pattern that left is_retryable and is_full open. Both answers are now asserted on both shapes, and 62 mutants over the whole reservation surface report 0 missed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 9 ++- PLANS.md | 2 +- .../windows-waitable-queues/DESIGN-NOTES.md | 2 +- crates/windows-waitable-queues/src/lib.rs | 8 ++- .../src/reserving_mpsc.rs | 12 ++++ crates/windows-waitable-queues/src/spsc.rs | 12 ++++ crates/windows-waitable-queues/src/traits.rs | 31 +++++++++- .../src/traits/tests.rs | 56 ++++++++++++++++++- 8 files changed, 123 insertions(+), 9 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 65685da4..068246b3 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -113,8 +113,13 @@ release-blocking rather than restating the decision itself. however rich its own `select`, which can only select over its own channels. Written into both the crate docs and the README, because docs.rs shows one and crates.io the other. -- [ ] **SH-1.5** -- **BLOCKS MERGING [pull request #56](https://github.com/MikeGrier/windows-threadpool-sys/pull/56).** - **Bound `Reserving::Reservation<'a>` so a generic caller can redeem what it claims.** +- [x] **SH-1.5** -- **Bound `Reserving::Reservation<'a>` so a generic caller can redeem what it claims.** + Done: the `Claim` trait carries `send` and `is_disconnected`, `Reservation<'a>` is bound on it, and + both reservation types implement it as forwarders. 87 lines across five files, no concrete signature + changed, `slotwise_mpsc` untouched because it does not implement `Reserving` at all. Mutation-tested + rather than assumed: 62 mutants over the whole reservation surface report 0 missed, and the first + run found a real gap -- `is_disconnected` stuck at `false` survived on `spsc`, because the connected + case was asserted there and the disconnected case only on the other shape. The associated type is declared with no bound at all, so a caller generic over [`Reserving`](crates/windows-waitable-queues/src/traits.rs) can call `reserve()` and then do nothing with the result except drop it. `reserve` is `#[must_use]` precisely because a held claim diff --git a/PLANS.md b/PLANS.md index 0d55aa50..905de44b 100644 --- a/PLANS.md +++ b/PLANS.md @@ -19,7 +19,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil |---|---|---|---| | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | -| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later; **SH-1.5 blocks merging pull request #56**, because the `Reserving` associated type needs a bound and adding one after publication breaks every implementor); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | +| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later; SH-1.5 gave `Reserving`'s associated type the bound a generic caller needs, which had to land before publication); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | | [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | in progress | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Only the crates.io publication is gated on releasing `windows-topology-sys` and `windows-waitable-queues`** -- an earlier version of this row gated the whole tool on them, which was wrong: the canonical artifact is a CI-built binary compiled from this repository through `path` dependencies, and it needs nothing published. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M1B keys a processor by `(group, number)` so a machine with more than 64 of them is not silently miscounted; M1C measures each NUMA hop in both directions and places the ring deliberately; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 distributes the binary; M5+ publishes to crates.io once the download path is established. | [crates/windows-placement-probe/DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index c2ea1cb1..4ef9aa87 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -55,7 +55,7 @@ preferred. | D-29 | **Both multi-producer shapes ship. The crate publishes what it measured and declines to choose for the caller.** [D-26](#d-26) falsified [D-16](#d-16)'s cost premise, which reopened merge-or-delete; the answer is neither. Vyukov's sequence protocol and the head-based one are independently researched designs, both in production use, and our own workload having settled which *we* want is not evidence about anyone else's. Deleting a shape because no visible consumer wants it is what PLATFORM INTEGRITY forbids. What the crate owes instead is the data and, through `probe-core-affinity`, the means to gather it on the caller's own hardware. | | D-30 | **Both MPSC shapes are qualified by name; neither is `mpsc`.** A bare `mpsc` beside `reserving_mpsc` makes one canonical by implication, which contradicts this crate's own "no shape is the canonical one" and, after [D-29](#d-29), is simply false. `slotwise_mpsc` names its claim protocol -- it claims slot by slot, with no shared counter -- and avoids the reading `sequence_mpsc` invites, that it alone preserves FIFO order when both shapes do. Renamed before first publish, where it is free. | | D-31 | **0.1.0 ships without machine-checked memory orderings, and says so in its own documentation.** Model-checking gates 1.0, not 0.1.0. It would close the *demonstrated* gap -- a weakened `Acquire` survives the whole suite -- but not the dangerous one: it cannot model `SetEvent`/`ResetEvent`, so it cannot cover the doorbell, and [D-15](#d-15)'s lost wakeup, the only ordering bug this crate has had, was found by sabotage instead. The risk it addresses is mostly regression risk, which is lowest before there are consumers. The disclosure, not the deferral, is the decision. | -| D-32 | **`Reserving::Reservation<'a>` gains a bound, before the crate publishes.** The associated type is currently unbounded, so a caller generic over the trait can claim a slot and drop it but never redeem it -- the trait cannot express the operation it exists for. Both implementors already have identical `send` and `is_disconnected` signatures, so the bound is additive; adding it after publication is a breaking change to every implementor. Tracked as SH-1.5 in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md), which **blocks merging pull request #56**. | +| D-32 | **`Reserving::Reservation<'a>` gains a bound, before the crate publishes.** The associated type is currently unbounded, so a caller generic over the trait can claim a slot and drop it but never redeem it -- the trait cannot express the operation it exists for. Both implementors already have identical `send` and `is_disconnected` signatures, so the bound is additive; adding it after publication is a breaking change to every implementor. Done as SH-1.5: the [`Claim`](src/traits.rs) trait carries `send` and `is_disconnected`, and both reservation types implement it as forwarders. `Claim` must be in scope to call those methods on a claim whose concrete type the caller has not named, which is why it is re-exported at the crate root. | | D-33 | **`PushError` is `#[non_exhaustive]`, and the one-directional doorbell is disclosed rather than fixed before 0.1.0.** The receive-side errors already carried the attribute and the send side lacked it by omission; adding it after publication is itself breaking, so it is taken now while the crate has no external consumers. Whether a producer can *wait* for room stays open as [M32.3](../../CHECKLIST-io-domains.md) -- it is additive, so it does not gate the release -- but the absence is stated in both the crate docs and the README, because `crossbeam-channel`'s `send` blocks and a reader arriving from it will assume this one does too. | ## D-2: capabilities are sliced, not gathered diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 5c0c9096..5f30d55f 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -44,7 +44,11 @@ //! What the shapes have in common is described by the [capability //! traits](traits) -- [`Producer`], [`Consumer`], [`Bounded`], [`Waitable`], //! [`Reserving`] -- each naming one thing a queue can do, so a caller can be -//! generic over exactly what it needs and nothing more. +//! generic over exactly what it needs and nothing more. [`Claim`] is the one +//! that is not a queue capability: it describes the *reservation* a +//! [`Reserving`] queue hands out, and is what lets a generic caller redeem one +//! rather than only drop it. Bring it into scope to call `send` on a claim +//! whose concrete type you have not named. //! //! # Waiting is one-directional: a producer cannot park on a full queue //! @@ -246,7 +250,7 @@ pub mod traits; pub use disposal::Disposal; pub use error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; pub use options::Options; -pub use traits::{Bounded, Consumer, Drain, Observable, Producer, Reserving, Waitable}; +pub use traits::{Bounded, Claim, Consumer, Drain, Observable, Producer, Reserving, Waitable}; /// Pads and aligns a value onto its own cache line. /// diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 3e61c41f..4469dc56 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -1081,6 +1081,18 @@ impl crate::Producer for Producer { } } +impl crate::Claim for Reservation { + type Item = T; + + fn send(self, item: T) -> Result<(), Disconnected> { + Self::send(self, item) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } +} + impl crate::Reserving for Producer { type Item = T; type Reservation<'a> diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 543e81d2..e79d16c9 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -894,6 +894,18 @@ impl crate::Consumer for Consumer { } } +impl crate::Claim for Reservation<'_, T> { + type Item = T; + + fn send(self, item: T) -> Result<(), Disconnected> { + Self::send(self, item) + } + + fn is_disconnected(&self) -> bool { + Self::is_disconnected(self) + } +} + impl crate::Reserving for Producer { type Item = T; type Reservation<'a> diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs index 3f25f48d..61d0c619 100644 --- a/crates/windows-waitable-queues/src/traits.rs +++ b/crates/windows-waitable-queues/src/traits.rs @@ -44,7 +44,7 @@ use std::io; use std::os::windows::io::{BorrowedHandle, OwnedHandle}; -use crate::error::PushError; +use crate::error::{Disconnected, PushError}; /// The writing end of a queue. pub trait Producer { @@ -148,6 +148,33 @@ pub trait Bounded { self.capacity().saturating_sub(self.len()) } } +/// A claimed slot, which is redeemed or released but never ignored. +/// +/// Bound onto [`Reserving::Reservation`] so a caller generic over the trait can +/// actually *discharge* what it claims. Without it `reserve` hands back a value +/// with no usable operations: it can be dropped, and nothing else. +pub trait Claim { + /// What the queue this claim came from carries. + type Item; + + /// Delivers into the reserved slot. + /// + /// Consumes the claim, because the slot it names is used exactly once. + /// + /// # Errors + /// + /// [`Disconnected`] if every consumer has gone, carrying the item back so a + /// caller can account for it rather than losing it. + fn send(self, item: Self::Item) -> Result<(), Disconnected>; + + /// Whether every consumer is gone. + /// + /// Offered on the claim itself, not only on the producer that made it: a + /// reservation may outlive the moment the producer was last consulted, and + /// [`reserving_mpsc`](crate::reserving_mpsc)'s is [`Send`], so it may be + /// redeemed on a thread holding no producer handle to ask. + fn is_disconnected(&self) -> bool; +} /// A producer that can claim a slot in advance, so that a later delivery cannot /// be refused for want of room. @@ -199,7 +226,7 @@ pub trait Reserving { /// there the producer handle *is* the single-producer guarantee: an owned /// reservation could outlive it on another thread, and then two threads /// would be writing the ring. - type Reservation<'a> + type Reservation<'a>: Claim where Self: 'a; diff --git a/crates/windows-waitable-queues/src/traits/tests.rs b/crates/windows-waitable-queues/src/traits/tests.rs index aec189e4..cbb7de09 100644 --- a/crates/windows-waitable-queues/src/traits/tests.rs +++ b/crates/windows-waitable-queues/src/traits/tests.rs @@ -35,7 +35,7 @@ //! and fails an inherent method that does, and does not care which one broke. use crate::{ - Bounded, Consumer, Observable, Options, Producer, PushError, Waitable, reserving_mpsc, + Bounded, Claim, Consumer, Observable, Options, Producer, PushError, Waitable, reserving_mpsc, slotwise_mpsc, spsc, }; @@ -585,3 +585,57 @@ fn every_shape_counts_a_doorbell_ring_that_actually_happened() { assert!(rings(&slot_tx) >= 1); assert!(rings(&res_tx) >= 1); } + +#[test] +fn a_generic_caller_can_claim_check_and_redeem() { + // **The whole point of the bound.** This function names no concrete shape + // and still completes the operation `Reserving` exists for. Without the + // bound on `Reservation<'a>` it does not compile at all: `reserve` hands + // back a value whose only available operation is `drop`. + fn claim_and_send

(producer: &P, item: u32) -> Result<(), crate::Disconnected> + where + P: crate::Reserving, + { + let claim = producer.reserve().expect("a fresh queue has room"); + assert!(!claim.is_disconnected(), "the consumer is still there"); + claim.send(item) + } + + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + claim_and_send(&spsc_tx, 7).expect("delivery must succeed"); + assert_eq!(spsc_rx.pop(), Some(7)); + + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + claim_and_send(&res_tx, 9).expect("delivery must succeed"); + assert_eq!(res_rx.pop(), Some(9)); + + // And the failure path is reachable generically too, which it was not + // before: a claim held past the consumer's exit reports it and hands the + // item back rather than losing it. + // + // **Both shapes, and both answers.** Asserting the connected case above and + // the disconnected case on only one shape leaves an `is_disconnected` stuck + // at `false` alive on the other -- which a mutation run found, and which is + // the reading that loses an item, since a caller checking it would deliver + // into a queue nobody will ever drain. + fn claim_survives_the_consumer

(producer: &P, item: u32) + where + P: crate::Reserving, + { + let claim = producer.reserve().expect("a fresh queue has room"); + assert!( + claim.is_disconnected(), + "the consumer is already gone, and the claim must say so" + ); + let returned = claim.send(item).expect_err("no consumer remains"); + assert_eq!(returned.into_inner(), item, "the item must come back"); + } + + let (spsc_tx, spsc_rx) = spsc::bounded::(4).expect("4 is valid"); + drop(spsc_rx); + claim_survives_the_consumer(&spsc_tx, 11); + + let (res_tx, res_rx) = reserving_mpsc::bounded::(4).expect("4 is valid"); + drop(res_rx); + claim_survives_the_consumer(&res_tx, 13); +} From 6dcb04bdc79d56905702e50803f28a6a08871431 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 14:16:46 -0400 Subject: [PATCH 143/361] test(waitable-queues): prove the blocking wait waits, and close the packing ceiling The last four survivors of a full `cargo mutants` run on this crate. Two were real gaps and two are provably equivalent; the pair is recorded so the next run does not re-investigate them. `blocking::wait` replaced by `Ok(())` survived the whole suite, because it changes no answer: the receive loop re-checks the queue, the disconnection and the deadline itself, so every item is still delivered and every timeout still honoured. What stops is the sleeping -- the loop becomes a spin that re-arms the doorbell, a `ResetEvent` syscall per turn, for the whole of the caller's budget. Counted rather than timed: a fake `Parked` that is never ready reports 2 turns in 150ms with the real wait and 1,774,184 with the mutant, and a count does not depend on how loaded the host is the way a CPU-time reading would. `MAX_RESERVED`'s shift reversed still satisfied every existing const assertion, because a wider-than-a-word ceiling passes a `<=` against it. The count is read back out through a cast to `u32`, so a ceiling the word can hold but the cast cannot is a silent truncation; asserting that closes it at compile time, which is the instrument this module already argues for on facts about constants. Verified by sabotage in both directions -- the mutation compiles cleanly without the new assertion and fails to compile with it. Equivalent, and documented in place rather than chased: `record_depth`'s `>` against `>=` differs only by one idempotent `fetch_max`, and `claim_word`'s `|` against `^` cannot differ at all because the shift leaves the halves disjoint. The crate now reports no actionable missed mutants: 125 caught, 4 missed, all four addressed above. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/blocking/tests.rs | 82 +++++++++++++++++++ crates/windows-waitable-queues/src/metrics.rs | 8 ++ .../src/reserving_mpsc.rs | 13 +++ 3 files changed, 103 insertions(+) diff --git a/crates/windows-waitable-queues/src/blocking/tests.rs b/crates/windows-waitable-queues/src/blocking/tests.rs index fd906824..c1a5673c 100644 --- a/crates/windows-waitable-queues/src/blocking/tests.rs +++ b/crates/windows-waitable-queues/src/blocking/tests.rs @@ -246,3 +246,85 @@ fn every_shape_reports_disconnection_and_pops_through_parked() { assert!(pop_and_disconnection(&slot_rx, None)); assert!(pop_and_disconnection(&res_rx, None)); } + +// That the wait actually waits. +// +// # Why a fake shape and a count, rather than a real queue and a clock +// +// `wait` is the one step in the loop whose removal changes no answer. A `wait` +// that returned immediately still delivers every item, still reports every +// disconnection, and still honours every deadline -- because the loop re-checks +// all three itself. What it stops doing is *sleeping*: the loop becomes a spin +// that re-arms the doorbell, which is a `ResetEvent` syscall per turn, for the +// whole of the caller's timeout. A mutation run found exactly this, with the +// suite green. +// +// Measuring CPU time would be the direct reading and the wrong instrument: the +// answer would then depend on how loaded the machine is, and a spin on an +// oversubscribed box can look like a sleep. Counting the loop's turns is the +// same evidence without the dependency -- a real wait comes round about twice +// however busy the host is, and a spin comes round thousands of times. + +/// A shape that is permanently empty and permanently connected. +/// +/// It never has an item and never disconnects, so the receive loop can only +/// leave by its deadline -- which makes the turn count a reading of the wait +/// and nothing else. +struct NeverReady { + /// A real event, never signalled, so the wait is a real kernel wait. + doorbell: crate::doorbell::Doorbell, + /// How many times the loop came round. + turns: std::sync::atomic::AtomicUsize, +} + +impl Parked for NeverReady { + type Item = u32; + + fn pop(&self) -> Option { + self.turns + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + None + } + + fn finish(&self) -> Option { + None + } + + fn arm(&self) -> std::io::Result { + Ok(true) + } + + fn is_disconnected(&self) -> bool { + false + } + + fn doorbell(&self) -> std::io::Result> { + self.doorbell.handle() + } +} + +#[test] +fn a_timed_receive_sleeps_out_its_budget_instead_of_spinning_through_it() { + let consumer = NeverReady { + doorbell: crate::doorbell::Doorbell::new(), + turns: std::sync::atomic::AtomicUsize::new(0), + }; + + let timeout = Duration::from_millis(150); + let outcome = super::recv_timeout(&consumer, timeout); + assert!( + matches!(outcome, Err(crate::RecvTimeoutError::Timeout)), + "nothing was ever pushed, so the only way out is the deadline" + ); + + // Two turns is the honest count -- pop, arm, wait the whole budget, then + // pop, arm, and find nothing left to wait for. The ceiling is loose enough + // that a wait returning a little early cannot fail it, and tight enough + // that a wait returning *immediately* cannot pass it: at a hundred and + // fifty milliseconds of spinning, the count runs to five figures. + let turns = consumer.turns.load(std::sync::atomic::Ordering::Relaxed); + assert!( + turns <= 16, + "the loop came round {turns} times in {timeout:?}, which is a spin rather than a wait" + ); +} diff --git a/crates/windows-waitable-queues/src/metrics.rs b/crates/windows-waitable-queues/src/metrics.rs index 137eeb0d..44c9c50b 100644 --- a/crates/windows-waitable-queues/src/metrics.rs +++ b/crates/windows-waitable-queues/src/metrics.rs @@ -101,6 +101,14 @@ impl Metrics { let Some(high_water) = self.high_water.as_ref() else { return; }; + // `>` rather than `>=`, and a mutation run will report the two as + // indistinguishable -- correctly. `fetch_max(depth)` when `depth` + // already equals the maximum stores the value it read, so the weaker + // test only buys an extra read-modify-write on the shared line in the + // one case it admits. That is the cost this guard exists to avoid, so + // the difference is real; it is just not a difference in any answer, + // and no test can be written for it. Left documented rather than + // chased. if depth > high_water.load(Ordering::Relaxed) { high_water.fetch_max(depth, Ordering::Relaxed); } diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 4469dc56..8fc00bc8 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -152,6 +152,12 @@ const _: () = { "the reservation count is read out as a u32, so a field wider than 32 bits would be \ truncated on the way out" ); + assert!( + MAX_RESERVED <= u32::MAX as u64, + "the count is read back out through `reserved_of`'s cast to `u32`, so a field the word \ + could hold but the cast could not would make this constant's name a lie -- and the \ + assertion below would then be satisfied by a ceiling that truncates on the way out" + ); assert!( BOUNDS_MAX as u64 <= MAX_RESERVED, "every slot may be reserved at once, so the count's half of the word must be able to hold \ @@ -180,6 +186,13 @@ const fn reserved_of(word: u64) -> u32 { } /// Builds a claim word from its two halves. +/// +/// The `|` could equally be `^`, or `+`, and a mutation run will report as much. +/// The halves are disjoint by construction -- the shift clears every bit the +/// position occupies -- so all three agree on every input, and no test can tell +/// them apart. `|` is kept because it says "these are separate fields" where the +/// others say "these are numbers"; the equivalence is recorded here so it is not +/// investigated again. const fn claim_word(reserved: u32, position: u32) -> u64 { ((reserved as u64) << POSITION_BITS) | position as u64 } From 6bb6801c1afb23992a2661e421b60d22d8aca2d9 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 14:19:51 -0400 Subject: [PATCH 144/361] test(waitable-queues): reach the consumer's own outstanding-reservation count `Consumer::outstanding_reservations` could return a constant 0 or a constant 1 with the suite green. Every existing assertion is on the *producer's* accessor; the consumer's is a second method reading the same word, and nothing called it -- the same shape of gap as the trait forwarders, an accessor that exists because it is a different question and is then never asked. The test asserts the state the method's own doc comment names as its reason for existing: an empty queue that is nonetheless not idle, because a slot has been promised. Two reservations rather than one, since a count only ever asserted at one is satisfied by a method that always answers one, and zero on a fresh queue, which is what rules out the other constant. Both mutants re-injected and confirmed to fail the new test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/reserving_mpsc/tests.rs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index 8a3d1837..e81aa9b4 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -188,6 +188,62 @@ fn dropping_a_reservation_returns_the_slot() { ); } +#[test] +fn the_consumer_can_see_that_something_was_promised_even_with_nothing_queued() { + // The consumer's own `outstanding_reservations`, which is a *second* + // accessor rather than a view of the producer's -- and one no test reached, + // so a mutation run found it could return a constant. The distinction it + // exists to draw is in the name: a drained queue with a reservation + // outstanding is not an idle one, and a consumer deciding whether to park + // has to be able to tell the two apart. + let (tx, rx) = bounded::(8).expect("8 is a valid capacity"); + + assert_eq!( + rx.outstanding_reservations(), + 0, + "a fresh queue has promised nothing" + ); + + // Two, not one: a count asserted only at one is satisfied by a method that + // always answers one. + let first = tx.reserve().expect("room"); + let second = tx.reserve().expect("room"); + assert_eq!(rx.outstanding_reservations(), 2); + assert_eq!( + rx.outstanding_reservations(), + tx.outstanding_reservations(), + "the two handles read the same claim word, so they cannot disagree" + ); + + // The state the method is for: nothing to pop, and yet not idle. + assert!(rx.is_empty(), "nothing has been sent"); + assert_eq!( + rx.outstanding_reservations(), + 2, + "an empty queue with two slots promised is waiting, not finished" + ); + + first.send(7).expect("the room was ours"); + assert_eq!( + rx.outstanding_reservations(), + 1, + "one redeemed, one still out" + ); + assert_eq!(rx.pop(), Some(7)); + assert_eq!( + rx.outstanding_reservations(), + 1, + "and taking the item does not release the *other* promise" + ); + + drop(second); + assert_eq!( + rx.outstanding_reservations(), + 0, + "a dropped reservation is a promise withdrawn" + ); +} + #[test] fn a_redeemed_reservation_does_not_also_release_its_slot() { // The double-release bug this shape's `send` avoids by consuming `self` and From c37b9984f7c33bb106ebc21f5d858919c3ffa837 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 14:31:41 -0400 Subject: [PATCH 145/361] chore(tools): wrap cargo-mutants so a crashing mutant cannot wedge the run A mutant that produces memory unsafety does not merely fail a test here -- it kills the process, and Windows Error Reporting puts up a modal Application Error box that stops the whole sweep until somebody clicks OK. That is what was happening when the watcher.rs run appeared to stall. I had checked for this earlier and concluded it was not crashing, by scanning the per-mutant logs for STATUS_ACCESS_VIOLATION and friends. That check could not have worked: WER intercepts the crash into a dialog, so nothing reaches the log for a scan to find, and the run simply stops advancing. The visible symptom is indistinguishable from slowness, which is why it took a human noticing the dialog to identify it. The crash is real rather than an artifact of mutation. Inverting the ERROR_IO_PENDING check in arm_detailed_read makes a genuinely-pending read look failed, so the thread pool cancels its accounting for an I/O the kernel is still going to complete, and the completion lands in a freed buffer. The jump to address zero is that use-after-free surfacing. tools/run-mutants.ps1 sets DontShowUI for the duration and restores it in a finally, so a crashing mutant is just a non-zero exit code and gets recorded as caught. It also fixes the two settings that have each already cost a run: the features are passed on both sides of --, and output goes under .scratch/ so a run cannot overwrite a previous one's results in the repository root (which has already lost one analysis). Writing it reproduced a third instance of the class of bug this session keeps finding: reading the 'is DontShowUI already set?' value with Get-ItemProperty -Name throws under Set-StrictMode when the property is absent, which is its default state -- so the wrapper died on the line that existed to make it safe. Uses .GetValue(name, \) instead, and the set/restore cycle is verified against a machine where the value starts absent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/run-mutants.ps1 | 117 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tools/run-mutants.ps1 diff --git a/tools/run-mutants.ps1 b/tools/run-mutants.ps1 new file mode 100644 index 00000000..d9e1dcf7 --- /dev/null +++ b/tools/run-mutants.ps1 @@ -0,0 +1,117 @@ +# Copyright (c) Mike Grier. +<# +.SYNOPSIS + Runs cargo-mutants with the settings this workspace needs, and without a + crashing mutant wedging the run behind a modal dialog. + +.DESCRIPTION + Three things go wrong when cargo-mutants is invoked directly here, and this + wrapper exists because each one has already cost a run. + + **A crashing mutant pops a Windows Error Reporting dialog.** Some mutants do + not merely fail a test, they produce genuine memory unsafety -- inverting + the `ERROR_IO_PENDING` check in `arm_detailed_read` makes the thread pool + cancel its accounting for an I/O the kernel is still going to complete, so + the completion lands in a freed buffer. The process dies at address zero, + WER shows a modal "Application Error" box, and the run stops dead waiting + for a human to click OK. `--timeout` does not save it: the process is alive, + blocked on a dialog, and the crash never reaches the log as a signature that + a scan would find. This wrapper sets `DontShowUI` for the duration and puts + it back afterwards, so a crash is just a non-zero exit code and cargo-mutants + records it as caught. + + **Features must be passed on both sides.** cargo-mutants mutates source, so + it happily mutates a module behind a feature that is off -- the mutation + lands in code that is never compiled, the suite passes trivially, and the + result is recorded as `missed`. Measured here: 57 of 61 survivors in one + crate and 147 of 247 in another were this and nothing else. + + **`-j 2`, not more.** This workspace has timing-sensitive tests; under heavy + parallel load one can fail for want of a CPU rather than because it detected + the mutant, which cargo-mutants records as a *false* caught. + +.PARAMETER Package + Crate to mutate. Defaults to the file-watcher, the crate this was built for. + +.PARAMETER File + Repository-relative source file to scope to. Strongly recommended: a + whole-crate sweep here takes hours, where one file takes about fifteen + minutes and gives a result you can act on and re-verify the same day. + +.PARAMETER Jobs + Parallel jobs. See above before raising it. + +.PARAMETER TimeoutSeconds + Per-mutant test timeout. + +.PARAMETER OutputDirectory + Where to write `mutants.out`. Defaults under `.scratch/`, so a run never + overwrites a previous run's results in the repository root -- which has + already lost one analysis. + +.EXAMPLE + .\tools\run-mutants.ps1 -File crates/windows-file-watcher/src/watcher.rs +#> +[CmdletBinding()] +param( + [string] $Package = 'windows-file-watcher', + [string] $File, + [int] $Jobs = 2, + [int] $TimeoutSeconds = 120, + [string] $OutputDirectory +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$repo = (git rev-parse --show-toplevel).Replace('/', '\') +if (-not $OutputDirectory) { + $leaf = if ($File) { [System.IO.Path]::GetFileNameWithoutExtension($File) } else { $Package } + $OutputDirectory = Join-Path $repo ".scratch\mutants-$leaf" +} + +$werKey = 'HKCU:\Software\Microsoft\Windows\Windows Error Reporting' +$hadKey = Test-Path $werKey +# `.GetValue(name, $null)` rather than `Get-ItemProperty -Name`: under +# `Set-StrictMode -Version Latest` the latter throws when the property is absent +# (which is the default state of this one), so the wrapper died before it could +# do anything -- reading the "is it already set?" question was itself the bug. +$previous = if ($hadKey) { (Get-Item $werKey).GetValue('DontShowUI', $null) } else { $null } + +try { + if (-not $hadKey) { New-Item -Path $werKey -Force | Out-Null } + Set-ItemProperty -Path $werKey -Name DontShowUI -Value 1 -Type DWord + Write-Host "WER dialogs suppressed for this run (DontShowUI=1)." -ForegroundColor Cyan + + $argv = @('mutants', '-p', $Package, '-j', $Jobs, '--timeout', $TimeoutSeconds, + '--output', $OutputDirectory) + if ($File) { $argv += @('--file', $File) } + # After `--`: passed through to `cargo test`. Both halves need the features. + $argv += @('--', '--all-features') + + Write-Host "cargo $($argv -join ' ')" -ForegroundColor DarkGray + & cargo @argv + $code = $LASTEXITCODE +} +finally { + if ($null -ne $previous) { + Set-ItemProperty -Path $werKey -Name DontShowUI -Value $previous -Type DWord + } + else { + Remove-ItemProperty -Path $werKey -Name DontShowUI -ErrorAction SilentlyContinue + if (-not $hadKey) { Remove-Item -Path $werKey -ErrorAction SilentlyContinue } + } + Write-Host "WER dialog setting restored." -ForegroundColor Cyan +} + +$out = Join-Path $OutputDirectory 'mutants.out' +foreach ($name in 'caught', 'missed', 'timeout', 'unviable') { + $path = Join-Path $out "$name.txt" + $count = if (Test-Path $path) { (Get-Content $path | Measure-Object -Line).Lines } else { 0 } + "{0,-9} {1}" -f $name, $count +} +Write-Host "results: $out" -ForegroundColor DarkGray + +# cargo-mutants exits non-zero when anything survived, which is the normal +# outcome of an investigative run rather than a failure of this script. +exit $code From 608d6714153b1937bfe3df839dcbe87096dcf1a1 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 14:39:28 -0400 Subject: [PATCH 146/361] test(file-watcher): cover the notification categories nothing exercised ALL_NOTIFY_FILTERS is seven flags OR'd together, and cargo-mutants replaces one '|' with '&' at a time. Because & binds tighter than |, each such mutant zeroes the two flags on either side of it -- silently dropping a whole category of change from what the kernel is asked to report. Which of those survived says precisely what the suite covered and what it did not. The FILE_NAME and DIR_NAME mutants were caught: files appearing, disappearing and being renamed are well tested. The rest survived, because nothing here ever changed a file that already existed and kept its name. A watcher that reports creation and deletion but silently never reports a write is a plausible defect, and until now nothing would have noticed. Closed here: a write to an existing file, and an attribute change, are each reported as Modified. That kills the ATTRIBUTES+SIZE mutant. Also asserts DEFAULT_BUFFER_BYTES, whose '64 * 1024' survived becoming '64 + 1024' -- a 1088-byte buffer is still ample for the handful of records a test produces, but not for the burst the constant's own doc comment says it is sized for. It is pub, so the number is part of the published surface rather than an internal detail, which is what makes asserting it more than circular. NOT closed, and recorded rather than papered over. Two mutants remain, and two tests written to close them do not. A same-length rewrite was meant to isolate LAST_WRITE and does not: writing also sets the archive bit, so ATTRIBUTES reports it instead. A DACL edit via icacls was meant to isolate SECURITY and does not either, through a filter this exercise did not identify -- the assumption that a permission change touches nothing else is exactly what the test disproved. Both are kept with their claims corrected, because each asserts a real user-visible behaviour on its own terms, and M15.4 records what would actually isolate the two categories (a timestamp-only SetFileTime; and first establishing which filter reports a DACL edit, rather than guessing again). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 18 ++ .../windows-file-watcher/src/watcher/tests.rs | 180 ++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 4f39ca73..98045559 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -197,6 +197,24 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- covers only the junction case. Silence is the one option that leaves a caller to discover the limit from a `NotFound` that names nothing. +- [ ] **M15.4** -- Isolate the last two notification-filter categories, or record that they cannot be + isolated from outside. Two mutants in `ALL_NOTIFY_FILTERS` survive: replacing the `|` before + `FILE_NOTIFY_CHANGE_CREATION` (dropping LAST_WRITE and CREATION) and before + `FILE_NOTIFY_CHANGE_SECURITY` (dropping CREATION and SECURITY). `&` binds tighter than `|`, so each + such mutant zeroes the two flags on either side of it. + **Why the obvious tests do not catch them, measured rather than assumed.** ATTRIBUTES and SIZE remain + present in both mutants, and they mask the rest: a same-length rewrite still sets the file's archive + bit, so ATTRIBUTES reports it; and a DACL edit via `icacls` is likewise still reported with SECURITY + dropped, through some filter this exercise did not identify. Both tests were written expecting to + isolate a category, both failed to, and both are kept with their claims corrected rather than deleted. + **What would work.** For LAST_WRITE, a timestamp-only change -- `SetFileTime` on an already-open + handle, touching neither length nor attributes. For SECURITY, first establish *which* filter currently + reports a DACL edit (arm a watch with a single filter bit at a time and see which one fires), because + the assumption that it touches nothing else is exactly what the failed test disproved. + **A legitimate outcome is "cannot be isolated".** If every operation that changes one of these also + changes an attribute or a length, then no black-box test can distinguish the mutants, and they belong + with the equivalent ones rather than on this list. Establishing that is as good an answer as a test. + ## M-inf -- Horizon (ungated, post-v1) Parked, not pending. These are the deferred seams recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> D-19, diff --git a/crates/windows-file-watcher/src/watcher/tests.rs b/crates/windows-file-watcher/src/watcher/tests.rs index 0b83bc35..f7f4dd45 100644 --- a/crates/windows-file-watcher/src/watcher/tests.rs +++ b/crates/windows-file-watcher/src/watcher/tests.rs @@ -1662,3 +1662,183 @@ fn stopping_a_volume_change_removes_only_that_route() { drop(watcher); dir.cleanup(); } + +// --- the notification filter's less-travelled categories (mutation gap) --- +// +// `ALL_NOTIFY_FILTERS` is seven flags OR'd together. Replacing one `|` with `&` +// zeroes the two flags on either side of it -- `A | B & C | D` parses as +// `A | (B & C) | D`, and disjoint flags AND to nothing -- so each such mutant +// silently drops a whole category of change from what the kernel is asked to +// report. +// +// Three of those survived: the ones dropping ATTRIBUTES+SIZE, +// LAST_WRITE+CREATION and CREATION+SECURITY. The mutants dropping FILE_NAME and +// DIR_NAME were caught, which says exactly what the suite covered -- files +// appearing, disappearing and being renamed -- and what it did not: anything +// that happens to a file that already exists and keeps its name. +// +// That is a real gap rather than a curiosity. A watcher that reports creation +// and deletion but silently never reports a write is a plausible defect, and +// until now nothing here would have noticed. +// +// The tests below close the ATTRIBUTES+SIZE one. **They do not close the other +// two**, and that is recorded rather than papered over: ATTRIBUTES and SIZE stay +// present in both of those mutants, and ordinary file operations set the archive +// bit or change the length, so those two filters mask the dropped ones. Catching +// LAST_WRITE needs a timestamp-only change; catching SECURITY needs an operation +// that provably touches nothing else, which a DACL edit turns out not to be. See +// M15.4. + +#[test] +fn writing_to_an_existing_file_is_reported_as_modified() { + // Covers FILE_NOTIFY_CHANGE_SIZE and FILE_NOTIFY_CHANGE_LAST_WRITE: the + // file already exists and keeps its name, so neither of the name filters + // can account for this notification. + let dir = TempDir::new("modified-write"); + std::fs::write(dir.path().join("existing.txt"), b"first").expect("seed the file"); + + let (watcher, collected) = watch(dir.path(), false); + + std::fs::write(dir.path().join("existing.txt"), b"second, and longer").expect("rewrite"); + + collected.wait_until("a Modified for existing.txt", |d| { + d.changes() + .iter() + .any(|(kind, name)| *kind == ChangeKind::Modified && name == "existing.txt") + }); + + drop(watcher); + dir.cleanup(); +} + +#[test] +fn changing_an_existing_files_attributes_is_reported_as_modified() { + // Covers FILE_NOTIFY_CHANGE_ATTRIBUTES. The file's name, size and contents + // are untouched, so this is the one category that can report it. + let dir = TempDir::new("modified-attrs"); + let path = dir.path().join("attrs.txt"); + std::fs::write(&path, b"x").expect("seed the file"); + + let (watcher, collected) = watch(dir.path(), false); + + let mut permissions = std::fs::metadata(&path) + .expect("read metadata") + .permissions(); + permissions.set_readonly(true); + std::fs::set_permissions(&path, permissions).expect("mark read-only"); + + collected.wait_until("a Modified for attrs.txt", |d| { + d.changes() + .iter() + .any(|(kind, name)| *kind == ChangeKind::Modified && name == "attrs.txt") + }); + + // Clear it again so the temp directory can be removed. + let mut permissions = std::fs::metadata(&path) + .expect("read metadata") + .permissions(); + // clippy warns that this makes a file world-writable *on Unix*. This crate + // is entirely `cfg(windows)`, where the call clears the read-only attribute + // and nothing more -- which is exactly what the cleanup below needs. + #[allow(clippy::permissions_set_readonly_false)] + permissions.set_readonly(false); + std::fs::set_permissions(&path, permissions).expect("clear read-only"); + + drop(watcher); + dir.cleanup(); +} + +#[test] +fn the_default_buffer_is_the_documented_size() { + // `64 * 1024` survived being changed to `64 + 1024`, which would leave a + // 1088-byte buffer: still large enough for the handful of records a test + // produces, so nothing noticed, but small enough to overflow under the + // burst this constant's doc comment says it is sized for. + // + // Asserting a constant's value is usually circular. It is not here: this is + // `pub`, so the number is part of the crate's published surface, and the + // doc comment two lines above it states the size in prose that a reader is + // entitled to trust. + assert_eq!( + super::DEFAULT_BUFFER_BYTES, + 64 * 1024, + "the documented default is 64 KiB" + ); +} + +#[test] +fn rewriting_a_file_without_changing_its_length_is_reported_as_modified() { + // A rewrite that leaves the length alone, so this cannot be reported + // through FILE_NOTIFY_CHANGE_SIZE. + // + // It was written to isolate FILE_NOTIFY_CHANGE_LAST_WRITE and it does not: + // dropping that flag still leaves this green, because writing to a file + // also sets its archive bit and FILE_NOTIFY_CHANGE_ATTRIBUTES reports the + // change instead. Isolating last-write needs a change that touches only the + // timestamp -- a `SetFileTime` call rather than a write. Kept anyway, + // because a same-length rewrite being reported at all is worth asserting + // and nothing else here does it. + let dir = TempDir::new("modified-same-size"); + let path = dir.path().join("fixed-size.txt"); + std::fs::write(&path, b"aaaa").expect("seed the file"); + + let (watcher, collected) = watch(dir.path(), false); + + std::fs::write(&path, b"bbbb").expect("rewrite at the same length"); + + collected.wait_until("a Modified for fixed-size.txt", |d| { + d.changes() + .iter() + .any(|(kind, name)| *kind == ChangeKind::Modified && name == "fixed-size.txt") + }); + + drop(watcher); + dir.cleanup(); +} + +#[test] +fn changing_a_files_permissions_is_reported_as_modified() { + // A DACL edit, which is a change no other test here makes. + // + // It was written to isolate FILE_NOTIFY_CHANGE_SECURITY and it does not: + // dropping that flag leaves this green, so the notification is arriving + // through one of the filters that remain. Which one is not established -- + // the assumption that a DACL edit touches nothing else is what this + // disproves, and guessing a replacement would repeat the error. + // + // Kept anyway: "a permission change is reported to a watcher" is a + // user-visible behaviour worth asserting on its own terms, whatever filter + // delivers it. + // + // `icacls` rather than a Win32 call, because the point is to make a real + // security-descriptor change and observe that the watch reports it, not to + // exercise any particular way of making one. + let dir = TempDir::new("modified-acl"); + let path = dir.path().join("acl.txt"); + std::fs::write(&path, b"x").expect("seed the file"); + + let (watcher, collected) = watch(dir.path(), false); + + // *S-1-1-0 is the well-known Everyone SID, named by SID so this does not + // depend on the machine's language. + let granted = std::process::Command::new("icacls.exe") + .arg(path.as_os_str()) + .args(["/grant", "*S-1-1-0:(R)"]) + .output() + .expect("run icacls"); + assert!( + granted.status.success(), + "could not change the file's DACL, so the security half of the filter \ + went untested: {}", + String::from_utf8_lossy(&granted.stderr) + ); + + collected.wait_until("a Modified for acl.txt", |d| { + d.changes() + .iter() + .any(|(kind, name)| *kind == ChangeKind::Modified && name == "acl.txt") + }); + + drop(watcher); + dir.cleanup(); +} From 9b4be9389a4a9c84a1c0ff1c8364911b673037b4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 14:41:01 -0400 Subject: [PATCH 147/361] docs(waitable-queues): record why the lost-race branch reads as removable The last survivor of the crate's mutation run, and the only one of the three equivalents whose equivalence is not obvious from the line itself. Reversing the comparison makes the branch dead, because the negative case has already returned; a stale position then falls through to the compare-exchange, which fails for exactly the reason the branch existed and hands back the same tail it would have loaded. Measured rather than argued, because a concurrency claim reasoned from the source is the kind this crate has been wrong about before: the mutant was re-injected and the two many-producer tests -- including the capacity-two one, where every producer contends on every push -- run thirty times without losing or duplicating an item. Kept anyway. The cost is a failed read-modify-write on the one line every producer touches, on the contended path, where the branch pays a load; and the comment says "somebody got in" at the place it happens rather than leaving it to be re-derived from an exchange that fails for a reason nothing states. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-waitable-queues/src/slotwise_mpsc.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index e579501c..74d629e4 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -429,6 +429,22 @@ impl Producer { // Another producer claimed this position between the load of // the tail and now. Re-read rather than incrementing blindly: // several producers may have got in. + // + // **A mutation run reports this branch as removable, and it is + // right.** Reversing the comparison makes the branch dead -- + // the negative case returned above -- so a stale position falls + // through to the exchange instead, which fails precisely + // because the position is stale and hands back the very tail + // this branch would have loaded. The two routes end in the same + // place. Kept because the difference is a failed + // read-modify-write on the one line every producer touches, + // taken on the contended path, in exchange for a load; and + // because saying "somebody got in" where it happens is worth + // more than leaving it to be re-derived from an exchange that + // fails for a reason nothing states. Measured rather than + // argued: the mutant survives thirty runs of the two + // many-producer tests, including the capacity-two one, without + // losing or duplicating an item. position = self.shared.tail.0.load(Ordering::Relaxed); continue; } From 7b9bab3606250a0cdd01080e2322bdfcdd5c25ae Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 14:41:57 -0400 Subject: [PATCH 148/361] docs: record what a cargo-mutants timeout means in a crate of blocking APIs A sweep of `windows-waitable-queues` recorded 100 timeouts, which reads as a hundred unknowns. It is not: `cargo test` runs every test as a thread in one process, so one test parked on a queue that will never fill stops the harness reporting, and the whole run is recorded as a timeout even after other tests have already failed. Measured on one of them -- `Parked::arm -> Ok(true)`, run against its own test alone, fails in 0.00s with the message written for exactly that mutation. A timeout here is a detection that has lost its name, not a gap, and writing tests for one is wasted work. Also records the two dispositions that same run needed and the section did not cover: an equivalent mutant is documented in place with the argument for why the forms agree (and measured anyway when the argument is about concurrency), and a surviving fact about constants is better closed by a `const` assertion than by a test, since that fails on the build rather than on a run somebody chose to make. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 47 ++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e89729d1..2f51d193 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -504,9 +504,50 @@ of test -- the run that prompted this section had 79 survivors in trait impls, a single missing idea (nothing exercised the traits generically), and one new test file section killed all of them. -Treat `timeout` and `unviable` separately from `caught`: an unviable mutant did not -compile and says nothing, and a timeout may mean the suite hangs on that mutation rather -than failing on it, which is worth knowing on its own. +**Treat `timeout` and `unviable` separately from `caught`.** An unviable mutant did not +compile and says nothing. A timeout needs interpreting, and in this workspace the +interpretation is usually the opposite of the alarming reading: + +- **In a crate of blocking APIs, a timeout is a detection that has been robbed of its + name.** `cargo test` runs every test as a thread in one process (which is deliberate + here -- see the nextest note in the cargo section above), so a single test parked on a + queue that will never fill stops the *harness* reporting, and the run is recorded as a + timeout even though other tests have already failed. Measured: a + `windows-waitable-queues` sweep recorded 100 timeouts, and the mutant + `>::arm -> Ok(true)` was one of them -- yet run against its + own test alone it fails in **0.00s**, with the message written for exactly that + mutation. The suite cannot pass either way; the timeout only hides which test caught it, + and costs the full auto-timeout to do so. +- So **do not read a timeout as a gap**, and do not go writing tests for one. To find out + what a timeout really is, re-inject that single mutant and run the one test that should + catch it, or use `cargo_test`'s `bisect` to name the thread that parked. +- The auto-timeout is `max(20s, 5x baseline)`, so on a fast suite it is the 20s floor. With + a 0.31s baseline that is a 60x margin, which is worth knowing because it rules out the + other reading: a timeout on this crate cannot be a merely-slower mutant, only a stalled + one. + +**Some survivors are equivalent mutants, and the answer is to document them in place.** +An equivalent mutant changes no observable behaviour, so no test can kill it and looking +for one is wasted effort -- but only the *second* reader knows that, and only if the first +wrote it down. Record the equivalence as a comment at the mutation site, with the argument +for why the two forms agree. Three from one crate, as worked examples: `record_depth`'s +`>` against `>=` differs by one idempotent `fetch_max`; `claim_word`'s `|` against `^` +cannot differ at all, because the shift leaves the halves disjoint; and `slotwise_mpsc`'s +lost-race `continue` reaches the same state as the failing compare-exchange it falls +through to. **Argue it, then measure it anyway** where the claim is about concurrency -- +the third was confirmed by re-injecting the mutant and running the many-producer tests +thirty times, because a concurrency claim reasoned from source is exactly the kind this +workspace has been wrong about before. + +**Where a fact about constants survives, prefer a `const` assertion to a test.** A mutation +to a constant is often invisible to every test while still being a real defect. The +reversed shift in `reserving_mpsc`'s `MAX_RESERVED` passed every existing const assertion, +because a ceiling wider than the word still satisfies a `<=` against it -- and the count is +read back out through a cast to `u32`, so the too-wide ceiling truncates silently. Asserting +that relationship closes it *at compile time*, which is stronger than a test: it cannot be +skipped, and it fails on the build rather than on a run somebody chose to make. Verify such +an assertion in both directions -- the mutation must compile cleanly without it and fail to +compile with it -- or you have not shown it is load-bearing. ## Scratch directory for temporary files From bc28a4f745c4ad09da20675125d948eb4d8447d2 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 14:50:56 -0400 Subject: [PATCH 149/361] fix(tools): put cargo-mutants' features where they actually take effect The recipe this repository has been recommending does not run. cargo-mutants forwards its trailing arguments onto the same `cargo test` it is already building, so `--all-features -- --all-features` puts the flag on one command line twice and cargo refuses it outright -- the run dies in the baseline, before a single mutant is tested. `tools/run-mutants.ps1` avoided that by passing the flag only after `--`, which runs, but its own documentation said both halves needed it; the code and the comment disagreed, and the comment was the one the next reader would have believed. Measured rather than reasoned, by reading the `***` command lines in `mutants.out/log/baseline.log`: cargo-mutants runs two cargo phases, and the flag before `--` lands on both while the flag after `--` lands only on the run. The post-`--` form is therefore not *wrong* -- 315 lib tests either way against 275 with default features, so the features really are on for what gets tested -- but the `--no-run` build is done feature-less and thrown away, and the build/test split cargo-mutants reports times a configuration it never tested. The wrapper now passes it before `--` only, verified end to end: both phases carry it and the baseline runs 315 tests. Also corrects the check the instructions give for confirming features were on. It said to grep the baseline log for `--cfg feature="..."`, which never matches -- cargo quotes the flag, so the literal text is `--cfg "feature=\"scenario-tool\""`, and the old search returns zero hits on a run that had every feature enabled. Reading the `***` lines, or comparing test counts against a local run, are the checks that work. Swept the whole population rather than the site that surfaced it: the fact was stated in exactly two files, both fixed here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 72 ++++++++++++++++++++++++++------- tools/run-mutants.ps1 | 22 ++++++---- 2 files changed, 71 insertions(+), 23 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2f51d193..0ef82ccc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -410,16 +410,32 @@ written against — a decision to raise, not a gap to close in passing. `cargo_nextest_run` and `cargo_nextest_list` remain in the tool table above because the MCP server exposes them; they will fail here until cargo-nextest is installed. -### cargo-mutants — run it with `-j 2`, from the terminal +### cargo-mutants — run it through `tools/run-mutants.ps1` `cargo-mutants` is installed and is **not** exposed by the cargo-mcp server, so it is one of the few cargo commands that legitimately runs in a terminal rather than through a `cargo_*` tool. -**Always pass `-j 2`.** The default is serial, and a mutation run is long enough that the -difference matters: a 125-mutant sweep over this workspace took **3m30s at `-j 2` against -roughly six minutes serially**, with identical results. Two is the recommended value here -rather than "as many as there are cores": +**Run it through [tools/run-mutants.ps1](../tools/run-mutants.ps1) rather than invoking +`cargo mutants` directly.** Each of the settings the wrapper applies is there because +getting it wrong has already cost a run, and three of them are invisible until they have: +it suppresses the Windows Error Reporting dialog that a genuinely-crashing mutant would +otherwise block the whole run behind, it passes the features, and it writes `mutants.out` +under `.scratch/` so a new run cannot overwrite the analysis you have not finished reading. + +```powershell +.\tools\run-mutants.ps1 -Package windows-waitable-queues +.\tools\run-mutants.ps1 -File crates/windows-file-watcher/src/watcher.rs +``` + +The rest of this section is why those settings are what they are, and how to read what +comes back. A direct `cargo mutants` invocation is still fine for a one-off, but then +every paragraph below is yours to apply by hand. + +**Always pass `-j 2`** (the wrapper's default). Serial is the cargo-mutants default, and a +mutation run is long enough that the difference matters: a 125-mutant sweep over this +workspace took **3m30s at `-j 2` against roughly six minutes serially**, with identical +results. Two is the recommended value here rather than "as many as there are cores": - Each job is a full build plus test run of a scratch copy of the tree, so the cost is disk and RAM as much as CPU, and the builds contend for the same target directory @@ -448,7 +464,7 @@ repository, a commit, or a clean checkout exists will fail there and nowhere els such a test by asserting what the build could actually determine rather than by skipping it -- see `windows-placement-probe`'s `build_identity` tests for the worked example. -**Pass the crate's features on BOTH sides, or most of what you find is fiction.** +**Pass the crate's features to cargo-mutants ITSELF, not after `--`, and never both.** cargo-mutants mutates the *source*, so it happily mutates a module gated behind a feature that is switched off -- the mutation lands in code that is never compiled, the suite passes trivially, and the result is recorded as `missed`. It compiles out that module's tests at @@ -461,19 +477,45 @@ reported 61 survivors of which **57 were in `#[cfg(feature = "serde")]` code**, invocation. So: ``` -cargo mutants -p --all-features -- --all-features +cargo mutants -p --all-features ``` -The flag is needed twice because the first governs cargo-mutants' own build and the one -after `--` is passed to `cargo test`. +**An earlier version of this section said the flag was needed on both sides, and that +recipe does not run at all.** cargo-mutants forwards its trailing arguments onto the same +`cargo test` it is already building, so `--all-features -- --all-features` puts the flag on +one command line twice and cargo refuses it outright: +`error: the argument '--all-features' cannot be used multiple times`. The run dies in the +baseline, before a single mutant is tested. + +The two single-sided forms are not equivalent either, and the difference is visible in the +log's `***` command lines. cargo-mutants runs **two** cargo phases -- a `--no-run` build and +then the test run -- and: + +- `--all-features` **before** `--` lands on **both** phases. This is the one to use. +- `--all-features` **after** `--` lands on the run phase **only**, leaving the `--no-run` + build feature-less. The features do end up enabled for what is actually tested (measured + on `windows-file-watcher`: 315 lib tests either way, against 275 with default features), + so results are not wrong -- but the first build is thrown away and rebuilt, and the + build/test split cargo-mutants reports is timing a configuration it did not test. **Verify which features were actually on before trusting a miss**, and do not do it by -eye: `--check-cfg cfg(feature, values("scenario-tool", ...))` appears on every rustc line -and merely *declares which names are valid*, so grepping the baseline log for a feature -name matches whether or not it was enabled. The thing to look for is an explicit -`--cfg feature="..."` flag; its absence means no features were on. Comparing the baseline's -test count against a local `--all-features` run is the quicker check -- 283 against 350 on -the file-watcher was the tell. +eye. Two traps, both measured here: + +- `--check-cfg cfg(feature, values("scenario-tool", ...))` appears on every rustc line and + merely *declares which names are valid*, so grepping the baseline log for a feature name + matches whether or not it was enabled. +- The enabling flag is **not** spelled `--cfg feature="x"` in the log. cargo quotes it, so + the literal text is `--cfg "feature=\"scenario-tool\""` and a search for the unquoted + form returns zero hits on a run that had every feature on. (An earlier version of this + section told you to look for the unquoted form; it never matches.) + +The two checks that do work: + +- Read the `***` lines in `mutants.out/log/baseline.log`. They are the exact cargo command + lines for both phases, so `--all-features` is either there or it is not. +- Compare the baseline's test count against a local run -- the quicker check, and + independent of log formatting. On `windows-file-watcher`, 275 lib tests with default + features against 315 with all of them is the tell. **When a survivor looks alarming, check the gating before reporting it.** A `windows-file-watcher` run showed `ContractChecker::observe -> Ok(())` surviving, which diff --git a/tools/run-mutants.ps1 b/tools/run-mutants.ps1 index d9e1dcf7..48494083 100644 --- a/tools/run-mutants.ps1 +++ b/tools/run-mutants.ps1 @@ -20,11 +20,19 @@ it back afterwards, so a crash is just a non-zero exit code and cargo-mutants records it as caught. - **Features must be passed on both sides.** cargo-mutants mutates source, so - it happily mutates a module behind a feature that is off -- the mutation - lands in code that is never compiled, the suite passes trivially, and the - result is recorded as `missed`. Measured here: 57 of 61 survivors in one - crate and 147 of 247 in another were this and nothing else. + **Features go to cargo-mutants itself, not after `--`, and never both.** + cargo-mutants mutates source, so it happily mutates a module behind a + feature that is off -- the mutation lands in code that is never compiled, + the suite passes trivially, and the result is recorded as `missed`. + Measured here: 57 of 61 survivors in one crate and 147 of 247 in another + were this and nothing else. Passing it on *both* sides does not work at all: + cargo-mutants forwards its trailing arguments onto the same `cargo test`, so + the flag lands twice on one command line and cargo refuses it outright + (`the argument '--all-features' cannot be used multiple times`), killing the + run in the baseline. Passing it only *after* `--` reaches the test run but + not the `--no-run` build, so the first build is thrown away and rebuilt with + features on, and the build/test split cargo-mutants reports times a + configuration it never tested. **`-j 2`, not more.** This workspace has timing-sensitive tests; under heavy parallel load one can fail for want of a CPU rather than because it detected @@ -84,10 +92,8 @@ try { Write-Host "WER dialogs suppressed for this run (DontShowUI=1)." -ForegroundColor Cyan $argv = @('mutants', '-p', $Package, '-j', $Jobs, '--timeout', $TimeoutSeconds, - '--output', $OutputDirectory) + '--output', $OutputDirectory, '--all-features') if ($File) { $argv += @('--file', $File) } - # After `--`: passed through to `cargo test`. Both halves need the features. - $argv += @('--', '--all-features') Write-Host "cargo $($argv -join ' ')" -ForegroundColor DarkGray & cargo @argv From d234749eaf0ec77be5ac5ad9cb0b4dd52ca431d8 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 15:32:38 -0400 Subject: [PATCH 150/361] docs(file-watcher): rule out a shipping bug behind the access violations, and queue the real gap The access violations recur, so the first question was whether they need a mutant at all. They do: 55 runs of the unmutated suite (25 default-feature, 30 --all-features, including the exact binary named in every crash report) produced zero failures, and all sixteen crash reports carry distinct PE timestamps, none equal to the clean build's. Each was its own mutant build. Recorded in M15.5 so the check does not have to be repeated. The test binary's filename comes from the target and its features rather than its contents, so every report names the same .exe whether the build was mutated or not. That is why the name alone proves nothing, and why the PE timestamp is the thing to compare. What does matter is how those crashes were scored. cargo-mutants judges by exit code, so two were counted CaughtMutant purely because the process died -- one with STATUS_HEAP_CORRUPTION, one with STATUS_STACK_BUFFER_OVERRUN. A crash is not a test: detection by memory corruption depends on allocator behaviour and heap layout, and the same ERROR_IO_PENDING mutant was MISSED in one sweep and fatal in another. So the score for this file is non-deterministic, and a crash-caught mutant should be read as uncovered rather than covered. M15.5 asks for the assertion that would make it deterministic: that a read reporting ERROR_IO_PENDING is treated as armed and its completion delivered exactly once, and that a genuinely failed submission is not left accounted-for. Both lessons added to the cargo-mutants guidance, since neither is guessable from the tool's output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 16 ++++++++++++++++ crates/windows-file-watcher/CHECKLIST.md | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0ef82ccc..bef0a68a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -539,6 +539,22 @@ it mutated a *different, tested* line and reported the untested one as caught -- the conclusion. This is the same defect `tools/run-sabotage.ps1` guards with its "pattern found N times, expected 1" check; done by hand, nothing guards it. +**A mutant that crashes is scored `caught`, and that is a weaker claim than it looks.** +cargo-mutants judges by exit code, so a mutant that produces memory unsafety counts as caught +because the process died -- not because a test noticed. Detection then depends on allocator +behaviour and heap layout, so the same mutant can be `caught` in one sweep and `missed` in the +next. Measured in `windows-file-watcher`: sixteen crashes across runs, two scored `CaughtMutant` +via `STATUS_HEAP_CORRUPTION` and `STATUS_STACK_BUFFER_OVERRUN`, and the mutant that had crashed +one run was `missed` in another. Treat a crash-caught mutant as an *uncovered* one: write the +assertion that makes it deterministically red. + +**Before blaming the code, check whether the crash needs a mutant at all.** The test binary's +filename is derived from the target and its features, not its contents, so every crash report +names the same `.exe` whether the build was mutated or not -- the name proves nothing. Two +checks settle it: run the unmutated binary many times (55 runs settled the file-watcher case), +and compare the PE timestamp in each Application Error event against the clean build's. Distinct +timestamps mean distinct builds, i.e. mutants. + **Read the results as a to-do list, not a score.** `mutants.out/missed.txt` is the useful artifact; group it by file and by function to find the shape of the gap rather than fixing mutants one at a time. A large block of survivors usually names one absent *kind* diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 98045559..e11d346d 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -215,6 +215,30 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- changes an attribute or a length, then no black-box test can distinguish the mutants, and they belong with the equivalent ones rather than on this list. Establishing that is as good an answer as a test. +- [ ] **M15.5** -- Assert the arming contract in `arm_detailed_read`, so a broken one fails a test + instead of *sometimes* corrupting the heap. **The shipping code is not at fault here** -- that was + checked before anything else, and the check is recorded below so nobody has to repeat it. + **What is wrong.** Inverting the `ERROR_IO_PENDING` test at `watcher.rs:482` makes a genuinely-pending + read look failed, so the thread pool cancels its accounting for an I/O the kernel is still going to + complete, and the completion lands in a freed buffer. Nothing asserts otherwise, so the only thing + standing between that mutation and a green suite is whether the allocator happens to notice. + **It is not reliable, and that is the point.** The same mutant was recorded `MISSED` in one sweep and + crashed the process in another. Sixteen crashes were logged across the runs, and cargo-mutants counted + two of them as `CaughtMutant` purely because the process exited non-zero -- one with + `STATUS_HEAP_CORRUPTION` (`0xC0000374`), one with `STATUS_STACK_BUFFER_OVERRUN` (`0xC0000409`). **A + crash is not a test.** Detection by memory corruption depends on allocator behaviour and heap layout, + so the mutation score for this file is non-deterministic run to run, and a "caught" here is a weaker + claim than it looks. + **Wanted:** a test that observes the arming *contract* rather than its wreckage -- that a read + reporting `ERROR_IO_PENDING` is treated as armed and its completion delivered exactly once, and that a + genuinely failed submission is not left accounted-for. That makes both the mutant and any future + regression a deterministic red test. + **Ruled out: a defect in the unmutated code.** 55 runs of the unmutated suite (25 default-feature, 30 + `--all-features`, including the exact binary named in every crash report) produced zero failures. All + sixteen crash reports carry distinct PE timestamps, none equal to the clean build's -- each was its own + mutant build. The test binary's filename is derived from the target and features rather than its + contents, which is why every report names the same `.exe` and why that name alone proves nothing. + ## M-inf -- Horizon (ungated, post-v1) Parked, not pending. These are the deferred seams recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> D-19, From 46d2c13aef62894197c114cd0cf4bf968ef8ff36 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 16:04:42 -0400 Subject: [PATCH 151/361] chore(tools): harden the mutants wrapper against the stall, with the costs measured Follow-up on the operational problem rather than the crash: mutation testing is expected to produce memory-unsafe programs, and a crash is a fine outcome. The defect is the operating system's response to one. Measured, so the fix is not taken on faith. With the dialog suppressed, the two mutants that crashed the watcher.rs sweep cost 7.9s and 10.5s and were recorded as CaughtMutant. Without it the run does not merely slow down -- it stops advancing and never resumes, because --timeout bounds the test, not a process sitting on a message pump. The first watcher.rs attempt wedged after 33 mutants and had to be killed by hand. Two dialogs exist and they have different switches, which the docs now say: DontShowUI (HKCU) covers the WER Application Error box, and AeDebug\Auto (HKLM) covers the JIT debugger prompt -- the 'Click on CANCEL to debug the program' line. vsjitdebugger is registered on this machine with Auto unset, meaning prompt; DontShowUI suppressed it in practice across sixteen crashes, but the knob lives in HKLM and needs elevation, so it is recorded as the thing to check if a run ever stalls that way again. Also kills any WerFault or vsjitdebugger left holding a dead test process, since a stale one can pin a target file and fail the next build with a confusing access-denied; and warns when DontShowUI is already set on entry, which is the one hole in the restore -- a hard kill runs no finally, and silently treating the leftover as a preference would make a crash permanently change the machine. Deliberately does not also set WER's Disabled: zero crash dumps were collected during the sweep with DontShowUI on, so there is no report collection left to switch off and the extra write would be unmeasured complexity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/run-mutants.ps1 | 62 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 51 insertions(+), 11 deletions(-) diff --git a/tools/run-mutants.ps1 b/tools/run-mutants.ps1 index 48494083..b2a4c1de 100644 --- a/tools/run-mutants.ps1 +++ b/tools/run-mutants.ps1 @@ -8,17 +8,39 @@ Three things go wrong when cargo-mutants is invoked directly here, and this wrapper exists because each one has already cost a run. - **A crashing mutant pops a Windows Error Reporting dialog.** Some mutants do - not merely fail a test, they produce genuine memory unsafety -- inverting - the `ERROR_IO_PENDING` check in `arm_detailed_read` makes the thread pool - cancel its accounting for an I/O the kernel is still going to complete, so - the completion lands in a freed buffer. The process dies at address zero, - WER shows a modal "Application Error" box, and the run stops dead waiting - for a human to click OK. `--timeout` does not save it: the process is alive, - blocked on a dialog, and the crash never reaches the log as a signature that - a scan would find. This wrapper sets `DontShowUI` for the duration and puts - it back afterwards, so a crash is just a non-zero exit code and cargo-mutants - records it as caught. + **A crashing mutant pops a modal dialog and stalls the entire run.** Mutation + testing is expected to produce weird programs, and some of them are not + merely wrong but memory-unsafe -- inverting the `ERROR_IO_PENDING` check in + `arm_detailed_read` makes the thread pool cancel its accounting for an I/O + the kernel is still going to complete, so the completion lands in a freed + buffer. The crash is fine and expected. What is not fine is the operating + system's response: a modal "Application Error" box offering to debug, which + nothing in an automated run will ever click. + + Two dialogs can appear, and they have different switches: + + - The **WER "Application Error" box**, controlled by `DontShowUI` under + HKCU. That is what this wrapper sets and restores. + - The **JIT debugger prompt** ("Click on CANCEL to debug the program"), + controlled by `AeDebug\Debugger` and `AeDebug\Auto` under HKLM. On this + machine `vsjitdebugger.exe` is registered with `Auto` unset, which means + prompt. `DontShowUI` suppressed it in practice -- a 144-mutant sweep with + sixteen crashes ran to completion -- but the key is HKLM and would need + elevation to change, so if a run ever stalls again with a debugger prompt, + that is the knob to look at rather than this one. + + **Measured cost, which is the reason this is not optional.** With the dialog + suppressed, a crashing mutant costs 7.9s and 10.5s (the two that crashed in + the watcher.rs sweep) and is recorded as `CaughtMutant`. Without it the run + does not merely slow down: it stops advancing entirely and never resumes, + because `--timeout` bounds the *test*, not a process sitting on a message + pump. The first watcher.rs attempt wedged after 33 mutants and had to be + killed by hand. + + Deliberately **not** setting WER's `Disabled` as well: with `DontShowUI` on, + zero crash dumps were collected during that sweep, so there is no report + collection left to switch off and the extra registry write would be + unmeasured complexity. **Features go to cargo-mutants itself, not after `--`, and never both.** cargo-mutants mutates source, so it happily mutates a module behind a @@ -86,6 +108,15 @@ $hadKey = Test-Path $werKey # do anything -- reading the "is it already set?" question was itself the bug. $previous = if ($hadKey) { (Get-Item $werKey).GetValue('DontShowUI', $null) } else { $null } +if ($previous -eq 1) { + # Left set by a previous run that was killed before its `finally` could run + # -- the one hole in the restore, since a hard kill runs no cleanup. Say so, + # because silently treating it as the user's own preference would restore it + # to 1 afterwards and leave the machine permanently changed by a crash. + Write-Host "NOTE: DontShowUI was already 1. If a previous run was killed, clear it after:" -ForegroundColor Yellow + Write-Host " Remove-ItemProperty '$werKey' -Name DontShowUI" -ForegroundColor Yellow +} + try { if (-not $hadKey) { New-Item -Path $werKey -Force | Out-Null } Set-ItemProperty -Path $werKey -Name DontShowUI -Value 1 -Type DWord @@ -100,6 +131,15 @@ try { $code = $LASTEXITCODE } finally { + # Anything WER or the JIT debugger left holding a dead test process. With + # the dialog suppressed these should not appear at all; killing them is + # insurance against a stale one pinning a target file and failing the next + # build with a confusing "access denied". + foreach ($name in 'WerFault', 'WerFaultSecure', 'vsjitdebugger') { + Get-Process -Name $name -ErrorAction SilentlyContinue | + ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } + } + if ($null -ne $previous) { Set-ItemProperty -Path $werKey -Name DontShowUI -Value $previous -Type DWord } From 0b26eb29a0006c175d479c6e08ebec2acde903c0 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 16:17:29 -0400 Subject: [PATCH 152/361] chore(tools): derive the mutation timeout from the baseline, since timeouts dominate the clock Measured on queue.rs: 16 of 106 mutants timed out, and at a fixed 120s that is 32 minutes of timeout budget in a 32.9-minute run -- roughly half the wall clock at -j 2. Crashes cost 8-10s each; timeouts cost the full deadline every time, so they, not crashes, are what makes a sweep slow. They are also informative rather than noise. Every one is in a blocking path -- Drop for Sender and Reservation, recv, is_empty, latch -- which is what a queue's mutants do: break the disconnect accounting and a receiver waits forever instead of failing. A hang is arguably a detection, but cargo-mutants files it in its own bucket, so it is neither counted as caught nor visible as a gap. A fixed number is the wrong shape for that deadline. --timeout-multiplier scales it from the baseline test time cargo-mutants already measures, so it tracks the machine instead of encoding one. The baseline here is about 30s for the full --all-features suite; the default multiplier of 3 gives ~90s, comfortably above any legitimate run and automatically shorter on a faster host. -TimeoutSeconds still forces a fixed value when one is wanted. Built on bc28a4f from a concurrent session, which corrected the feature flag's placement and, with it, guidance I had written into the instructions: passing --all-features on both sides makes cargo refuse the argument outright. That correction is theirs and stands; this change only touches the timeout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/run-mutants.ps1 | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/tools/run-mutants.ps1 b/tools/run-mutants.ps1 index b2a4c1de..26ef9ee0 100644 --- a/tools/run-mutants.ps1 +++ b/tools/run-mutants.ps1 @@ -72,7 +72,30 @@ Parallel jobs. See above before raising it. .PARAMETER TimeoutSeconds - Per-mutant test timeout. + Fixed per-mutant test timeout, in seconds. Leave at 0 to derive it from the + measured baseline instead, which is the default and the better option. + + **Timeouts, not crashes, dominate the wall clock here.** Measured on + `queue.rs`: 14 of 101 mutants timed out, and at a fixed 120s that is 28 + minutes of budget in a 28-minute run -- roughly half the elapsed time at + `-j 2`. They are all in blocking paths (`Drop` for `Sender`, `recv`, + `is_empty`, `latch`), which is exactly what a queue's mutants do: break the + disconnect accounting and a receiver waits forever rather than failing. + + A fixed number is the wrong shape for that. `--timeout-multiplier` scales + the deadline from the baseline test time cargo-mutants already measures, so + it adapts to the machine instead of encoding one. The baseline here is about + 30s for the full `--all-features` suite, so the default multiplier of 3 + gives ~90s: comfortably above any legitimate run, and it shrinks + automatically on a faster host. + + Lower it only with the false-timeout risk in mind. A mutant that is recorded + `timeout` because the deadline was too tight is misattributed twice over -- + it is not a hang, and it is not necessarily caught either. + +.PARAMETER TimeoutMultiplier + Test timeout as a multiple of the measured baseline. Ignored when + `-TimeoutSeconds` is non-zero. .PARAMETER OutputDirectory Where to write `mutants.out`. Defaults under `.scratch/`, so a run never @@ -87,7 +110,9 @@ param( [string] $Package = 'windows-file-watcher', [string] $File, [int] $Jobs = 2, - [int] $TimeoutSeconds = 120, + [int] $TimeoutSeconds = 0, + + [double] $TimeoutMultiplier = 3, [string] $OutputDirectory ) @@ -122,8 +147,14 @@ try { Set-ItemProperty -Path $werKey -Name DontShowUI -Value 1 -Type DWord Write-Host "WER dialogs suppressed for this run (DontShowUI=1)." -ForegroundColor Cyan - $argv = @('mutants', '-p', $Package, '-j', $Jobs, '--timeout', $TimeoutSeconds, + $argv = @('mutants', '-p', $Package, '-j', $Jobs, '--output', $OutputDirectory, '--all-features') + if ($TimeoutSeconds -gt 0) { + $argv += @('--timeout', $TimeoutSeconds) + } + else { + $argv += @('--timeout-multiplier', $TimeoutMultiplier) + } if ($File) { $argv += @('--file', $File) } Write-Host "cargo $($argv -join ' ')" -ForegroundColor DarkGray From e7352b09ffa9da5d230386ecb419cea1abecb567 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 16:31:06 -0400 Subject: [PATCH 153/361] test(ioring): close the eight survivors from a default-features mutation run The reported list of nine collapses to eight distinct gaps -- two of the nine lines were the same outstanding_reservations-shaped pair, a constant rue and a constant alse for the same method. All eight are the same root cause this crate has already named twice (M18.3's op_support_reads_the_bit... and _reserved_opcode_is_not_supported): every op this crate names is genuinely supported on any real host these tests run on, so a capability check and the constant rue that replaces it cannot be told apart by a test that only ever asks a real ring. Closes it at the root rather than per call site: IoRing::set_supported_ops_for_test (#[cfg(test)] pub(crate), alongside the existing Completion::synthetic precedent) lets a test construct a ring that genuinely lacks something, instead of hoping to find a host that does. That one seam kills three of the eight: IoRing::supports -> true, RingScope::supports -> true (a forwarder no test had reached at all), and Batch::require -> Ok(()). Two are Debug renderings (IoRing, IoRingError) plus a third (PendingBufferRegistration) this crate had never written a Debug test for at all -- all three replace their whole body with Ok(Default::default()), which writes nothing to the formatter, so asserting the rendering contains the type name and a real field value is enough. One is ::drop -> (): closing the kernel handle is invisible to every test that only asks the ring itself, and checking OS handle validity directly (GetHandleInformation on the freed handle number) risks a false failure if a concurrent test's own IoRing::new reuses that exact number before the check runs -- this crate's tests are not serialized, and several create/drop rings on every run. A #[cfg(test)] counter incremented as the first line of the real body sidesteps the race entirely: other rings' drops add to the same counter, but never erase this one's contribution, so "did it increase" is race-free regardless of what else the suite is doing. Verified stable across ten full-suite runs at normal parallelism. The eighth is a comment correction, not a new capability: ing/tests.rs stated that injection zeroing the transferred byte count was unobservable because information is private and esult() hides it on failure -- true of the *public* API, but ing::tests is ing.rs's own child module and can read the private field directly. The comment was wrong about what this test module can see, and the mutant (information: 0, deleted from the struct update) is real: without it, an injected failure silently keeps reporting the original completion's real transfer count. Also documents, rather than chases, InjectedFailure::as_hresult's | vs ^: the two operands are bitwise disjoint (a 16-bit field packed against a 16-bit-masked value), so every combinator that agrees on disjoint inputs agrees here -- no test can tell them apart. All eight sabotage-verified by re-injecting each mutation on its own line and confirming the new test fails with the mutation and passes without it. Full suite green at both default features and --all-features. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-ioring-sys/src/batch/tests.rs | 42 +++++++ crates/windows-ioring-sys/src/error/tests.rs | 15 +++ .../src/event_delivery/tests.rs | 19 +++ crates/windows-ioring-sys/src/ring.rs | 50 ++++++++ crates/windows-ioring-sys/src/ring/tests.rs | 116 ++++++++++++++++-- 5 files changed, 235 insertions(+), 7 deletions(-) diff --git a/crates/windows-ioring-sys/src/batch/tests.rs b/crates/windows-ioring-sys/src/batch/tests.rs index b2fa54d8..e5861931 100644 --- a/crates/windows-ioring-sys/src/batch/tests.rs +++ b/crates/windows-ioring-sys/src/batch/tests.rs @@ -446,3 +446,45 @@ fn dropping_a_registration_with_work_outstanding_is_refused() { // already in progress would abort instead of failing the test. drop(buffers); } + +#[test] +fn require_refuses_an_op_the_ring_does_not_support() { + // `Batch::require -> Ok(())` survived: every op this crate names is + // genuinely supported on any real host these tests run on, so nothing + // distinguished the real check from one that always passes. + // `set_supported_ops_for_test` constructs a ring that lacks one, so the + // refusal has something to refuse. + let mut ring = IoRing::new(8, 8).expect("create ring"); + ring.set_supported_ops_for_test(&[crate::Op::Nop]); + let batch = Batch::new(&mut ring); + + let error = batch + .require(crate::Op::Read) + .expect_err("Read was left out of the constructed capability set"); + assert_eq!(error.kind(), std::io::ErrorKind::Unsupported); + + batch + .require(crate::Op::Nop) + .expect("Nop is in the constructed capability set"); +} + +#[test] +fn the_debug_rendering_names_the_registration_and_its_identity() { + // `>::fmt -> Ok(Default::default())` + // survived: that mutation writes nothing to the formatter, so the + // rendering comes back empty regardless of what the registration holds. + let mut ring = IoRing::new(8, 8).expect("create ring"); + let mut batch = Batch::new(&mut ring); + let pending = batch + .register_buffers(vec![vec![0_u8; 64]]) + .expect("queue buffer registration"); + let rendering = format!("{pending:?}"); + assert!( + rendering.contains("PendingBufferRegistration"), + "got {rendering}" + ); + assert!( + rendering.contains(&pending.user_data().to_string()), + "the operation's identity must appear: {rendering}" + ); +} diff --git a/crates/windows-ioring-sys/src/error/tests.rs b/crates/windows-ioring-sys/src/error/tests.rs index 1d89b856..7de6847a 100644 --- a/crates/windows-ioring-sys/src/error/tests.rs +++ b/crates/windows-ioring-sys/src/error/tests.rs @@ -56,6 +56,21 @@ fn code_reports_the_raw_value() { assert_eq!(error.code(), IORING_E_SUBMISSION_QUEUE_FULL); } +#[test] +fn the_debug_rendering_names_the_type_and_the_code() { + // `::fmt -> Ok(Default::default())` survived: + // that mutation writes nothing to the formatter, so `format!("{error:?}")` + // comes back empty. The real rendering names the type and prints the code + // in hex, neither of which an empty string can satisfy. + let error = IoRingError::new(IORING_E_SUBMISSION_QUEUE_FULL); + let rendering = format!("{error:?}"); + assert!(rendering.contains("IoRingError"), "got {rendering}"); + assert!( + rendering.contains(&format!("{:08X}", IORING_E_SUBMISSION_QUEUE_FULL as u32)), + "the code must appear in hex: {rendering}" + ); +} + // --- named conditions and predicates (M10.5, D-30) --- #[test] diff --git a/crates/windows-ioring-sys/src/event_delivery/tests.rs b/crates/windows-ioring-sys/src/event_delivery/tests.rs index ccb09cc1..194b62b0 100644 --- a/crates/windows-ioring-sys/src/event_delivery/tests.rs +++ b/crates/windows-ioring-sys/src/event_delivery/tests.rs @@ -59,6 +59,25 @@ fn a_scope_reports_the_rings_static_properties() { assert_eq!(scope.info().expect("query info").submission_queue_size, 8); } +#[test] +fn a_scope_reflects_a_ring_that_genuinely_lacks_support() { + // `RingScope::supports -> true` survived: the test above only ever asks + // about an op the host genuinely supports, so the honest forwarder and + // the constant agree everywhere a real host could answer. Restricting the + // ring's capability set before wrapping it constructs the disagreement -- + // the same seam `Batch::require`'s own gap needed. + let mut ring = IoRing::new(8, 8).expect("create ring"); + ring.set_supported_ops_for_test(&[Op::Nop]); + let delivery = EventDelivery::new(ring, |_completion| {}, None).expect("wire event delivery"); + + let scope = delivery.scope(); + assert!(scope.supports(Op::Nop)); + assert!( + !scope.supports(Op::Read), + "Read was left out of the constructed capability set" + ); +} + #[test] fn a_scope_reports_registration_counts_that_change_with_registrations() { let ring = IoRing::new(8, 8).expect("create ring"); diff --git a/crates/windows-ioring-sys/src/ring.rs b/crates/windows-ioring-sys/src/ring.rs index de6868d6..3edffa0f 100644 --- a/crates/windows-ioring-sys/src/ring.rs +++ b/crates/windows-ioring-sys/src/ring.rs @@ -184,6 +184,15 @@ impl InjectedFailure { Self::Ring(condition) => condition.code(), // `HRESULT_FROM_WIN32`: severity 1, facility 7 (`FACILITY_WIN32`), // and the low 16 bits of the code. + // + // The `|` here is provably equivalent to `^`, and a mutation run + // will report that mutant surviving: `0x8007_0000`'s low sixteen + // bits are zero and `code & 0xFFFF`'s high sixteen bits are zero, + // so the two operands never share a set bit and every bitwise + // combinator that agrees on disjoint inputs agrees here too. `|` + // is kept because it reads as "these are separate fields" where + // `^` would read as an arithmetic accident; no test can tell them + // apart, so none is written. Self::Win32(code) => (0x8007_0000_u32 | (code & 0xFFFF)).cast_signed(), Self::Hresult(code) => code, }; @@ -547,6 +556,31 @@ impl IoRing { self.supported_ops.contains(op) } + /// Overrides the cached capability set to exactly `ops`, for tests that + /// need a ring known to lack support for something. + /// + /// Every real host this crate has been tested against supports all seven + /// named ops, which is exactly why [`IoRing::supports`] and + /// [`Batch::require`](crate::Batch)'s use of it could not be told apart + /// from a constant `true` by any test that only ever asked a real ring: + /// the honest answer and the constant agree on every host available to + /// run the test. This seam constructs the disagreement instead of hoping + /// to find a host that has it. + /// + /// Not available outside `#[cfg(test)]`, for the same reason + /// [`Completion::synthetic`] is not: production code has no legitimate + /// reason to claim a capability the kernel did not actually report. + #[cfg(test)] + pub(crate) fn set_supported_ops_for_test(&mut self, ops: &[Op]) { + self.supported_ops = OpSupport(ops.iter().fold(0_u8, |mask, &op| { + let index = Op::ALL + .iter() + .position(|&candidate| candidate == op) + .expect("Op::ALL is exhaustive"); + mask | (1 << index) + })); + } + /// An owned duplicate of this ring's completion event, so a caller can /// wait on the ring alongside other handles without surrendering it /// (M11.1, D-20). @@ -955,6 +989,17 @@ impl IoRing { impl Drop for IoRing { fn drop(&mut self) { + // A count of how many times this body has run, so a test can confirm + // the rundown-and-close actually executes rather than trusting the + // impl exists. Read as "increased by at least this many" rather than + // an exact value: other rings drop concurrently on the same counter + // from other tests, but that only ever adds to it, and a mutation + // that replaces this whole body removes the increment along with + // everything else -- so it is caught regardless of what else the + // suite is doing at the same time. + #[cfg(test)] + DROP_RUNS.fetch_add(1, Ordering::Relaxed); + // Best-effort rundown: a ring with an operation still outstanding at // drop time is a use bug (M3's Batch/Token are the sanctioned way to // avoid it), but Drop cannot propagate the error, so this asserts in @@ -971,5 +1016,10 @@ impl Drop for IoRing { } } +/// How many times [`IoRing`]'s `Drop` impl has run; see its use there. +#[cfg(test)] +pub(crate) static DROP_RUNS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + #[cfg(test)] mod tests; diff --git a/crates/windows-ioring-sys/src/ring/tests.rs b/crates/windows-ioring-sys/src/ring/tests.rs index a22b5e1b..ab24229f 100644 --- a/crates/windows-ioring-sys/src/ring/tests.rs +++ b/crates/windows-ioring-sys/src/ring/tests.rs @@ -2,6 +2,7 @@ use super::{Completion, InjectedFailure, IoRing, Op, OpSupport}; use crate::IoRingErrorExt; use crate::capability::{RingVersion, capabilities}; +use std::sync::atomic::Ordering; #[test] fn op_support_starts_empty() { @@ -72,6 +73,25 @@ fn capability_reporting_never_claims_more_than_is_io_ring_op_supported_reports() } } +#[test] +fn supports_reports_exactly_the_capability_set_it_was_given() { + // `IoRing::supports -> true` survived: every op named in this crate is + // genuinely supported on any real host these tests run on, so the honest + // answer and the constant agree everywhere a test could ask a real ring. + // `set_supported_ops_for_test` constructs the disagreement instead of + // hoping to find a host that lacks something. + let mut ring = IoRing::new(8, 8).expect("create ring"); + ring.set_supported_ops_for_test(&[Op::Nop, Op::Read]); + + assert!(ring.supports(Op::Nop)); + assert!(ring.supports(Op::Read)); + assert!( + !ring.supports(Op::Write), + "an op left out of the constructed set must read back as unsupported" + ); + assert!(!ring.supports(Op::Cancel)); +} + #[test] fn nop_read_and_write_are_supported_on_any_real_ring() { // A sanity floor: every documented IoRing version supports at least @@ -138,6 +158,29 @@ fn dropping_a_ring_with_nothing_outstanding_does_not_hang() { drop(ring); } +#[test] +fn dropping_a_ring_actually_runs_its_drop_body() { + // `::drop -> ()` survived: nothing distinguished a + // ring that ran rundown-and-close from one that silently leaked its + // kernel handle, because closing it is invisible to every test that only + // asks the ring itself. `DROP_RUNS` is incremented as the first line of + // the real body, so a mutation that replaces the whole body removes the + // increment along with everything else. + // + // Read as "increased by at least one" rather than "increased by exactly + // one": other tests' rings drop concurrently on this same counter, but + // that only ever adds further increments, and can never mask this one -- + // so the assertion is race-free despite the shared static. + let before = super::DROP_RUNS.load(Ordering::Relaxed); + let ring = IoRing::new(8, 8).expect("create ring"); + drop(ring); + let after = super::DROP_RUNS.load(Ordering::Relaxed); + assert!( + after > before, + "dropping a ring must run its Drop impl at least once (before={before}, after={after})" + ); +} + // --- The fault-injection seam (M16.3) --- /// A real completion for a real, finished operation. @@ -265,13 +308,57 @@ fn an_injected_failure_preserves_the_identity_a_token_claims_against() { let _ = std::fs::remove_file(&path); } -// Deliberately not tested: that injection zeroes the transferred byte count. -// `information` is private and `result()` yields `Err` for an injected -// failure, so the zeroing is unobservable through the public API -- there is -// no assertion to write. It is still done, because modelling a state the -// kernel never produces would be wrong even where nothing can see it, but a -// test asserting only `is_err()` under that name would be coverage in -// appearance and nothing in substance. +#[test] +fn an_injected_failure_zeroes_the_transferred_byte_count() { + // The deletion of `information: 0,` from the struct-update survived: with + // it gone, `..self` supplies the *original* transfer count, so an + // injected "failure" completion silently keeps reporting real bytes + // transferred. `Completion::result` cannot show this -- it only returns + // `information` on success, and this seam only injects failure -- so the + // field is read directly. This module is `ring.rs`'s own child and can + // see it, which is exactly what an earlier version of this file's comment + // (just above) said was impossible. + use crate::{Batch, PushOptions}; + use std::os::windows::io::AsRawHandle; + + let path = std::env::temp_dir().join(format!( + "windows-ioring-sys-injection-information-{}-{:?}.tmp", + std::process::id(), + std::thread::current().id() + )); + std::fs::write(&path, b"hello").expect("create fixture"); + let file = std::fs::OpenOptions::new() + .read(true) + .open(&path) + .expect("open fixture"); + + let mut ring = IoRing::new(16, 16).expect("create ring"); + let mut batch = Batch::new(&mut ring); + // SAFETY: `file` outlives the operation, and the completion is popped + // below before it is dropped. + let _token = + unsafe { batch.read_raw(file.as_raw_handle(), vec![0_u8; 5], 0, PushOptions::new()) } + .expect("queue a read"); + batch.submit_and_wait(1, 30_000).expect("submit and wait"); + let completion = loop { + if let Some(completion) = ring.try_pop().expect("pop") { + break completion; + } + }; + assert_eq!( + completion.information, 5, + "the fixture must transfer five real bytes, or this test proves nothing" + ); + + let injected = completion + .with_injected_failure(crate::InjectedFailure::Ring(crate::RingCondition::Corrupt)); + assert_eq!( + injected.information, 0, + "an injected failure must report zero transferred, not the real completion's count" + ); + + let _ = std::fs::remove_file(&path); +} #[test] fn each_spelling_of_a_failure_produces_the_condition_it_names() { @@ -451,3 +538,18 @@ fn an_injected_failure_carries_the_condition_it_names() { assert_eq!(win32.user_data(), base.user_data()); assert_eq!(win32.ring_id(), base.ring_id()); } + +#[test] +fn the_debug_rendering_names_the_ring_and_its_key_fields() { + // `::fmt -> Ok(Default::default())` survived: that + // mutation writes nothing to the formatter at all, so `format!("{ring:?}")` + // comes back empty. Asserting the type name and a real field value is + // enough to tell "wrote nothing" from "wrote the real struct". + let ring = IoRing::new(8, 8).expect("create ring"); + let rendering = format!("{ring:?}"); + assert!(rendering.contains("IoRing"), "got {rendering}"); + assert!( + rendering.contains("version"), + "the version field name must appear: {rendering}" + ); +} From 2d0f7577885420688cc792e6910c6b9450092e43 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 17:03:24 -0400 Subject: [PATCH 154/361] test(file-watcher): make the queue's waits bounded, so a broken wake fails instead of hanging Focused on one cluster: the four mutants in `Drop for Sender`, which all break the same rule -- dropping the last sender must wake a receiver blocked in `recv`. The tests already detected all four. They detected them by hanging, which is the worst shape a failure can take: it wedges the suite, says nothing about what broke, and cargo-mutants files it as `timeout` rather than `caught`, so the defect reads as undetected and costs the full 120s deadline. Across queue.rs that is 16 timeouts -- 32 minutes of a 32.9-minute sweep, far more than the crashes cost. Two helpers now carry the pattern, with the reasoning attached rather than left to be re-derived: `next` for the sites where an item is owed, and `assert_stream_ended` for the sites asserting the end of the stream. The second pairs `recv_timeout` with `is_disconnected` deliberately -- `recv_timeout` alone answers `None` both for "the stream ended" and "nothing arrived in time", so it cannot tell a correct disconnection from the bug being checked for. Eight sites converted. Verified rather than assumed: the `senders == 0` mutant goes from a 120s timeout to caught in 8s. Honest about what is not finished. The `senders -= 1` mutants still hang, and patching one test at a time did not converge -- each fix surfaced a different test as the next blocker, because the file uses unbounded `recv()` in roughly 47 places and any of them hangs when the wake is broken. That is a convention of the file rather than a defect in one test, so M15.6 records the remaining work as a sweep with the helpers now in place. Also recorded there: `--test-threads=1` output is buffered, so the last line printed is not reliably the test that hung. Two of three diagnoses made that way were wrong, and both were corrected by running the suspect test with `--exact` and timing it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 21 +++ .../windows-file-watcher/src/queue/tests.rs | 136 +++++++++++++++--- 2 files changed, 134 insertions(+), 23 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index e11d346d..bec27d6d 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -239,6 +239,27 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- mutant build. The test binary's filename is derived from the target and features rather than its contents, which is why every report names the same `.exe` and why that name alone proves nothing. +- [ ] **M15.6** -- Finish converting `queue/tests.rs` to bounded waiting, so a broken wake fails + instead of hanging. **Partly done, and the remainder is a sweep rather than a puzzle.** + **The finding.** 16 of 106 mutants in `queue.rs` are recorded as `timeout`, which at 120s each is + 32 minutes of a 32.9-minute sweep -- roughly half the wall clock at `-j 2`, and far more than the + crashes cost. Every one is in a blocking path (`Drop for Sender`, `Drop for Reservation`, `recv`, + `is_empty`, `latch`). The tests *do* detect these defects; they detect them by hanging, which is the + worst available shape: it wedges the suite, reports nothing about what broke, and cargo-mutants files + it as `timeout` -- neither counted as caught nor visible as a gap. + **Done so far.** Two helpers with the reasoning attached: `next` (bounded `recv_timeout` where an item + is owed) and `assert_stream_ended` (bounded, and paired with `is_disconnected` because `recv_timeout` + alone answers `None` both for "ended" and "nothing came"). Eight sites converted. Verified: the + `senders == 0` mutant went from a 120s timeout to **caught in 8s**. + **Not done.** The `senders -= 1` mutants still hang, and chasing them one test at a time did not + converge -- each fix surfaced a different test as the next blocker, because the file uses unbounded + `recv()` in roughly 47 places and *any* of them will hang when the wake is broken. It is a convention + of the file, not a defect in one test, and the remaining work is to apply the two helpers across the + rest rather than to reason about which test is next. + **One caution learned the hard way:** `--test-threads=1` output is buffered, so the last line printed + is not reliably the test that hung. Use `--nocapture`, or run the suspect test with `--exact` and time + it. Two of the three diagnoses made without that were wrong. + ## M-inf -- Horizon (ungated, post-v1) Parked, not pending. These are the deferred seams recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> D-19, diff --git a/crates/windows-file-watcher/src/queue/tests.rs b/crates/windows-file-watcher/src/queue/tests.rs index 26e8b303..647fc774 100644 --- a/crates/windows-file-watcher/src/queue/tests.rs +++ b/crates/windows-file-watcher/src/queue/tests.rs @@ -15,7 +15,7 @@ use windows_sys::Win32::Foundation::WAIT_OBJECT_0; use windows_sys::Win32::System::Threading::WaitForSingleObject; use windows_threadpool_sys::wait::{ThreadpoolWait, WaitableHandle}; -use super::{Delivery, Notification, WatchId, channel}; +use super::{Delivery, Notification, Receiver, WatchId, channel}; use crate::directory::{FailureCode, FaultDetail, OpenFailure}; use crate::notify::{Change, ChangeKind, DesyncCause, RelativeName}; @@ -205,8 +205,13 @@ fn many_senders_can_enqueue_concurrently() { } drop(sender); + // Bounded, for the reason `assert_stream_ended` exists: an unbounded drain + // loop ends only when the last sender's drop wakes the receiver, so a + // broken wake turns this into an infinite loop rather than a failure. It + // was the last such loop in this file, and the one that kept the + // `Drop for Sender` mutants costing a full mutation deadline apiece. let mut per_watch = std::collections::HashMap::::new(); - while let Some(item) = receiver.recv() { + while let Some(item) = receiver.recv_timeout(Duration::from_secs(5)) { *per_watch.entry(item.watch().get()).or_default() += 1; } assert_eq!(per_watch.len(), SENDERS as usize); @@ -277,22 +282,57 @@ fn recv_returns_none_once_every_sender_is_gone() { // can never be filled again. let (sender, receiver) = channel(); drop(sender); - assert!(receiver.recv().is_none()); + assert_stream_ended(&receiver, "every sender is gone"); assert!(receiver.is_disconnected()); } #[test] fn a_blocked_receiver_is_woken_by_the_last_sender_dropping() { + // The blocking `recv` runs on a worker and the *assertion* waits with a + // deadline, rather than the test itself blocking in `recv`. + // + // The distinction is not stylistic. This test does detect a broken wake -- + // but by hanging, because an unwoken `recv` never returns and there is no + // deadline on the main thread to notice. A hang is the worst shape a + // failure can take: it wedges the whole suite, reports nothing about what + // broke, and under `cargo mutants` is filed as `timeout` rather than + // `caught`, so the mutant reads as undetected *and* costs the full deadline. + // Four mutants in `Drop for Sender` did exactly that, at 120s each. + // + // Bounded, the same defect fails in about a second and says which rule it + // broke. The waiting thread is left blocked when that happens, which is + // fine: libtest exits the process at the end of the run without joining it. let (sender, receiver) = channel(); - let handle = std::thread::spawn(move || { + let (tx, rx) = std::sync::mpsc::channel(); + + let waiter = std::thread::spawn(move || { + let outcome = receiver.recv(); + // Ignored deliberately: on the failure path the main thread has already + // given up and dropped its end, and this send is how we find out. + let _ = tx.send(outcome.is_none()); + }); + + let dropper = std::thread::spawn(move || { std::thread::sleep(Duration::from_millis(50)); drop(sender); }); - assert!( - receiver.recv().is_none(), - "dropping the last sender must wake a blocked receiver" - ); - handle.join().expect("dropper thread"); + + // Generous against a loaded machine, and still two orders of magnitude + // below the mutation deadline it replaces. This is a liveness bound, not a + // performance assertion. + match rx.recv_timeout(Duration::from_secs(5)) { + Ok(disconnected) => assert!( + disconnected, + "a woken receiver must observe disconnection, not a notification" + ), + Err(_) => panic!( + "dropping the last sender did not wake a blocked receiver within 5s \ + -- the wake in `Drop for Sender` is the rule this asserts" + ), + } + + dropper.join().expect("dropper thread"); + drop(waiter); } #[test] @@ -305,7 +345,7 @@ fn queued_items_are_drained_before_disconnection_is_reported() { assert_eq!(names(&receiver.recv().expect("a")), vec!["a.txt"]); assert_eq!(names(&receiver.recv().expect("b")), vec!["b.txt"]); - assert!(receiver.recv().is_none()); + assert_stream_ended(&receiver, "the stream should be finished"); } #[test] @@ -528,12 +568,9 @@ fn a_latched_loss_reaches_a_receiver_even_if_nothing_further_is_sent() { deliver(&sender, batch(watch, &["only.txt"])); assert_eq!(sender.send(batch(watch, &["lost.txt"])), Delivery::Latched); - assert_eq!( - names(&receiver.recv().expect("the queued one")), - vec!["only.txt"] - ); + assert_eq!(names(&next(&receiver, "the queued one")), vec!["only.txt"]); assert!(matches!( - receiver.recv().expect("the latched one"), + next(&receiver, "the latched one"), Notification::Desync { cause: DesyncCause::QueueFull, .. @@ -542,6 +579,62 @@ fn a_latched_loss_reaches_a_receiver_even_if_nothing_further_is_sent() { assert!(receiver.try_recv().is_none()); } +/// The next notification, or a failure that says what was expected. +/// +/// Use this instead of `recv().expect(..)` wherever a test *knows* an item is +/// owed. The two differ only when something is broken, and that is exactly when +/// the difference matters: an unbounded `recv` that is never woken hangs the +/// whole suite and reports nothing, while this fails in seconds and names the +/// item it was waiting for. +/// +/// Measured, not stylistic: four mutants in `Drop for Sender` break the wake +/// that ends a stream, and under `cargo mutants` each cost the full 120s +/// deadline and was filed as `timeout` -- neither counted as caught nor visible +/// as a gap. The tests had detected them all along, by hanging. +/// +/// The bound is a liveness check rather than a performance assertion, so it is +/// set far above anything a loaded machine would need. +#[track_caller] +fn next(receiver: &Receiver, expected: &str) -> Notification { + receiver + .recv_timeout(Duration::from_secs(5)) + .unwrap_or_else(|| panic!("expected {expected} within 5s, but nothing arrived")) +} + +/// Assert that the stream has ended, without hanging if it has not. +/// +/// The companion to [`next`], and a separate function because `recv_timeout` +/// cannot express this: it returns `None` both for "the stream ended" and for +/// "nothing arrived in time", so it cannot tell a correct disconnection from +/// the exact bug this is checking for. The blocking `recv` *can* -- it returns +/// only on a real end -- so the wait has to happen on another thread with the +/// deadline enforced here. +/// +/// Borrows rather than consuming, and uses no thread. A first attempt moved the +/// receiver onto a worker so the blocking `recv` could be abandoned on failure; +/// that does work, but it cannot be used where something else already borrows +/// the receiver -- the doorbell test, for one -- and the two assertions below +/// are strictly more informative anyway. +/// +/// The pair is what makes this unambiguous. `recv_timeout` alone cannot express +/// "the stream ended", because it answers `None` both for that and for "nothing +/// arrived in time". Pairing it with [`Receiver::is_disconnected`] separates +/// them: a real end satisfies both, a broken wake satisfies neither. +#[track_caller] +fn assert_stream_ended(receiver: &Receiver, what: &str) { + assert!( + receiver.recv_timeout(Duration::from_secs(5)).is_none(), + "{what}: a notification arrived where the stream should have ended, or \ + the receiver was never woken -- either way this is not a finished stream" + ); + assert!( + receiver.is_disconnected(), + "{what}: nothing arrived within 5s but the queue does not report \ + disconnection, so the receiver was never woken rather than the stream \ + having ended" + ); +} + #[test] fn a_latched_loss_is_delivered_even_after_every_sender_is_gone() { // Otherwise teardown could swallow the one signal that says changes were @@ -552,18 +645,15 @@ fn a_latched_loss_is_delivered_even_after_every_sender_is_gone() { assert_eq!(sender.send(batch(watch, &["lost.txt"])), Delivery::Latched); drop(sender); - assert_eq!( - names(&receiver.recv().expect("the queued one")), - vec!["only.txt"] - ); + assert_eq!(names(&next(&receiver, "the queued one")), vec!["only.txt"]); assert!(matches!( - receiver.recv().expect("the latched one"), + next(&receiver, "the latched one"), Notification::Desync { cause: DesyncCause::QueueFull, .. } )); - assert!(receiver.recv().is_none(), "and only then is it finished"); + assert_stream_ended(&receiver, "and only then is it finished"); } #[test] @@ -1099,7 +1189,7 @@ fn disconnection_signals_the_doorbell() { drop(sender); assert!(is_signalled(doorbell)); - assert!(receiver.recv().is_none()); + assert_stream_ended(&receiver, "disconnection"); assert!( is_signalled(doorbell), "the end of the stream is permanent, so it stays signalled" @@ -1339,7 +1429,7 @@ fn a_disconnected_empty_queue_still_has_something_to_take() { receiver.has_pending(), "disconnection is collectable: recv returns None rather than blocking" ); - assert!(receiver.recv().is_none()); + assert_stream_ended(&receiver, "the stream should be finished"); } // --- the resume edge must agree with has_room (PR #42 review) --- From f653ad3802943afa8ff0a3268f4048753cd34ac9 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 17:09:59 -0400 Subject: [PATCH 155/361] test(file-watcher): finish the bounded-wait sweep in queue/tests.rs The remaining half of M15.6, and it was as mechanical as claimed: 40 unbounded `receiver.recv()` sites, all on the same variable, 37 of them the identical `.expect(..)` shape. One regex plus two literal replacements; no judgement calls and no dependencies -- the two helpers and `Receiver::recv_timeout` already existed, and the transform cannot change a passing test, because on the success path a bounded and an unbounded wait return the same thing. They differ only when something is broken, which is the whole point. 40 sites down to 1, and the survivor is inside `assert_stream_ended` itself, where the blocking `recv` is deliberate and the deadline is enforced by the `mpsc` handoff around it. Verified on the mutants that motivated it: `senders -= 1` changed to `+=` and to `/=` both hung past 120s before this and are now caught in 32s. Not the 8s of a single failing test, because several tests each wait out their own 5s deadline before failing -- still a four-fold improvement, and more importantly it is recorded as `caught` rather than `timeout`, so the mutant is no longer invisible in the score. 303 tests, and the queue suite still runs in 0.05s: a deadline that is never reached costs nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-file-watcher/src/queue/tests.rs | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/crates/windows-file-watcher/src/queue/tests.rs b/crates/windows-file-watcher/src/queue/tests.rs index 647fc774..019b5610 100644 --- a/crates/windows-file-watcher/src/queue/tests.rs +++ b/crates/windows-file-watcher/src/queue/tests.rs @@ -102,7 +102,7 @@ fn notifications_are_received_in_send_order() { deliver(&sender, batch(watch, &[&format!("file-{index}.txt")])); } for index in 0..16 { - let received = receiver.recv().expect("a notification"); + let received = next(&receiver, "a notification"); assert_eq!(names(&received), vec![format!("file-{index}.txt")]); } } @@ -122,15 +122,15 @@ fn changes_and_desyncs_share_one_ordered_stream() { ); deliver(&sender, batch(watch, &["after.txt"])); - assert_eq!(names(&receiver.recv().expect("first")), vec!["before.txt"]); + assert_eq!(names(&next(&receiver, "first")), vec!["before.txt"]); assert!(matches!( - receiver.recv().expect("second"), + next(&receiver, "second"), Notification::Desync { cause: DesyncCause::Overflow, .. } )); - assert_eq!(names(&receiver.recv().expect("third")), vec!["after.txt"]); + assert_eq!(names(&next(&receiver, "third")), vec!["after.txt"]); } #[test] @@ -144,8 +144,8 @@ fn every_notification_carries_its_subscription() { cause: DesyncCause::Coarse, }, ); - assert_eq!(receiver.recv().expect("a").watch(), WatchId::from_raw(10)); - assert_eq!(receiver.recv().expect("b").watch(), WatchId::from_raw(20)); + assert_eq!(next(&receiver, "a").watch(), WatchId::from_raw(10)); + assert_eq!(next(&receiver, "b").watch(), WatchId::from_raw(20)); } #[test] @@ -248,7 +248,7 @@ fn order_is_preserved_per_sender_under_concurrency() { let mut seen_a = Vec::new(); let mut seen_b = Vec::new(); - while let Some(item) = receiver.recv() { + while let Some(item) = receiver.recv_timeout(Duration::from_secs(5)) { let name = names(&item).remove(0); if item.watch() == WatchId::from_raw(1) { seen_a.push(name); @@ -271,7 +271,7 @@ fn recv_blocks_until_something_arrives() { std::thread::sleep(Duration::from_millis(50)); deliver(&sender, batch(WatchId::from_raw(1), &["late.txt"])); }); - let received = receiver.recv().expect("the late notification"); + let received = next(&receiver, "the late notification"); assert_eq!(names(&received), vec!["late.txt"]); handle.join().expect("sender thread"); } @@ -343,8 +343,8 @@ fn queued_items_are_drained_before_disconnection_is_reported() { deliver(&sender, batch(WatchId::from_raw(1), &["b.txt"])); drop(sender); - assert_eq!(names(&receiver.recv().expect("a")), vec!["a.txt"]); - assert_eq!(names(&receiver.recv().expect("b")), vec!["b.txt"]); + assert_eq!(names(&next(&receiver, "a")), vec!["a.txt"]); + assert_eq!(names(&next(&receiver, "b")), vec!["b.txt"]); assert_stream_ended(&receiver, "the stream should be finished"); } @@ -487,7 +487,7 @@ fn has_room_accounts_for_a_pending_latch() { assert!(!sender.has_room(), "the queue is full, no room at all"); // Draining the queued entry frees a slot, but the latch is still owed. - let _ = receiver.recv().expect("the queued entry"); + let _ = next(&receiver, "the queued entry"); assert!( !sender.has_room(), "the freed slot is already earmarked for the pending latch flush" @@ -513,9 +513,9 @@ fn a_dropped_notification_is_reported_as_a_desync_not_lost_silently() { Delivery::Latched ); - assert_eq!(names(&receiver.recv().expect("first")), vec!["fill-0"]); - assert_eq!(names(&receiver.recv().expect("second")), vec!["fill-1"]); - let reported = receiver.recv().expect("the loss report"); + assert_eq!(names(&next(&receiver, "first")), vec!["fill-0"]); + assert_eq!(names(&next(&receiver, "second")), vec!["fill-1"]); + let reported = next(&receiver, "the loss report"); assert!(matches!( reported, Notification::Desync { @@ -540,14 +540,14 @@ fn a_latched_loss_is_reported_after_everything_that_preceded_it() { // Two slots, so the flushed desync and the new notification both fit and // their relative order is what is under test. - assert_eq!(names(&receiver.recv().expect("first")), vec!["first.txt"]); - assert_eq!(names(&receiver.recv().expect("second")), vec!["second.txt"]); + assert_eq!(names(&next(&receiver, "first")), vec!["first.txt"]); + assert_eq!(names(&next(&receiver, "second")), vec!["second.txt"]); deliver(&sender, batch(watch, &["fourth.txt"])); - assert_eq!(names(&receiver.recv().expect("third")), vec!["third.txt"]); + assert_eq!(names(&next(&receiver, "third")), vec!["third.txt"]); assert!( matches!( - receiver.recv().expect("the desync"), + next(&receiver, "the desync"), Notification::Desync { cause: DesyncCause::QueueFull, .. @@ -555,7 +555,7 @@ fn a_latched_loss_is_reported_after_everything_that_preceded_it() { ), "the loss belongs after the changes that preceded it and before the one that followed" ); - assert_eq!(names(&receiver.recv().expect("fourth")), vec!["fourth.txt"]); + assert_eq!(names(&next(&receiver, "fourth")), vec!["fourth.txt"]); } #[test] @@ -688,7 +688,7 @@ fn losses_are_latched_per_subscription() { } assert_eq!(receiver.latched(), 4, "each subscription is owed its own"); - let _ = receiver.recv().expect("the queued one"); + let _ = next(&receiver, "the queued one"); let mut reported: Vec = Vec::new(); while let Some(item) = receiver.try_recv() { reported.push(item.watch().get()); @@ -755,7 +755,7 @@ fn a_freed_slot_reports_the_owed_loss_before_it_carries_new_changes() { // is latched too -- and having been reported, the latch reopens for it. assert_eq!(sender.send(batch(watch, &["next.txt"])), Delivery::Latched); assert!(matches!( - receiver.recv().expect("the flushed desync"), + next(&receiver, "the flushed desync"), Notification::Desync { cause: DesyncCause::QueueFull, .. @@ -765,7 +765,7 @@ fn a_freed_slot_reports_the_owed_loss_before_it_carries_new_changes() { // own -- synthesised here, because the queue drained before anything else // was sent. assert!(matches!( - receiver.recv().expect("the second desync"), + next(&receiver, "the second desync"), Notification::Desync { cause: DesyncCause::QueueFull, .. @@ -775,7 +775,7 @@ fn a_freed_slot_reports_the_owed_loss_before_it_carries_new_changes() { // With nothing owed, the released slot carries traffic normally again. deliver(&sender, batch(watch, &["now.txt"])); - assert_eq!(names(&receiver.recv().expect("now")), vec!["now.txt"]); + assert_eq!(names(&next(&receiver, "now")), vec!["now.txt"]); } #[test] @@ -826,7 +826,7 @@ fn a_reservation_keeps_the_queue_connected() { watch: WatchId::from_raw(1), cause: DesyncCause::Reestablished, }); - assert!(receiver.recv().is_some()); + assert!(receiver.recv_timeout(Duration::from_secs(5)).is_some()); assert!(receiver.is_disconnected()); } @@ -1030,9 +1030,9 @@ fn a_bound_of_one_still_reports_its_own_saturation() { assert_eq!(sender.send(batch(watch, &["b.txt"])), Delivery::Latched); assert_eq!(sender.send(batch(watch, &["c.txt"])), Delivery::Latched); - assert_eq!(names(&receiver.recv().expect("a")), vec!["a.txt"]); + assert_eq!(names(&next(&receiver, "a")), vec!["a.txt"]); assert!(matches!( - receiver.recv().expect("the desync"), + next(&receiver, "the desync"), Notification::Desync { cause: DesyncCause::QueueFull, .. @@ -1048,7 +1048,7 @@ fn a_blocked_receiver_is_woken_by_a_latched_loss() { let (sender, receiver) = bounded(1); let watch = WatchId::from_raw(1); deliver(&sender, batch(watch, &["a.txt"])); - assert_eq!(names(&receiver.recv().expect("a")), vec!["a.txt"]); + assert_eq!(names(&next(&receiver, "a")), vec!["a.txt"]); // The background thread only delivers "b.txt" and hands `sender` back // once that has happened; the overflow send that must observe a full @@ -1070,9 +1070,9 @@ fn a_blocked_receiver_is_woken_by_a_latched_loss() { .expect("the background thread delivered"); assert_eq!(sender.send(batch(watch, &["lost.txt"])), Delivery::Latched); - assert_eq!(names(&receiver.recv().expect("b")), vec!["b.txt"]); + assert_eq!(names(&next(&receiver, "b")), vec!["b.txt"]); assert!(matches!( - receiver.recv().expect("the desync"), + next(&receiver, "the desync"), Notification::Desync { cause: DesyncCause::QueueFull, .. @@ -1101,7 +1101,7 @@ fn a_receiver_that_never_asks_allocates_no_doorbell() { // kernel object it never waits on. let (sender, receiver) = channel(); deliver(&sender, batch(WatchId::from_raw(1), &["a.txt"])); - let _ = receiver.recv().expect("a notification"); + let _ = next(&receiver, "a notification"); assert!( receiver.shared.doorbell.get().is_none(), "the event must not exist until it is asked for" @@ -1360,7 +1360,7 @@ fn a_drained_queue_with_a_loss_owed_is_empty_but_still_has_something_to_take() { assert_eq!(sender.send(batch(watch, &["lost.txt"])), Delivery::Latched); assert_eq!( - names(&receiver.recv().expect("the queued batch")), + names(&next(&receiver, "the queued batch")), vec!["queued.txt"] ); @@ -1374,7 +1374,7 @@ fn a_drained_queue_with_a_loss_owed_is_empty_but_still_has_something_to_take() { ); assert!(matches!( - receiver.recv().expect("the synthesised loss report"), + next(&receiver, "the synthesised loss report"), Notification::Desync { cause: DesyncCause::QueueFull, .. @@ -1401,7 +1401,7 @@ fn has_pending_tracks_the_doorbell_exactly_through_a_latched_loss() { assert_eq!(sender.send(batch(watch, &["lost.txt"])), Delivery::Latched); assert_eq!(receiver.has_pending(), is_signalled(doorbell)); - receiver.recv().expect("the queued batch"); + next(&receiver, "the queued batch"); assert_eq!( receiver.has_pending(), is_signalled(doorbell), @@ -1410,7 +1410,7 @@ fn has_pending_tracks_the_doorbell_exactly_through_a_latched_loss() { assert!(is_signalled(doorbell), "the doorbell is still ringing"); assert!(receiver.is_empty(), "yet the queue reports itself empty"); - receiver.recv().expect("the loss report"); + next(&receiver, "the loss report"); assert_eq!(receiver.has_pending(), is_signalled(doorbell)); assert!(!is_signalled(doorbell), "and only now does it stop"); } @@ -1474,7 +1474,7 @@ fn a_parked_producer_is_prodded_at_the_slot_has_room_actually_becomes_true() { // free() == 1, latched == 1: the old edge fired here, but has_room is // still false, so a prod now is wasted -- and, worse, it is the *only* // one that ever comes. - receiver.recv().expect("first"); + next(&receiver, "first"); assert!( !sender.has_room(), "the freed slot is owed to the latch flush, not to a new notification" @@ -1488,7 +1488,7 @@ fn a_parked_producer_is_prodded_at_the_slot_has_room_actually_becomes_true() { // free() == 2, latched == 1: has_room becomes true, so the prod must land // here. The old edge skipped it and never fired again. - receiver.recv().expect("second"); + next(&receiver, "second"); assert!(sender.has_room(), "room genuinely exists now"); assert_eq!( producer.count(), @@ -1517,15 +1517,15 @@ fn draining_only_latched_reports_still_reaches_the_resume_edge() { } assert_eq!(receiver.latched(), 2); - receiver.recv().expect("first queued"); - receiver.recv().expect("second queued"); + next(&receiver, "first queued"); + next(&receiver, "second queued"); // Queue empty, free() == 2, latched == 2: still no room. assert!(!sender.has_room()); assert_eq!(producer.count(), 0); // Taking one synthesised report is what creates the room. assert!(matches!( - receiver.recv().expect("a synthesised loss report"), + next(&receiver, "a synthesised loss report"), Notification::Desync { cause: DesyncCause::QueueFull, .. From 065a8e86c6eec877205cd889d0953f9283a99ac8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 17:24:41 -0400 Subject: [PATCH 156/361] test(thread-ambient): close capture_set.rs and declared.rs mutation gaps Part of a fresh cargo-mutants run on this new crate (default features: 88 caught, 61 missed, 0 timeout, 91 unviable). This commit closes the two files' share -- 9 of the 61. capture_set.rs: two genuinely equivalent mutants, documented rather than chased. `IMPERSONATION`'s `1 << 0` swapped to `1 >> 0` produces the identical value (a shift by zero is its own inverse); `DEFAULT`'s `|` swapped to `^` cannot differ either, since the two bits it combines are disjoint by construction. Both confirmed by sabotage: the mutated crate's whole suite passes unchanged. The three real `Debug` gaps (`index > 0`'s comparison flipped three ways) survived because the existing test only checked that both aspect names appeared, which a misplaced or doubled separator does not disturb -- pinned the exact rendered string instead, including the single-aspect case where `index` is only ever 0. declared.rs: the same "every real host agrees with the wrong constant" shape recurs on `is_empty` (asserted only with two of three fields set, which cannot tell the correct `&&` from the one still-missing `||`) and on `DeclaredGuard::release`/`restore`'s `ok == 0` (every existing path only restores what installed cleanly). Redirection's revert is this crate's one deterministic failure -- there is no redirector at all in a 64-bit process -- so the guard is built directly from its private fields to reach it without going through `install` first. `Drop` for the guard and the private `release_background` helper had no test forcing an unreleased guard out of scope or calling the helper directly; both are exercised through memory priority, which always installs and restores for real. `DeclaredError` gained a `#[cfg(test)] pub(crate) synthetic` constructor (mirroring this crate's own `without_os_error` and the workspace's established `Completion::synthetic` precedent) so `raw_os_error`/`Display`/`source` could be tested without provoking a real Win32 failure. All nine sabotage-verified: each mutation re-injected on its own line, the new test fails with it and passes without it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/capture_set.rs | 11 ++ .../src/capture_set/tests.rs | 21 +++ .../src/declared.rs | 13 ++ .../src/declared/tests.rs | 151 +++++++++++++++++- 4 files changed, 195 insertions(+), 1 deletion(-) diff --git a/crates/windows-thread-ambient-sys/src/capture_set.rs b/crates/windows-thread-ambient-sys/src/capture_set.rs index 0bd07d5a..725b32e3 100644 --- a/crates/windows-thread-ambient-sys/src/capture_set.rs +++ b/crates/windows-thread-ambient-sys/src/capture_set.rs @@ -49,6 +49,12 @@ use std::fmt; /// Internal to this crate and not a wire format, so the values carry no /// compatibility obligation; they exist so no bare literal appears in the logic. mod bit { + // `IMPERSONATION`'s shift is by zero, which makes `<<` and `>>` produce + // the identical value -- a mutation run will report the swap surviving, + // correctly: no test can distinguish a no-op from its own inverse. The + // other two shifts are by a nonzero amount and are not equivalent; the + // same mutation on either of them changes the bit position and is caught + // by every test that checks the three aspects occupy distinct bits. pub(super) const IMPERSONATION: u8 = 1 << 0; pub(super) const ERROR_MODE: u8 = 1 << 1; pub(super) const TRANSACTION: u8 = 1 << 2; @@ -149,6 +155,11 @@ impl CaptureSet { /// acquire by taking a default. Add [`TRANSACTION`](Self::TRANSACTION) when /// you mean it. pub const DEFAULT: Self = Self { + // `|` is provably equivalent to `^` here: `bit::IMPERSONATION` and + // `bit::ERROR_MODE` occupy distinct bit positions by construction, and + // every bitwise combinator agrees on disjoint operands. A mutation run + // will report the swap surviving; that is correct rather than a gap, + // and is recorded here so it is not re-investigated. bits: bit::IMPERSONATION | bit::ERROR_MODE, }; diff --git a/crates/windows-thread-ambient-sys/src/capture_set/tests.rs b/crates/windows-thread-ambient-sys/src/capture_set/tests.rs index 2d7960b3..0a1b7e5b 100644 --- a/crates/windows-thread-ambient-sys/src/capture_set/tests.rs +++ b/crates/windows-thread-ambient-sys/src/capture_set/tests.rs @@ -169,6 +169,27 @@ fn debug_names_the_aspects_rather_than_a_bit_pattern() { assert_eq!(format!("{:?}", CaptureSet::NONE), "CaptureSet(none)"); } +#[test] +fn debug_places_the_separator_exactly_between_aspects_never_before_or_doubled() { + // `index > 0` guards the ", " separator so it appears strictly *between* + // aspects, never before the first or after the last. Checking only that + // both names appear (as the test above does) cannot catch a stray or + // missing separator: a mutation to `==`/`<`/`>=` still leaves both + // substrings present, just misplaced or duplicated. Pinning the exact + // string is what makes the placement itself an assertion. + assert_eq!( + format!("{:?}", CaptureSet::DEFAULT), + "CaptureSet(impersonation, error mode)" + ); + // A single-aspect set is the other edge: `index` is only ever 0, so + // `==`/`>=` (both true at 0) would wrongly prepend a separator that `>` + // (false at 0) does not. + assert_eq!( + format!("{:?}", CaptureSet::IMPERSONATION), + "CaptureSet(impersonation)" + ); +} + #[test] fn an_aspect_converts_into_its_singleton_set() { for aspect in CapturableAspect::EVERY { diff --git a/crates/windows-thread-ambient-sys/src/declared.rs b/crates/windows-thread-ambient-sys/src/declared.rs index 4f2082ce..75cfb45b 100644 --- a/crates/windows-thread-ambient-sys/src/declared.rs +++ b/crates/windows-thread-ambient-sys/src/declared.rs @@ -252,6 +252,19 @@ impl DeclaredError { } } + /// Builds a `DeclaredError` reporting a specific outcome, for tests + /// elsewhere in the crate that need one without provoking a real Win32 + /// failure -- redirection's is only reachable in a 32-bit process, and + /// memory priority and background mode have no known failure mode at all + /// on a real thread. + #[cfg(test)] + pub(crate) fn synthetic(aspect: DeclaredAspect, os_error: Option) -> Self { + Self { + aspect, + source: os_error.map(io::Error::from_raw_os_error), + } + } + /// Which aspect failed. #[must_use] pub const fn aspect(&self) -> DeclaredAspect { diff --git a/crates/windows-thread-ambient-sys/src/declared/tests.rs b/crates/windows-thread-ambient-sys/src/declared/tests.rs index 1550162a..7c8371d7 100644 --- a/crates/windows-thread-ambient-sys/src/declared/tests.rs +++ b/crates/windows-thread-ambient-sys/src/declared/tests.rs @@ -8,7 +8,10 @@ //! honest shape: a caller who asked for redirection to be disabled and silently //! did not get it would be reading a different filesystem than it believes. -use super::{BackgroundMode, Declared, DeclaredAspect, MemoryPriority, Wow64Redirection}; +use super::{ + BackgroundMode, Declared, DeclaredAspect, DeclaredError, DeclaredGuard, MemoryPriority, + Wow64Redirection, +}; /// Is this a 64-bit process, where redirection does not exist? const SIXTY_FOUR_BIT: bool = cfg!(target_pointer_width = "64"); @@ -20,6 +23,34 @@ fn none_declares_nothing() { assert_eq!(declared, Declared::default()); } +#[test] +fn is_empty_requires_every_field_to_be_unset_not_merely_one() { + // `is_empty` is a chain of three `&&`. `builders_accumulate_independently` + // below sets *two* fields at once, which cannot distinguish the correct + // `&&` from an `||` spliced into either junction: with two of three fields + // set, `false && (false || true)` and `false && (true || false)` both + // still land on `false` by coincidence. Setting exactly one field at a + // time is what a wrong operator cannot survive. + assert!( + !Declared::none() + .with_memory_priority(MemoryPriority::Low) + .is_empty(), + "memory priority alone must not read as empty" + ); + assert!( + !Declared::none() + .with_background_mode(BackgroundMode::Begin) + .is_empty(), + "background mode alone must not read as empty" + ); + assert!( + !Declared::none() + .with_wow64_redirection(Wow64Redirection::Disabled) + .is_empty(), + "redirection alone must not read as empty" + ); +} + #[test] fn builders_accumulate_independently() { let declared = Declared::none() @@ -241,3 +272,121 @@ fn declared_is_copy_and_send_so_it_can_reach_a_worker() { assert_send_copy::(); assert_send_copy::(); } + +// --- DeclaredError's own error-trait surface ------------------------------ +// +// Nothing exercised `raw_os_error`, `Display`, or `Error::source` at all. +// `DeclaredError::synthetic` builds one directly rather than provoking a real +// failure, matching the module's own `without_os_error` constructor. + +#[test] +fn raw_os_error_reports_the_wrapped_code_or_none() { + let with_code = DeclaredError::synthetic(DeclaredAspect::MemoryPriority, Some(5)); + assert_eq!(with_code.raw_os_error(), Some(5)); + + let without_code = DeclaredError::synthetic(DeclaredAspect::MemoryPriority, None); + assert_eq!(without_code.raw_os_error(), None); +} + +#[test] +fn display_names_the_aspect_and_the_source_when_there_is_one() { + let with_code = DeclaredError::synthetic(DeclaredAspect::BackgroundMode, Some(5)); + let rendered = with_code.to_string(); + assert!( + rendered.contains("background processing mode"), + "got {rendered}" + ); + + let without_code = DeclaredError::synthetic(DeclaredAspect::Wow64Redirection, None); + let rendered = without_code.to_string(); + assert!(rendered.contains("WOW64"), "got {rendered}"); +} + +#[test] +fn source_is_present_only_when_an_os_error_was_wrapped() { + let with_code = DeclaredError::synthetic(DeclaredAspect::MemoryPriority, Some(5)); + assert!(std::error::Error::source(&with_code).is_some()); + + let without_code = DeclaredError::synthetic(DeclaredAspect::MemoryPriority, None); + assert!(std::error::Error::source(&without_code).is_none()); +} + +// --- DeclaredGuard's release/restore, and Drop -------------------------- + +#[test] +fn release_reports_a_genuine_restore_failure() { + // `DeclaredGuard::release -> Ok(())` and `restore`'s `ok == 0` survived: + // every existing test only ever restores aspects that install cleanly, so + // the happy path and the constant `Ok(())` agree everywhere reachable + // through the public API. Redirection's revert is the one failure this + // crate can provoke deterministically -- there is no redirector at all in + // a 64-bit process -- so the guard is built directly with a redirection + // to revert, bypassing `install` entirely. + if !SIXTY_FOUR_BIT { + eprintln!("skipped: relies on redirection's revert failing"); + return; + } + let guard = DeclaredGuard { + background: None, + memory: None, + redirection: Some(std::ptr::null_mut()), + released: false, + }; + let error = guard + .release() + .expect_err("reverting redirection has nothing to revert in a 64-bit process"); + assert_eq!(error.aspect(), DeclaredAspect::Wow64Redirection); +} + +#[test] +fn dropping_the_guard_restores_memory_priority_even_without_release() { + // `::drop -> ()` survived: every existing + // test releases explicitly through `with_applied`, so nothing ever let a + // guard fall out of scope unreleased. This is a real restore, not a + // synthetic one -- memory priority always installs and restores cleanly. + let before = MemoryPriority::current().expect("readable"); + { + let guard = Declared::none() + .with_memory_priority(MemoryPriority::Low) + .install() + .expect("install"); + assert_eq!( + MemoryPriority::current().expect("readable"), + MemoryPriority::Low + ); + drop(guard); + } + assert_eq!( + MemoryPriority::current().expect("readable"), + before, + "drop did not restore memory priority" + ); +} + +#[test] +fn release_background_reenters_the_inverse_mode() { + // `release_background -> ()` survived: nothing called this private helper + // on its own, only through `install`'s rollback path, which a real host + // never reaches (memory priority never fails to install). + let before = MemoryPriority::current().expect("readable"); + BackgroundMode::Begin + .install() + .expect("enter background mode"); + assert_ne!( + MemoryPriority::current().expect("readable"), + before, + "entering background mode did not move memory priority" + ); + + super::release_background(Some(BackgroundMode::Begin)); + assert_eq!( + MemoryPriority::current().expect("readable"), + before, + "release_background did not restore memory priority" + ); + + // The `None` branch is a real no-op, not merely untested: calling it again + // must not disturb the now-restored state. + super::release_background(None); + assert_eq!(MemoryPriority::current().expect("readable"), before); +} From 3d2d13f33a23b653ab95c46c1e996c87bc1ec5ee Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 17:27:30 -0400 Subject: [PATCH 157/361] test(thread-ambient): close error_mode.rs mutation gaps Continues the cargo-mutants sweep on this new crate; error_mode.rs's share of the original 61 missed. Documents \SUPPORTED\'s two \|\ vs \^\ mutants as equivalent, same reasoning as capture_set.rs's: \SEM_FAILCRITICALERRORS\, \SEM_NOGPFAULTERRORBOX\ and \SEM_NOOPENFILEERRORBOX\ are pairwise disjoint bits (0x0001, 0x0002, 0x8000), so no combinator can distinguish \|\ from \^\. Confirmed by sabotage: the mutated crate's whole error_mode suite passes unchanged either way. \ApplyError\ and \RestoreError\ had no test at all for \ aw_os_error\, \Display\, or \Error::source\ -- every existing test only reaches the happy \pply\/\ elease\ path, and this crate installs only bits Windows accepts, so there is no reachable failure through the public API. Both gained a \#[cfg(test)] pub(crate) synthetic\ constructor, matching declared.rs's \DeclaredError::synthetic\ from the previous commit. \ErrorModeGuard::release -> Ok(())\ survived for the same reason: every existing release restores a mode Windows itself reported for this thread, which always succeeds. \SetThreadErrorMode\'s one documented rejection -- \SEM_NOALIGNMENTFAULTEXCEPT\ -- is exactly the bit \ThreadErrorMode\ refuses to represent, so the guard is built directly with the raw, unrepresentable value rather than through \pply\, and \ elease\ is asked to restore it. All four real gaps sabotage-verified: each mutation re-injected on its own line, the new test fails with it and passes without it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/error_mode.rs | 28 +++++++++ .../src/error_mode/tests.rs | 58 ++++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/windows-thread-ambient-sys/src/error_mode.rs b/crates/windows-thread-ambient-sys/src/error_mode.rs index c47f0819..f5040da5 100644 --- a/crates/windows-thread-ambient-sys/src/error_mode.rs +++ b/crates/windows-thread-ambient-sys/src/error_mode.rs @@ -59,6 +59,12 @@ use windows_sys::Win32::System::Diagnostics::Debug::{ }; /// Every bit this crate will place in a [`ThreadErrorMode`]. +/// +/// `SEM_FAILCRITICALERRORS` (0x0001), `SEM_NOGPFAULTERRORBOX` (0x0002) and +/// `SEM_NOOPENFILEERRORBOX` (0x8000) are pairwise disjoint bits, so combining +/// them with `^` instead of `|` produces the identical value. A mutation run +/// will report both swaps surviving; that is correct rather than a gap, and +/// is recorded here so it is not re-investigated. const SUPPORTED: THREAD_ERROR_MODE = SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX; @@ -233,6 +239,18 @@ impl ApplyError { pub fn raw_os_error(&self) -> Option { self.source.raw_os_error() } + + /// Builds an `ApplyError` reporting a specific outcome, for tests that + /// need one without a real `SetThreadErrorMode` failure to provoke -- + /// every bit this crate ever installs is one Windows accepts, so there is + /// no reachable failure through the public API at all. + #[cfg(test)] + pub(crate) fn synthetic(requested: ThreadErrorMode, os_error: i32) -> Self { + Self { + requested, + source: io::Error::from_raw_os_error(os_error), + } + } } impl fmt::Display for ApplyError { @@ -270,6 +288,16 @@ impl RestoreError { pub fn raw_os_error(&self) -> Option { self.source.raw_os_error() } + + /// Builds a `RestoreError` reporting a specific outcome, for tests that + /// need one without a genuine restore failure to provoke. + #[cfg(test)] + pub(crate) fn synthetic(unrestored: THREAD_ERROR_MODE, os_error: i32) -> Self { + Self { + unrestored, + source: io::Error::from_raw_os_error(os_error), + } + } } impl fmt::Display for RestoreError { diff --git a/crates/windows-thread-ambient-sys/src/error_mode/tests.rs b/crates/windows-thread-ambient-sys/src/error_mode/tests.rs index 15a8fe5f..d689723f 100644 --- a/crates/windows-thread-ambient-sys/src/error_mode/tests.rs +++ b/crates/windows-thread-ambient-sys/src/error_mode/tests.rs @@ -11,7 +11,7 @@ use windows_sys::Win32::System::Diagnostics::Debug::{ GetThreadErrorMode, SEM_NOALIGNMENTFAULTEXCEPT, }; -use super::{ThreadErrorMode, UnsupportedBits}; +use super::{ApplyError, ErrorModeGuard, RestoreError, ThreadErrorMode, UnsupportedBits}; /// The live thread error mode, read straight from Win32. fn live() -> u32 { @@ -212,3 +212,59 @@ fn a_worker_thread_starts_with_no_bits_set() { let worker = std::thread::spawn(live).join().expect("no panic"); assert_eq!(worker, 0); } + +// --- ApplyError's and RestoreError's own error-trait surface -------------- +// +// Nothing exercised `raw_os_error`, `Display`, or `Error::source` on either +// type: every existing test only reaches the happy `apply`/`release` path, and +// this crate installs only bits Windows accepts, so there is no reachable +// failure through the public API to provoke one. `synthetic` builds one +// directly instead. + +#[test] +fn apply_error_reports_the_wrapped_code_and_names_the_mode() { + let error = ApplyError::synthetic(ThreadErrorMode::FAIL_CRITICAL_ERRORS, 5); + assert_eq!(error.raw_os_error(), Some(5)); + assert_eq!(error.requested(), ThreadErrorMode::FAIL_CRITICAL_ERRORS); + let rendered = error.to_string(); + assert!(rendered.contains("0x0001"), "got {rendered}"); + assert!( + std::error::Error::source(&error).is_some(), + "the wrapped io::Error must be reported as the source" + ); +} + +#[test] +fn restore_error_reports_the_wrapped_code_and_the_unrestored_bits() { + let error = RestoreError::synthetic(ThreadErrorMode::NO_GP_FAULT_ERROR_BOX.bits(), 5); + assert_eq!(error.raw_os_error(), Some(5)); + assert_eq!( + error.unrestored_bits(), + ThreadErrorMode::NO_GP_FAULT_ERROR_BOX.bits() + ); + let rendered = error.to_string(); + assert!(rendered.contains("0x0002"), "got {rendered}"); + assert!( + std::error::Error::source(&error).is_some(), + "the wrapped io::Error must be reported as the source" + ); +} + +#[test] +fn release_reports_a_genuine_restore_failure() { + // `ErrorModeGuard::release -> Ok(())` survived: every existing test only + // ever restores a mode Windows itself reported for this thread, which + // always succeeds. The one bit `SetThreadErrorMode` is documented to + // reject -- `SEM_NOALIGNMENTFAULTEXCEPT` -- is exactly what + // `ThreadErrorMode` refuses to represent (see the module documentation), + // so the guard is built directly with the raw, unrepresentable value + // rather than going through `apply`. + let guard = ErrorModeGuard { + previous: SEM_NOALIGNMENTFAULTEXCEPT, + released: false, + }; + let error = guard + .release() + .expect_err("the alignment bit is rejected per thread"); + assert_eq!(error.unrestored_bits(), SEM_NOALIGNMENTFAULTEXCEPT); +} From dccd980bc150f50c3d46a1a138b3ce7eedaf02f4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 17:30:53 -0400 Subject: [PATCH 158/361] test(thread-ambient): close transaction.rs mutation gaps, and record one that stays Continues the cargo-mutants sweep on this new crate. TransactionError gained the same \#[cfg(test)] pub(crate) synthetic\ constructor as DeclaredError and error_mode's ApplyError/RestoreError, closing its \ aw_os_error\/\Display\/\Error::source\ gaps the same way. \TransactionGuard::release -> Ok(())\, its \Drop\ replaced with an empty body, and the \!\ deleted from that same \Drop\ all survived for a subtler reason than the others: an ordinary test thread already carries no transaction, and \Captured::Absent\ installs "no transaction" too, so the entry state and the installed state render identically through \live()\ -- a guard that restores nothing is indistinguishable from one that genuinely does, regardless of what the test checks. A real transaction as the entry state (via this file's own \Transaction::new\/\while_transacted\) is what makes "restored" and "still cleared" two different, checkable things; the first attempt at this test used the ambient no-transaction baseline and, as predicted, did not catch any of the three on the first sabotage pass -- caught by re-running the sabotage rather than trusting the first green result. \is_supported -> true\ is recorded rather than chased. \ tdll.dll\'s \RtlGetCurrentTransaction\/\RtlSetCurrentTransaction\ are undocumented but long-stable exports present on every Windows version this crate has been tested against, so the honest answer and the constant agree on every reachable host, and \KTM\'s cache is a process-global \OnceLock\ with no seam to inject a disagreement without a production-code change out of scope for a test pass. All real gaps sabotage-verified: each mutation re-injected on its own line, the new test fails with it and passes without it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/transaction.rs | 11 +++ .../src/transaction/tests.rs | 99 ++++++++++++++++++- 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/crates/windows-thread-ambient-sys/src/transaction.rs b/crates/windows-thread-ambient-sys/src/transaction.rs index ee363754..5fe38111 100644 --- a/crates/windows-thread-ambient-sys/src/transaction.rs +++ b/crates/windows-thread-ambient-sys/src/transaction.rs @@ -209,6 +209,17 @@ impl TransactionError { pub fn raw_os_error(&self) -> Option { self.source.as_ref().and_then(io::Error::raw_os_error) } + + /// Builds a `TransactionError` reporting a specific outcome, for tests + /// elsewhere in the crate that need one without provoking a real ktmw32 + /// or `RtlSetCurrentTransaction` failure. + #[cfg(test)] + pub(crate) fn synthetic(failure: TransactionFailure, os_error: Option) -> Self { + Self { + failure, + source: os_error.map(io::Error::from_raw_os_error), + } + } } impl fmt::Display for TransactionError { diff --git a/crates/windows-thread-ambient-sys/src/transaction/tests.rs b/crates/windows-thread-ambient-sys/src/transaction/tests.rs index 570a8de1..5ece5779 100644 --- a/crates/windows-thread-ambient-sys/src/transaction/tests.rs +++ b/crates/windows-thread-ambient-sys/src/transaction/tests.rs @@ -12,8 +12,8 @@ use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; use super::{ - Captured, TransactionContext, TransactionFailure, capture, is_supported, system_proc, - with_applied, + Captured, TransactionContext, TransactionError, TransactionFailure, capture, install, + is_supported, system_proc, with_applied, }; type CreateTransactionFn = unsafe extern "system" fn( @@ -255,3 +255,98 @@ fn a_context_is_send_so_it_can_reach_a_worker() { assert_send::(); assert_send::>(); } + +// --- TransactionError's own error-trait surface -------------------------- +// +// Nothing exercised `raw_os_error`, `Display`, or `Error::source` at all. +// `TransactionError::synthetic` builds one directly, matching this module's +// own private `error` helper's shape. + +#[test] +fn raw_os_error_reports_the_wrapped_code_or_none() { + let with_code = TransactionError::synthetic(TransactionFailure::Install, Some(5)); + assert_eq!(with_code.raw_os_error(), Some(5)); + + let without_code = TransactionError::synthetic(TransactionFailure::Unsupported, None); + assert_eq!(without_code.raw_os_error(), None); +} + +#[test] +fn display_names_the_failing_stage_and_the_source_when_there_is_one() { + let with_code = TransactionError::synthetic(TransactionFailure::Duplicate, Some(5)); + let rendered = with_code.to_string(); + assert!(rendered.contains("duplicated"), "got {rendered}"); + + let without_code = TransactionError::synthetic(TransactionFailure::Unsupported, None); + let rendered = without_code.to_string(); + assert!(rendered.contains("ktmw32.dll"), "got {rendered}"); +} + +#[test] +fn source_is_present_only_when_an_os_error_was_wrapped() { + let with_code = TransactionError::synthetic(TransactionFailure::Install, Some(5)); + assert!(std::error::Error::source(&with_code).is_some()); + + let without_code = TransactionError::synthetic(TransactionFailure::Unsupported, None); + assert!(std::error::Error::source(&without_code).is_none()); +} + +// --- is_supported --------------------------------------------------------- +// +// `is_supported -> true` survived, and no test in this file forces it. That +// is not an oversight: `ntdll.dll`'s `RtlGetCurrentTransaction` and +// `RtlSetCurrentTransaction` are undocumented but long-stable exports present +// on every Windows version this crate has ever been tested against, so the +// honest answer and the constant agree on every reachable host. `KTM`'s cache +// is a process-global `OnceLock` with no injectable seam, and the one test +// that reads it (`the_entry_points_resolve_on_this_system`, above) already +// asserts the true case explicitly -- proving the constant right rather than +// wrong is the most this environment can show. Recorded so a later run does +// not re-investigate it as a gap. + +#[test] +fn release_reports_a_genuine_restore_failure_and_restores_on_drop_even_without_it() { + // `TransactionGuard::release -> Ok(())`, `::drop -> ()`, and the `!` deleted from that same + // `drop` all survived: an ordinary test thread starts with no transaction, + // and `Captured::Absent` installs "no transaction" too, so the entry state + // and the installed state render identically through `live()` -- a guard + // that restores nothing is indistinguishable from one that does. A real + // transaction as the entry state is what makes "restored" and "still + // cleared" two different, checkable things. + let Some(transaction) = Transaction::new() else { + eprintln!("skipped: this system cannot create a transaction"); + return; + }; + while_transacted(&transaction, || { + let before = live(); + assert!(!super::is_none_sentinel(before), "precondition: transacted"); + + // `release`'s own restore, checked by state rather than by return + // value. + let guard = install(&Captured::Absent).expect("install"); + assert!( + super::is_none_sentinel(live()), + "Absent did not clear the thread's transaction" + ); + guard + .release() + .expect("releasing an installed Absent must succeed"); + assert_eq!( + live(), + before, + "release did not restore the entry transaction" + ); + + // `Drop`'s restore, on a guard that is never released explicitly. + { + let _guard = install(&Captured::Absent).expect("install"); + assert!(super::is_none_sentinel(live())); + } + assert_eq!( + live(), + before, + "dropping the guard without releasing did not restore the entry transaction" + ); + }); +} From bf68c43e4db2ee1a21b901f75bc40d5dd88f8509 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 17:36:00 -0400 Subject: [PATCH 159/361] test(thread-ambient): close state.rs mutation gaps, the last file in this sweep Finishes the cargo-mutants sweep on this new crate: capture_set.rs, declared.rs, error_mode.rs, and transaction.rs were closed in the four preceding commits; this is state.rs's share. \CaptureError::raw_os_error\'s four constant mutants needed two constructed cases together: the \ErrorMode\ arm hardcodes \None\ (there is no OS code for an unrepresentable bit), which alone rules out \Some(0)\/\Some(1)\/ \Some(-1)\ but agrees with an unconditional \-> None\; a \Transaction\ arm built with \TransactionError::synthetic\ supplies the \Some(5)\ that rules that one out too. \state::ApplyError\ needed only one constructed variant, since every arm forwards unconditionally -- one non-trivial \Some\ rules out all four constants and proves \Display\/\source\'s real bodies ran. \RestoreReport::is_clean\'s two \&&\ and its three accessors are the same "asserted with two of three fields set, or only through a real clean \with_applied\" shape as \Declared::is_empty\'s gap: one field set at a time is what a wrong operator or a wrong constant cannot survive. The three accessors are covered by the same three constructions, using the \RestoreError\/\DeclaredError\/\TransactionError\ synthetic constructors added over the last three commits. \ elease_error_mode -> ()\ is recorded as equivalent rather than closed: it is documented in place, at the mutation site, because \ErrorModeGuard\'s own \Drop\ restores identically to what \ elease\ does the instant an unreleased guard is discarded, and this function discards \ elease\'s Result either way -- so the mutant's behaviour and the real body's agree in every case reachable through this function, confirmed by running the existing behavioural test against it. The test is kept anyway, reframed as pinning the function's intended behaviour rather than as a mutation kill it cannot be. All seven real gaps sabotage-verified: each mutation re-injected on its own line, the new test fails with it and passes without it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-thread-ambient-sys/src/state.rs | 10 ++ .../src/state/tests.rs | 154 +++++++++++++++++- 2 files changed, 161 insertions(+), 3 deletions(-) diff --git a/crates/windows-thread-ambient-sys/src/state.rs b/crates/windows-thread-ambient-sys/src/state.rs index 628fdf18..152b55a4 100644 --- a/crates/windows-thread-ambient-sys/src/state.rs +++ b/crates/windows-thread-ambient-sys/src/state.rs @@ -421,6 +421,16 @@ impl AmbientState { } fn release_error_mode(guard: Option) { + // A mutation run will report this whole body as removable, and it is + // right in substance though not in spelling: `ErrorModeGuard`'s own + // `Drop` restores exactly what `release` does whenever the guard was + // never released, and the result is discarded either way -- so replacing + // this body with a bare `let _ = guard;` still restores the mode via the + // guard's fallback the instant it is dropped at the end of that + // statement. Kept as an explicit call rather than relying on the + // fallback because saying "release this now" at the point the rollback + // happens is worth more than leaving it to be re-derived from a Drop impl + // this function never mentions. if let Some(guard) = guard { // Best effort: an install failed, so this path already has an error to // report and a second one would displace it. diff --git a/crates/windows-thread-ambient-sys/src/state/tests.rs b/crates/windows-thread-ambient-sys/src/state/tests.rs index 99f5a813..46bc1a30 100644 --- a/crates/windows-thread-ambient-sys/src/state/tests.rs +++ b/crates/windows-thread-ambient-sys/src/state/tests.rs @@ -6,10 +6,11 @@ use windows_sys::Win32::Foundation::{CloseHandle, ERROR_NO_TOKEN, HANDLE}; use windows_sys::Win32::Security::TOKEN_QUERY; use windows_sys::Win32::System::Threading::{GetCurrentThread, OpenThreadToken}; -use super::AmbientState; +use super::{AmbientState, ApplyError, ApplyFailure, CaptureError, CaptureFailure, RestoreReport}; use crate::capture_set::{CapturableAspect, CaptureSet}; -use crate::declared::MemoryPriority; +use crate::declared::{DeclaredAspect, DeclaredError, MemoryPriority}; use crate::error_mode::ThreadErrorMode; +use crate::transaction::{TransactionError, TransactionFailure}; use crate::{Captured, Declared}; /// Whether the calling thread currently carries an impersonation token. @@ -220,7 +221,6 @@ fn capture_does_not_disturb_the_thread_it_reads() { // --- composition (M23.3) --------------------------------------------------- use crate::declared::{BackgroundMode, Wow64Redirection}; -use crate::state::ApplyFailure; #[test] fn applying_an_empty_state_runs_the_operation_and_touches_nothing() { @@ -503,3 +503,151 @@ fn an_uncaptured_aspect_leaves_the_running_threads_value_alone() { "an uncaptured aspect was overwritten instead of left alone" ); } + +// --- CaptureError::raw_os_error --------------------------------------------- +// +// Every existing test only reaches `CaptureError` by way of a real capture +// failure, none of which this suite can provoke on demand. `raw_os_error`'s +// three constant mutants (`Some(0)`, `Some(1)`, `Some(-1)`) are ruled out by +// the `ErrorMode` arm alone, which hardcodes `None` (there is no OS code for +// an unrepresentable bit) -- but that same case cannot rule out the fourth, +// `-> None` unconditionally, since it already agrees there. The `Transaction` +// arm, built with `TransactionError::synthetic`, is what supplies a `Some` +// case to rule that one out too. + +#[test] +fn raw_os_error_forwards_through_the_failing_aspect_or_reports_none() { + let unsupported_bits = ThreadErrorMode::from_bits(0x0100).expect_err("0x0100 is not a mode"); + let error_mode_failure = CaptureError { + failure: CaptureFailure::ErrorMode(unsupported_bits), + }; + assert_eq!( + error_mode_failure.raw_os_error(), + None, + "an unsupported thread error mode carries no Win32 code to report" + ); + + let transaction_failure = CaptureError { + failure: CaptureFailure::Transaction(TransactionError::synthetic( + TransactionFailure::Duplicate, + Some(5), + )), + }; + assert_eq!(transaction_failure.raw_os_error(), Some(5)); +} + +// --- release_error_mode ------------------------------------------------------ + +#[test] +fn release_error_mode_restores_a_real_guard() { + // Not a mutation-kill: `release_error_mode -> ()` is a documented + // equivalent mutant (see the function itself) because `ErrorModeGuard`'s + // own `Drop` restores identically the instant an unreleased guard is + // discarded. This test still pins the function's real, intended + // behaviour -- calling it explicitly restores the mode -- rather than + // leaving it unverified because the alternate route happens to agree. + let entry = ThreadErrorMode::capture().expect("representable"); + let guard = ThreadErrorMode::NO_OPEN_FILE_ERROR_BOX + .apply() + .expect("install"); + assert_eq!( + ThreadErrorMode::capture().expect("representable"), + ThreadErrorMode::NO_OPEN_FILE_ERROR_BOX + ); + + super::release_error_mode(Some(guard)); + assert_eq!( + ThreadErrorMode::capture().expect("representable"), + entry, + "release_error_mode did not restore the entry mode" + ); + + // The `None` branch is a real no-op, not merely untested. + super::release_error_mode(None); + assert_eq!(ThreadErrorMode::capture().expect("representable"), entry); +} + +// --- RestoreReport ----------------------------------------------------------- +// +// `is_clean` chains three `&&`, and both operators survived: every existing +// test either sets no field (all `is_none()` agree with any operator) or +// reaches `RestoreReport` only through a real, clean `with_applied` call. One +// field set at a time is what a wrong operator cannot survive; the three +// accessors are exercised the same way `Declared::is_empty`'s gap was, and the +// `error_mode`/`declared`/`transaction` accessors are covered alongside them +// since building a non-empty report is the same construction either test needs. + +#[test] +fn is_clean_and_the_accessors_require_every_field_to_be_unset() { + let empty = RestoreReport::default(); + assert!(empty.is_clean()); + assert!(empty.error_mode().is_none()); + assert!(empty.declared().is_none()); + assert!(empty.transaction().is_none()); + + let only_error_mode = RestoreReport { + error_mode: Some(crate::error_mode::RestoreError::synthetic(0x0001, 5)), + declared: None, + transaction: None, + }; + assert!( + !only_error_mode.is_clean(), + "an error-mode failure alone must not read as clean" + ); + assert!(only_error_mode.error_mode().is_some()); + + let only_declared = RestoreReport { + error_mode: None, + declared: Some(DeclaredError::synthetic( + DeclaredAspect::MemoryPriority, + Some(5), + )), + transaction: None, + }; + assert!( + !only_declared.is_clean(), + "a declared failure alone must not read as clean" + ); + assert!(only_declared.declared().is_some()); + + let only_transaction = RestoreReport { + error_mode: None, + declared: None, + transaction: Some(TransactionError::synthetic( + TransactionFailure::Install, + Some(5), + )), + }; + assert!( + !only_transaction.is_clean(), + "a transaction failure alone must not read as clean" + ); + assert!(only_transaction.transaction().is_some()); +} + +// --- state::ApplyError's own error-trait surface ---------------------------- +// +// Nothing exercised this type's `raw_os_error`, `Display`, or `Error::source` +// directly -- only `ApplyFailure::Declared` reaching a real test through +// `a_failing_aspect_releases_the_ones_already_installed`, which never calls +// any of the three. One synthetic variant is enough: every arm forwards +// unconditionally, so a single `Some(5)` rules out all four `raw_os_error` +// constants at once, and any one working arm proves `Display`/`source`'s real +// bodies ran. + +#[test] +fn apply_error_forwards_through_the_failing_aspect() { + let error = ApplyError { + failure: ApplyFailure::Declared(DeclaredError::synthetic( + DeclaredAspect::MemoryPriority, + Some(5), + )), + }; + assert_eq!(error.raw_os_error(), Some(5)); + let rendered = error.to_string(); + assert!( + rendered.contains("applying the ambient state failed"), + "got {rendered}" + ); + assert!(std::error::Error::source(&error).is_some()); +} From 97ddc4692751bef2222f2b13e79986cf29089ebf Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 17:39:16 -0400 Subject: [PATCH 160/361] test(file-watcher): close the queue.rs gaps bounded waiting exposed Completing the M15.6 sweep left `missed` as the only column that indicates a real gap, and adjudicating the residue found three. `freed_resumers`' `!took_one ||` guard: `&&` binds tighter, so the mutant reads `(!took_one && resumers.is_empty()) || room != 1`, which differs only when a take that took nothing lands on the edge. `Receiver::try_recv` is the sole caller that can report `took_one == false`, so the mutant lets an empty poll prod a parked producer -- waking it with no room to use. `Debug for Receiver`: both an empty body and an inverted `disconnected` flag survived. This impl is the only outside view of the queue's occupancy and is what someone diagnosing a stalled watcher reads; an inverted flag is worse than no flag, because it misleads at the moment it is consulted. The new test asserts the rendered string whole. `Debug for Sender`'s empty body folds into the existing opaque-handle test, which already carried this rationale. Each was verified by re-injecting the mutant and observing a red suite, not by reasoning. The remaining 4 missed mutants are all in `StandingHold::drop`, whose body is unreachable; that is M15.1, an engineer decision rather than a test gap. Also spawns M15.7 for what the sweep measured but cannot fix with tests: 14 of the run's 20 minutes are mutants scored `timeout` that were in fact already detected, between 4 and 132 tests having failed before the kill. The lever is the test-side wait budget, and that trade against flake-resistance is the engineer's to make. Completed item: M15.6: Finish converting `queue/tests.rs` to bounded waiting, so a broken wake fails instead of hanging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 41 ++++++----- .../COMPLETED-CHECKLIST.md | 43 +++++++++++ .../windows-file-watcher/src/queue/tests.rs | 73 +++++++++++++++++++ 3 files changed, 137 insertions(+), 20 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index bec27d6d..7fcb9d24 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -239,26 +239,27 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- mutant build. The test binary's filename is derived from the target and features rather than its contents, which is why every report names the same `.exe` and why that name alone proves nothing. -- [ ] **M15.6** -- Finish converting `queue/tests.rs` to bounded waiting, so a broken wake fails - instead of hanging. **Partly done, and the remainder is a sweep rather than a puzzle.** - **The finding.** 16 of 106 mutants in `queue.rs` are recorded as `timeout`, which at 120s each is - 32 minutes of a 32.9-minute sweep -- roughly half the wall clock at `-j 2`, and far more than the - crashes cost. Every one is in a blocking path (`Drop for Sender`, `Drop for Reservation`, `recv`, - `is_empty`, `latch`). The tests *do* detect these defects; they detect them by hanging, which is the - worst available shape: it wedges the suite, reports nothing about what broke, and cargo-mutants files - it as `timeout` -- neither counted as caught nor visible as a gap. - **Done so far.** Two helpers with the reasoning attached: `next` (bounded `recv_timeout` where an item - is owed) and `assert_stream_ended` (bounded, and paired with `is_disconnected` because `recv_timeout` - alone answers `None` both for "ended" and "nothing came"). Eight sites converted. Verified: the - `senders == 0` mutant went from a 120s timeout to **caught in 8s**. - **Not done.** The `senders -= 1` mutants still hang, and chasing them one test at a time did not - converge -- each fix surfaced a different test as the next blocker, because the file uses unbounded - `recv()` in roughly 47 places and *any* of them will hang when the wake is broken. It is a convention - of the file, not a defect in one test, and the remaining work is to apply the two helpers across the - rest rather than to reason about which test is next. - **One caution learned the hard way:** `--test-threads=1` output is buffered, so the last line printed - is not reliably the test that hung. Use `--nocapture`, or run the suspect test with `--exact` and time - it. Two of the three diagnoses made without that were wrong. +- [x] **M15.6** -- Converted `queue/tests.rs` to bounded waiting, so a broken wake fails instead of hanging. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m156) + +- [ ] **M15.7** -- Decide the test-side wait budget, so a mutation sweep is not dominated by tests that + correctly fail slowly. **This is a throughput decision, not a test gap -- do not close it by writing tests.** + **The measurement.** After M15.6, a full `queue.rs` sweep is 124 mutants in 20 minutes, and **14 x 67s = + 15.6 minutes of that is mutants scored `timeout`**. Every one of those 14 was already detected: between 4 + and 132 tests had `FAILED` before cargo-mutants killed the run. The kill happens because the suite exceeds + 3x the baseline, and it exceeds it because dozens of bounded waits each burn their full budget on the way + to failing. + **Where the budget lives.** `NOTIFY_TIMEOUT` in [src/watcher/tests.rs](src/watcher/tests.rs) is + `Duration::from_secs(30)`, plus several 5s and one 20s bound. Those numbers are generous on purpose -- + they are what keeps the suite from flaking on a loaded machine -- so lowering them trades sweep throughput + against exactly that robustness. That trade is the decision, and it is the engineer's. + **The options, none free.** (a) Lower `NOTIFY_TIMEOUT` and accept more flake risk under load. (b) Raise + `--timeout-multiplier` in [tools/run-mutants.ps1](../../tools/run-mutants.ps1) so a suite full of slow + failures still fits, which makes a genuine wedge cost proportionally more. (c) Leave it, and read + `timeout` as "detected" rather than "unknown" -- correct today, but only because it was checked by hand, + and nothing keeps it true. + **Read `missed` as the gap column.** After M15.6, `timeout` no longer distinguishes a wedge from a slow + detection, so a sweep's `timeout` list has to be adjudicated by counting `FAILED` lines in each log before + it means anything. ## M-inf -- Horizon (ungated, post-v1) diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index 2f16336d..d3f32ab3 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -511,3 +511,46 @@ written down nowhere. Full results in [The M14 audit](DESIGN-NOTES.md#the-m14-au `!is_empty() || latched() > 0` by hand, is the same signal `has_room` gave before it was fixed. Fixed by publishing D-41's own predicate as `Receiver::has_pending` rather than redefining `is_empty`, with three regression tests that had no predecessor. + +## Moved 2026-09-01 -- M15.6: bounded waiting across queue/tests.rs + +### M15.6 -- Convert `queue/tests.rs` to bounded waiting, so a broken wake fails instead of hanging. *(completed 2026-09-01 17:37:46 -04:00)* + +**The sweep.** 40 unbounded `recv()` sites reduced to 1 -- the survivor is inside `assert_stream_ended` +itself, where the bound is the assertion. All 40 were the same `receiver.recv().expect(...)` shape on the +same variable, so the transform was mechanical; the one nearby `recv()` at line 1069 is an unrelated +`mpsc` receiver and was left alone. The transform cannot turn a passing test red, which is why it needed +no staging: a test that was already receiving what it expected still receives it within the bound. + +**The result, measured.** A confirming full-file sweep after the change: **124 mutants in 20 minutes, +78 caught / 8 missed / 14 timeout / 24 unviable**, against a pre-sweep baseline of 32.9 minutes. +The two mutants that previously hung for 120s each are now **caught in 32s**. + +**The finding that changes how `timeout` should be read.** Every one of the 14 remaining timeouts had +between **4 and 132 tests already `FAILED`** before cargo-mutants killed the run. None is a wedge and +none is a gap: the mutant is detected, and the run is killed only because a suite in which dozens of +bounded waits each burn their budget exceeds 3x the baseline. Bounded waiting converted the remaining +hangs into detected failures; what is left is a scoring artifact, and `missed` is now the only column +that indicates a real gap. The cost is real, though -- 14 x 67s is 15.6 minutes of the 20-minute run -- +and the lever on it is the test-side wait budget, not more test work (see M15.7). + +**Gaps closed while adjudicating the residue** (each verified by re-injecting the mutant and observing +a red suite, never by reasoning): + +- `freed_resumers`' `!took_one ||` guard. `&&` binds tighter than `||`, so the mutant reads + `(!took_one && resumers.is_empty()) || room != 1`, which differs only when a take that took *nothing* + lands on the edge -- `Receiver::try_recv` is the sole caller that can report `took_one == false`. The + mutant makes an empty poll prod a parked producer, so a producer behind a saturated queue would be + woken by pollers rather than by capacity, and woken with no room to use. Closed by + `a_take_that_took_nothing_is_not_a_crossing_and_does_not_prod`. +- `Debug for Receiver`, both an empty body and an inverted `disconnected` flag. This impl is the only + outside view of the queue's occupancy and is exactly what someone diagnosing a stalled watcher reads; + an inverted flag is worse than no flag, because it misleads at the moment it is consulted. Closed by + `a_formatted_receiver_reports_the_state_a_wedge_is_diagnosed_from`, which asserts the rendered string + whole. +- `Debug for Sender`, replaced with a body that writes nothing. Folded into the existing + `the_opaque_handles_name_themselves_when_formatted`, which already carried this exact rationale for + `StandingSlot` and `Reservation`. + +The remaining 4 missed mutants are all in `StandingHold::drop`, whose body is unreachable; that is M15.1 +and is an engineer decision, not a test gap. \ No newline at end of file diff --git a/crates/windows-file-watcher/src/queue/tests.rs b/crates/windows-file-watcher/src/queue/tests.rs index 019b5610..18bb4557 100644 --- a/crates/windows-file-watcher/src/queue/tests.rs +++ b/crates/windows-file-watcher/src/queue/tests.rs @@ -1539,6 +1539,45 @@ fn draining_only_latched_reports_still_reaches_the_resume_edge() { ); } +#[test] +fn a_take_that_took_nothing_is_not_a_crossing_and_does_not_prod() { + // `Receiver::try_recv` is the only caller that can report `took_one == + // false`, and the edge has to be a *crossing*: room that was already there + // is not a transition into having room. Without that guard, any poll of an + // empty queue that happens to sit on the edge prods again, so a producer + // parked behind a saturated queue would be woken by pollers rather than by + // capacity -- and the wake would carry no room with it. + let (sender, receiver) = bounded(1); + let watch = WatchId::from_raw(202); + let producer = Arc::new(CountingResumer::default()); + sender.register_resume(&producer); + + // Empty, and already sitting on the edge (`best_effort_room() == 1`), so + // the `took_one` guard is the only thing separating this no-op poll from a + // real crossing. + assert!(receiver.try_recv().is_none(), "nothing to take"); + assert_eq!( + producer.count(), + 0, + "a poll that took nothing crossed no edge" + ); + + // The genuine crossing: saturate, then take the one item. + fill(&sender, watch, 1); + assert!(!sender.has_room(), "saturated"); + assert!(receiver.try_recv().is_some(), "the queued item"); + assert_eq!(producer.count(), 1, "taking the item crossed the edge"); + + // The queue now sits on that same edge again, but nothing was taken, so the + // prod must not repeat. + assert!(receiver.try_recv().is_none(), "drained"); + assert_eq!( + producer.count(), + 1, + "an empty poll must not re-fire an edge it did not cross" + ); +} + #[test] fn draining_a_standing_send_returns_the_carve_out_to_the_slot_not_to_the_pool() { // A `cargo mutants` run flagged the reservation accounting, and chasing it @@ -1649,4 +1688,38 @@ fn the_opaque_handles_name_themselves_when_formatted() { rendered.contains("Reservation"), "a reservation must name itself when formatted, got {rendered:?}" ); + + let rendered = format!("{sender:?}"); + assert!( + rendered.contains("Sender"), + "a sender must name itself when formatted, got {rendered:?}" + ); +} + +#[test] +fn a_formatted_receiver_reports_the_state_a_wedge_is_diagnosed_from() { + // Unlike the opaque handles above, `Receiver`'s `Debug` is the one place + // the queue's occupancy is visible from outside, and those four numbers are + // exactly what someone diagnosing a stalled watcher reads. A body that + // writes nothing, or a `disconnected` flag that reports the opposite of the + // truth, both survived mutation -- and an inverted flag is worse than no + // flag, because it misleads at the moment it is consulted. + let (sender, receiver) = bounded(2); + let watch = WatchId::from_raw(303); + + fill(&sender, watch, 2); + assert_eq!(sender.send(batch(watch, &["lost.txt"])), Delivery::Latched); + assert_eq!( + format!("{receiver:?}"), + "Receiver { queued: 2, capacity: 2, latched: 1, disconnected: false, .. }", + "a live receiver must report its occupancy and that it is still connected" + ); + + // Dropping the last sender is the transition the flag exists to show. + drop(sender); + assert_eq!( + format!("{receiver:?}"), + "Receiver { queued: 2, capacity: 2, latched: 1, disconnected: true, .. }", + "once every sender is gone the receiver must say so" + ); } From 279013ad1c34d0460be20d3c5621e3c62d5d6d30 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 17:59:27 -0400 Subject: [PATCH 161/361] test(thread-ambient): close CaptureError's own gap, and correct a wrong claim A fresh full-crate cargo-mutants verification run (post-sweep, via tools\run-mutants.ps1) surfaced two things the four preceding commits missed or got wrong. \CaptureError::raw_os_error\'s prior fix only asserted the constant-ruling-out value; \Display\ and \Error::source\ on the same type had no test at all, and were genuinely missed in the original 61-item list -- an oversight in the initial triage, not a new regression (both appear in the run that started this sweep, at lines 164 and 175, simply not grouped with the other five types' identical gaps at the time). One constructed \Transaction\ variant proves both, the same as \state::ApplyError\'s equivalent fix. The bigger correction is to \TransactionGuard::release\/\Drop\'s test, whose prior commit message claimed the mutation's effect was "thread reuse in the test harness" and fixed it by moving the check onto a dedicated thread. That diagnosis was wrong, caught by re-verifying with a full sweep rather than trusting the earlier sabotage runs: cargo-mutants' own invocation of the exact same mutation still reports the mutant as missed, on the *dedicated-thread* version, even though a direct \cargo test\ -- run 25 times clean and 10 times against the mutation, every time -- shows it failing reliably every time, taking three other, unrelated transaction tests down with it. The dedicated thread does not stop that spread (nothing in a test can), it only stops this test's own pass/fail from depending on another test's unrelated state. Since this workspace's CI runs \cargo test\, not \cargo mutants\, and that is the verification this repository's own instructions call authoritative, the test stands on that basis; the comment now describes the measured process-wide spread rather than the disproven thread-reuse theory, and the discrepancy against cargo-mutants' own harness is left unexplained rather than papered over with a wrong claim. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/state/tests.rs | 22 +++++ .../src/transaction/tests.rs | 87 ++++++++++++------- 2 files changed, 77 insertions(+), 32 deletions(-) diff --git a/crates/windows-thread-ambient-sys/src/state/tests.rs b/crates/windows-thread-ambient-sys/src/state/tests.rs index 46bc1a30..8055ae41 100644 --- a/crates/windows-thread-ambient-sys/src/state/tests.rs +++ b/crates/windows-thread-ambient-sys/src/state/tests.rs @@ -536,6 +536,28 @@ fn raw_os_error_forwards_through_the_failing_aspect_or_reports_none() { assert_eq!(transaction_failure.raw_os_error(), Some(5)); } +#[test] +fn display_and_source_forward_through_the_failing_aspect() { + // `::fmt -> Ok(Default::default())` and + // `::source -> None` survived alongside + // `raw_os_error`'s gap above, for the same reason: nothing in this suite + // provokes a real capture failure. One constructed variant is enough for + // both -- the mutation replaces the whole body, so any one working arm + // proves the real one ran. + let error = CaptureError { + failure: CaptureFailure::Transaction(TransactionError::synthetic( + TransactionFailure::Duplicate, + Some(5), + )), + }; + let rendered = error.to_string(); + assert!( + rendered.contains("capturing the transaction aspect failed"), + "got {rendered}" + ); + assert!(std::error::Error::source(&error).is_some()); +} + // --- release_error_mode ------------------------------------------------------ #[test] diff --git a/crates/windows-thread-ambient-sys/src/transaction/tests.rs b/crates/windows-thread-ambient-sys/src/transaction/tests.rs index 5ece5779..c84cdea6 100644 --- a/crates/windows-thread-ambient-sys/src/transaction/tests.rs +++ b/crates/windows-thread-ambient-sys/src/transaction/tests.rs @@ -314,39 +314,62 @@ fn release_reports_a_genuine_restore_failure_and_restores_on_drop_even_without_i // that restores nothing is indistinguishable from one that does. A real // transaction as the entry state is what makes "restored" and "still // cleared" two different, checkable things. - let Some(transaction) = Transaction::new() else { + // + // Run on a dedicated thread rather than the calling one. Measured: with + // the mutation genuinely applied, leaving this thread's transaction + // unrestored does not stay confined to this thread or even to this test -- + // running the whole crate's `cargo test` reliably fails three *other*, + // unrelated transaction tests alongside this one every time (confirmed + // clean 25/25 without the mutation, failing 10/10 with it, both via a + // direct `cargo test` invocation, which is what this workspace's CI runs). + // TxF's current-transaction state is process-visible in a way this + // crate's own aspects are not, so an unreleased guard is a sharper + // contamination than the same mistake on error mode or memory priority + // would be. A dedicated thread does not prevent that spread -- nothing + // this test does can -- but it does mean the *test's own* pass/fail + // reflects its own assertion rather than another test's unrelated state. + let observed = std::thread::spawn(|| { + let transaction = Transaction::new()?; + Some(while_transacted(&transaction, || { + let before = live(); + assert!(!super::is_none_sentinel(before), "precondition: transacted"); + + // `release`'s own restore, checked by state rather than by return + // value. + let guard = install(&Captured::Absent).expect("install"); + assert!( + super::is_none_sentinel(live()), + "Absent did not clear the thread's transaction" + ); + guard + .release() + .expect("releasing an installed Absent must succeed"); + let released_cleanly = live() == before; + + // `Drop`'s restore, on a guard that is never released explicitly. + let dropped_cleanly = { + let _guard = install(&Captured::Absent).expect("install"); + assert!(super::is_none_sentinel(live())); + drop(_guard); + live() == before + }; + + (released_cleanly, dropped_cleanly) + })) + }) + .join() + .expect("the worker did not panic"); + + let Some((released_cleanly, dropped_cleanly)) = observed else { eprintln!("skipped: this system cannot create a transaction"); return; }; - while_transacted(&transaction, || { - let before = live(); - assert!(!super::is_none_sentinel(before), "precondition: transacted"); - - // `release`'s own restore, checked by state rather than by return - // value. - let guard = install(&Captured::Absent).expect("install"); - assert!( - super::is_none_sentinel(live()), - "Absent did not clear the thread's transaction" - ); - guard - .release() - .expect("releasing an installed Absent must succeed"); - assert_eq!( - live(), - before, - "release did not restore the entry transaction" - ); - - // `Drop`'s restore, on a guard that is never released explicitly. - { - let _guard = install(&Captured::Absent).expect("install"); - assert!(super::is_none_sentinel(live())); - } - assert_eq!( - live(), - before, - "dropping the guard without releasing did not restore the entry transaction" - ); - }); + assert!( + released_cleanly, + "release did not restore the entry transaction" + ); + assert!( + dropped_cleanly, + "dropping the guard without releasing did not restore the entry transaction" + ); } From 4a94658f4dbf7520f6bcd60f0c951e0b3ad120fa Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 18:30:55 -0400 Subject: [PATCH 162/361] fix(file-watcher): replace StandingHold::drop's dead release path with a tripwire Mutation testing left four survivors in `StandingHold::drop`. The interesting part was not the survivors but why they survived, and the answer changed what the right fix was. Reachability, from the source: every hold is built in one place and moved straight into `state.queue`; the only site that removes an entry from that queue is `take`, which settles the reservation inline with the pop and sets `resolved`. The only other way a hold dies is `Shared` being torn down, where the `Weak` fails to upgrade. Nothing reaches the body in between -- confirmed by replacing it with `unreachable!()` and passing the full suite. History, from git: `4198aa8` says this `Drop` restored `reserved` *on drain* -- it was the drain path. `07d4b75` found that popping exposed the queue slot before the deferred `Drop` restored the reservation, moved the release into `take`, and left `Drop` as "the fallback for every other discard." Live code whose only caller moved out from under it. The finding that decided the outcome: the body could not have run safely. `take` takes `&mut State`, so its caller holds the `items` guard -- and any other way to remove an entry needs that same guard, so a hold discarded on such a path is dropped inside the lock, and the body's first act was a plain non-reentrant `lock(&shared.items)`. Measured, identical forced unwind out of `take`: body live hung past 90s; `Drop` short-circuited failed immediately. The "fallback for every other discard" would have deadlocked in exactly the situation it was written for. Building the discard path was never an option either -- it would contradict `dropping_a_standing_slot_while_its_message_is_still_queued_releases_capacity_once`, which asserts a cancelled slot's queued question still arrives. So the body is replaced by `debug_assert!(std::thread::panicking(), ...)`: the true statement rather than a bare `false`, so the one way to arrive today lets the original panic propagate instead of becoming an abort from a second one, while any other arrival fires. It encodes the contract the deadlock taught -- a discard must release under the `items` lock it already holds, as `take` does. All four survivors are gone, three by deletion and the whole-impl mutant by the new tripwire test. Every branch the new `Drop` admits was injected and confirmed caught. Swept the four other statements of the same fact -- `Entry`, `StandingHold`, `StandingState` and `take` all described this `Drop` as the live release mechanism, and none had moved when the fact did. Completed item: M15.1: Decide whether `StandingHold::drop`'s release path is dead code, and act on the answer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 26 +--- .../COMPLETED-CHECKLIST.md | 49 +++++++- crates/windows-file-watcher/DESIGN-NOTES.md | 55 +++++++++ crates/windows-file-watcher/src/queue.rs | 116 ++++++++++-------- .../windows-file-watcher/src/queue/tests.rs | 32 ++++- 5 files changed, 202 insertions(+), 76 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 7fcb9d24..b8c1fbd9 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -128,29 +128,9 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-25---- Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27----m14-audit-the-delivery-contract-against-the-ten-specification-gap-categories-d-84). -## M15 -- Resolve the unreachable half of `StandingHold::drop` - -- [ ] **M15.1** -- Decide whether `StandingHold::drop`'s release path is dead code, and act on the - answer. **Found by mutation testing, and it is a reachability question rather than a test gap** -- - which is why it is queued for a decision instead of being closed with a test. - Three mutants in that `Drop` survive: `state.reserved += 1` changed to `-=` and to `*=`, and the `!` - deleted from `if !standing.slot_alive`. Each was re-injected on its own line and confirmed to leave the - suite green, so this is not an artifact of the run's feature flags. - **No test can catch them as the code stands.** The only pop from `state.queue` is in `take` (one call - site), and `take` performs the release inline and sets `resolved = true`, so `Drop` returns at its first - line for every drained entry. An *undrained* entry's hold is only dropped when `Shared` itself is torn - down -- and then `self.shared.upgrade()` returns `None` and `Drop` returns at its second line. Nothing - reaches the body in between. - The doc comment says `Drop` "remains the fallback for every other discard", so either a discard path was - intended and never built, or one existed and was removed when `take` took over the release (a PR #20 - review response, per the comment beside it). Both readings are plausible from the code alone; the - engineer who made that change can tell them apart, and an assistant deleting live-looking accounting on - a hunch is exactly the wrong move. - Three outcomes are legitimate: **remove** the unreachable body if the discard path is genuinely gone; - **keep it and say why** if it guards a path that is coming (recording that here, so the next mutation - run does not re-litigate it); or **build the missing path** if its absence is itself the defect. What is - not legitimate is adding a test that reaches it artificially -- that would manufacture coverage for code - nothing calls. +## M15 -- Findings from the mutation-testing sweep + +- [x] **M15.1** -- Resolved the unreachable half of `StandingHold::drop`: it was the drain path until `take` took the release over, and it could not have run safely -- reaching it deadlocks on the `items` lock its caller already holds. Replaced by an exercised tripwire. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m151) - [ ] **M15.2** -- Explain, then either fix or document, why a handle from `reopen_by_id` **rejects the very read the watcher exists to issue**. Found while chasing a surviving mutant; the mutant is the diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index d3f32ab3..0c547379 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -553,4 +553,51 @@ a red suite, never by reasoning): `StandingSlot` and `Reservation`. The remaining 4 missed mutants are all in `StandingHold::drop`, whose body is unreachable; that is M15.1 -and is an engineer decision, not a test gap. \ No newline at end of file +and is an engineer decision, not a test gap. +## Moved 2026-09-01 -- M15.1: the unreachable half of `StandingHold::drop` + +### M15.1 -- Decide whether `StandingHold::drop`'s release path is dead code, and act on the answer. *(completed 2026-09-01 18:29:36 -04:00)* + +**The reachability question, settled from the source.** Every `StandingHold` is built in one place +(`StandingSlot::send`) and moved straight into `state.queue`; `Entry::plain` never carries one. The only +site anywhere in the crate that removes an entry from that queue is `take` (grepped for +pop/drain/retain/clear/remove/truncate -- one hit), and it settles the reservation inline with the pop and +sets `resolved`, so `Drop` returns at its first line. The only other way a hold dies is `Shared` being torn +down, where the `Weak` fails to upgrade and it returns at its second. Confirmed empirically by replacing the +body with `unreachable!()` and passing the full `--all-features` suite (372 tests, lib + 8 integration +targets + doctests). + +**The history question, settled from git.** The checklist offered two readings; the first is disproved. +`4198aa8` says this `Drop` "unconditionally restored `reserved` **on drain**" -- it *was* the drain path. +`07d4b75` (PR #20 review 5000746684) then found that popping exposed the queue slot before the deferred +`Drop` restored the reservation, so `queue.len() + reserved` could exceed capacity; the fix moved the +release into `take` and left `Drop` as "the fallback for every other discard." It was live code whose only +caller moved out from under it, not scaffolding that was never wired up. + +**The finding that decided the outcome: the body could not have run safely.** `take` takes `&mut State`, so +its caller holds the `items` guard -- and any other way to remove an entry from `state.queue` needs that +same guard, so a hold discarded on such a path is dropped *inside* the lock. The body's first act was +`lock(&shared.items)`, a plain non-reentrant `Mutex::lock`. Differential measurement, identical forced +unwind out of `take`: body live -> **hung past 90s**; `Drop` short-circuited -> **exit 101, immediate**. The +"fallback for every other discard" would have deadlocked in exactly the situation it was written for. + +**Building the discard path was never an option.** It would contradict a tested decision: +`dropping_a_standing_slot_while_its_message_is_still_queued_releases_capacity_once` asserts that a cancelled +slot's queued question **still arrives**. + +**What landed.** The body is replaced by `debug_assert!(std::thread::panicking(), ...)` -- the true +statement rather than a bare `false`, so the one way to arrive today (an unwind out of `take` between the +pop and `resolved`) lets the original panic propagate instead of becoming an abort from a second one, while +any other arrival fires. It encodes the contract the deadlock taught: a discard must release the reservation +under the `items` lock it already holds, exactly as `take` does. `a_hold_that_outlives_its_entry_while_the_queue_is_alive_trips_the_tripwire` +exercises it, because an assertion nothing exercises is worth no more than the comment beside it. + +**Mutation result.** All four survivors are gone -- three by deletion, and the whole-impl mutant +(`replace drop with ()`) is now caught by the tripwire test. Every branch the new `Drop` admits was injected +and confirmed caught: `resolved` -> `true`/`false`, `upgrade().is_none()` -> `true`/`false`, and the empty +body. `queue.rs` now has **4 missed mutants, none in this impl**. + +**Blast-radius sweep.** The survivors were the symptom of a doc gone false by vacuity: `Entry`, +`StandingHold`, `StandingState`, and `take` all described this `Drop` as the live release mechanism. Four +restatements of one fact, none of which moved when the fact did; all four corrected here, plus the note in +`queue/tests.rs`. Recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> `Dead code that could not have run`. \ No newline at end of file diff --git a/crates/windows-file-watcher/DESIGN-NOTES.md b/crates/windows-file-watcher/DESIGN-NOTES.md index 31664a68..ffaa91d0 100644 --- a/crates/windows-file-watcher/DESIGN-NOTES.md +++ b/crates/windows-file-watcher/DESIGN-NOTES.md @@ -770,3 +770,58 @@ The corrected test also asserts that a coarse `QueueFull` is actually *generated not merely permitted -- without that, the test would pass just as well against a generator that still excluded it, which is the same weakness the wake-edge regression test had in its first form. + +### Dead code that could not have run: `StandingHold::drop` + +Mutation testing left four survivors in `StandingHold::drop`, and the interesting +part was not the survivors but why they survived. + +The reachability question settles from the source alone. Every `StandingHold` is +built in one place (`StandingSlot::send`) and moved straight into `state.queue`. +The only site anywhere in the crate that removes an entry from that queue is +`take`, which settles the reservation inline with the pop and sets `resolved`, so +`Drop` returns at its first line. The only other way a hold dies is `Shared` being +torn down, where the hold's `Weak` fails to upgrade and it returns at its second. +Nothing reaches the body in between -- confirmed by replacing it with +`unreachable!()` and passing the full suite. + +The history says it was not always so. In `4198aa8` this `Drop` *was* the drain +path: it "unconditionally restored `reserved` on drain." `07d4b75` then found that +popping exposed the queue slot before the deferred `Drop` restored the +reservation, so `queue.len() + reserved` could exceed capacity; the fix moved the +release into `take`, inline with the pop, and left `Drop` as "the fallback for +every other discard." The body was live code whose only caller moved out from +under it. + +**The finding that decided what to do about it: the body could not have run +safely.** `take` takes `&mut State`, so its caller holds the `items` guard -- and +any other way to remove an entry from `state.queue` needs that same guard. A hold +discarded on such a path is therefore dropped *inside* the lock, and the body's +first act was `lock(&shared.items)`, a plain non-reentrant `Mutex::lock`. So the +"fallback for every other discard" would have deadlocked in precisely the +situation it was written for. Measured, not reasoned: with a forced unwind out of +`take`, the body hung past 90s; the identical unwind with `Drop` short-circuited +failed immediately. + +Building the missing discard path was never an option either, and the tests +already said so: `dropping_a_standing_slot_while_its_message_is_still_queued_releases_capacity_once` +asserts that a cancelled slot's queued question **still arrives**. There is no +discard to fall back from. + +So the body is gone and an assertion stands in its place, phrased as +`debug_assert!(std::thread::panicking(), ...)` -- which is the true statement +rather than a bare `false`. The only way to reach it today is an unwind out of +`take` between the pop and `resolved` being set, and there the original panic is +the real diagnostic and must be left to propagate rather than turned into an abort +by a second one. Any *other* arrival is a new discard path that has not settled +its reservation, and it fires. **The contract it encodes: a discard must release +the reservation under the `items` lock it already holds, exactly as `take` does -- +never by delegating to a hold's `Drop`.** + +Two transferable points. First, "unreachable" and "harmless" are different +claims, and the second does not follow from the first: this body was unreachable +*and* was a deadlock waiting for its first caller. Second, the survivors were the +symptom of a doc that had gone false by vacuity -- `Entry`, `StandingHold`, +`StandingState`, and `take` all described this `Drop` as the live release +mechanism, so a reader would have trusted a fallback that could not work. Four +restatements of one fact, none of which moved when the fact did. \ No newline at end of file diff --git a/crates/windows-file-watcher/src/queue.rs b/crates/windows-file-watcher/src/queue.rs index 5998dcd5..1cfb9803 100644 --- a/crates/windows-file-watcher/src/queue.rs +++ b/crates/windows-file-watcher/src/queue.rs @@ -303,19 +303,17 @@ pub(crate) trait Resume: Send + Sync { fn resume(&self); } -/// One queued notification, optionally carrying the obligation to hand a -/// [`StandingSlot`]'s permanent reservation back to `reserved` once the entry -/// is gone. +/// One queued notification, optionally carrying a [`StandingSlot`]'s permanent +/// reservation for as long as it sits in the queue. /// /// A `StandingSlot` send transfers its one carved-out unit of `reserved` /// capacity into the queue for as long as the message sits there (so `free()` /// is never double-charged for it, once as `reserved` and again as an -/// occupied queue slot). [`StandingHold`]'s own `Drop` is what hands that unit -/// back, whenever and however this entry is finally discarded -- taken -/// normally or dropped some other way -- which is also what lets -/// [`StandingSlot::drop`] tell whether it still owns that unit itself (no hold -/// outstanding) or has already transferred it to a queued entry that has not -/// been drained yet. +/// occupied queue slot). [`take`] hands that unit back, inline with the pop +/// that removes this entry; the [`StandingHold`] is the record that the +/// transfer happened, which is what lets [`StandingSlot::drop`] tell whether it +/// still owns that unit itself (no hold outstanding) or has already +/// transferred it to a queued entry that has not been drained yet. struct Entry { notification: EntryNotification, _standing_hold: Option, @@ -348,7 +346,7 @@ impl Entry { /// The notification this entry carries, reading a standing entry's /// current (possibly coalesced) value from its `StandingHold` before the - /// hold itself drops and releases the reservation. + /// entry itself is discarded. fn into_notification(self) -> Notification { match self.notification { EntryNotification::Plain(notification) => notification, @@ -657,10 +655,11 @@ impl Drop for Reservation { /// together with the `reserved` count they gate, never observed mid-way. /// /// Lock order is fixed as *the queue's `items`, then this* everywhere it is -/// taken (`send`, both `Drop` impls), which is what makes the combined -/// transition atomic: whichever of `StandingSlot::drop` or -/// `StandingHold::drop` runs first holds this lock for its entire mutation of -/// `reserved`, so the other side cannot observe a half-finished transfer. +/// taken (`send`, `StandingSlot::drop`, and [`take`]), which is what makes the +/// combined transition atomic: whichever of `StandingSlot::drop` or `take` runs +/// first holds this lock for its entire mutation of `reserved`, so the other +/// side cannot observe a half-finished transfer. `StandingHold::drop` takes +/// neither lock -- see that impl for why it must not. struct StandingState { /// Whether the owning `StandingSlot` has been dropped. Once false, no /// further send can ever happen, so an outstanding hold's eventual drain @@ -774,20 +773,19 @@ impl std::fmt::Debug for StandingSlot { } } -/// The reservation-release obligation a [`StandingSlot::send`] transfers into -/// its queued entry. +/// The record that a [`StandingSlot::send`] has transferred its permanent +/// reservation into a queued entry. /// -/// `Drop` -- rather than only [`take`] -- is what performs the release on any -/// path other than an ordinary pop, so it happens exactly once no matter how -/// the entry is finally discarded (normal drain, or the queue itself going -/// away). `take` is the common case and the one path where the pop and the -/// release must be one atomic accounting transition (PR #20 review -/// response): popping a standing entry exposes its queue slot before its -/// reservation was restored under the old design, and a producer woken in -/// that gap could overcommit capacity. `take` therefore performs the release -/// itself, inline with the pop, under the same `items` lock, and marks -/// `resolved` so this `Drop` becomes a no-op for that path; `Drop` remains -/// the fallback for every other discard. +/// The release itself belongs to [`take`], performed inline with the pop under +/// the same `items` lock (PR #20 review response): popping a standing entry +/// exposed its queue slot before the reservation was restored under the old +/// design -- where this type's own `Drop` did the restoring, deferred until +/// after the queue lock was released -- and a producer woken in that gap could +/// overcommit capacity. `take` therefore performs the release itself and marks +/// `resolved`. What is left here is the record of the transfer, which is what +/// [`StandingSlot::drop`] reads to tell whether it still owns its unit; it is +/// not a second release path, and this type's `Drop` explains why no discard may +/// delegate the release back to it. struct StandingHold { /// `Weak`, not `Arc` (PR #20 review response): this hold lives inside an /// `Entry` inside `Shared.items.queue` -- inside the very `Shared` a @@ -799,38 +797,54 @@ struct StandingHold { /// `reserved` accounting left to update. shared: Weak, state: Arc>, - /// Set once this hold's release has already been performed inline by - /// `take`, so `Drop` does not perform it a second time. + /// Set once [`take`] has performed this hold's release inline with the pop. + /// Every hold that leaves the queue leaves it that way, so this is the arm + /// `Drop` actually takes; see `Drop` for why the other arm is a tripwire + /// rather than a fallback. resolved: bool, } impl Drop for StandingHold { - /// Lock order: `items`, then `standing` -- matching [`StandingSlot::send`] - /// and [`take`]. + /// A tripwire, not a release path. + /// + /// Both arms below return, and that is the whole behaviour: a hold is + /// reachable only from `state.queue`, the one site that removes an entry + /// from that queue ([`take`]) settles the reservation itself and sets + /// `resolved`, and the only other way a hold dies is `Shared` being torn + /// down, where `upgrade` fails and there is no accounting left to update. + /// + /// It deliberately does **not** restore the reservation itself, and a future + /// discard path must not make it. `take` receives `&mut State`, so its caller + /// holds the `items` guard -- and any other way to remove an entry from + /// `state.queue` needs that same guard, so a hold discarded on such a path is + /// dropped *inside* the lock. Re-taking it here would deadlock, because + /// [`lock`] is a plain non-reentrant `Mutex::lock`. That is measured, not + /// supposed: with a forced unwind out of `take`, the earlier body that did + /// the accounting here hung indefinitely, while the same unwind with this + /// `Drop` short-circuited failed immediately. **Any discard path must release + /// the reservation under the lock it already holds, exactly as [`take`] + /// does.** fn drop(&mut self) { if self.resolved { return; } - let Some(shared) = self.shared.upgrade() else { + if self.shared.upgrade().is_none() { // `Shared` is already being torn down; nothing to update. return; - }; - let mut state = lock(&shared.items); - let mut standing = lock(&self.state); - standing.in_flight = false; - standing.queued = None; - if !standing.slot_alive { - // Nothing will ever reserve this unit again: `queue.len()` - // already accounted for its release when this entry left the - // queue, so there is nothing further to do here beyond - // recording that no hold remains outstanding. - return; } - drop(standing); - state.reserved += 1; - let resumers = freed_resumers(&mut state, true); - drop(state); - prod(resumers); + // Reaching here means a hold outlived its entry while the queue was + // still alive. Today the only way that can happen is an unwind out of + // `take` between the pop and `resolved` being set, and there the original + // panic is the real diagnostic -- so it is left to propagate rather than + // replaced by an abort from a second panic. Anything else is a new + // discard path that has not settled its reservation; see above for the + // one way it is allowed to. + debug_assert!( + std::thread::panicking(), + "a StandingHold outlived its entry with the queue still alive: a \ + discard path must release the reservation under the `items` lock it \ + already holds, as `take` does" + ); } } @@ -1102,7 +1116,11 @@ impl Receiver { /// capacity. The returned `Entry` still carries its (now inert) `StandingHold` /// so a caller may defer *dropping* it until after the queue lock is released /// -- the hold's own `Drop` is a no-op here, since `resolved` is already set -- -/// but the reservation itself is never left outstanding past this call. +/// but the reservation itself is never left outstanding past this call. Setting +/// `resolved` is not bookkeeping that can be skipped: it is what keeps the hold +/// off the tripwire in `StandingHold::drop`, which any future path that removes +/// an entry from the queue must satisfy the same way -- by releasing inline, +/// here, under the lock it already holds. fn take(state: &mut State) -> Option { if let Some(mut entry) = state.queue.pop_front() { if matches!(entry.notification, EntryNotification::Standing) { diff --git a/crates/windows-file-watcher/src/queue/tests.rs b/crates/windows-file-watcher/src/queue/tests.rs index 18bb4557..05106ebf 100644 --- a/crates/windows-file-watcher/src/queue/tests.rs +++ b/crates/windows-file-watcher/src/queue/tests.rs @@ -948,6 +948,31 @@ fn dropping_an_unused_standing_slot_returns_its_capacity() { ); } +#[test] +#[cfg(debug_assertions)] +#[should_panic(expected = "must release the reservation under the `items` lock")] +fn a_hold_that_outlives_its_entry_while_the_queue_is_alive_trips_the_tripwire() { + // `StandingHold::drop` is not a release path. Reaching its body means an + // entry was discarded without settling its reservation, and the accounting + // that used to live there could not have run anyway: every way to remove an + // entry from the queue holds the `items` lock, and re-taking it deadlocks. + // That was measured, not assumed -- a forced unwind out of `take` hung past + // 90s with the old body, and the same unwind with this `Drop` + // short-circuited failed immediately. + // + // The body is now an assertion, and an assertion nothing exercises is worth + // no more than the comment beside it. Building a hold by hand is the only + // way to reach it, which is itself the point: no path in the crate can. + let (sender, _receiver) = bounded(2); + let slot = sender.reserve_standing().expect("a slot"); + let hold = super::StandingHold { + shared: Arc::downgrade(&sender.shared), + state: Arc::clone(&slot.state), + resolved: false, + }; + drop(hold); +} + #[test] fn a_second_standing_send_while_the_first_is_still_queued_coalesces_in_place() { // PR #20 review response: reachable when an interactive watch is answered @@ -1585,9 +1610,10 @@ fn draining_a_standing_send_returns_the_carve_out_to_the_slot_not_to_the_pool() // inline with the pop -- was covered only incidentally, by tests asserting // that sends succeed rather than that capacity is conserved. // - // (The copy of that accounting in `StandingHold::drop` is a different - // matter: it is not reachable in the current design, and no test here can - // cover it. See the note on that impl.) + // (`StandingHold::drop` no longer carries a copy of this accounting. It + // could not be reached, and if it ever were it would deadlock on the + // `items` lock its caller already holds; it is now a tripwire, exercised by + // `a_hold_that_outlives_its_entry_while_the_queue_is_alive_trips_the_tripwire`.) // // `unreserved() == capacity - queue.len() - reserved`, so getting this // wrong does not merely lose the slot's guarantee: decrementing inflates From 57f9e19bd0f38ad80a3d3a142816bda221ad15bd Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 18:41:02 -0400 Subject: [PATCH 163/361] test(wtf-string): close mutation gaps Completed item: M1.1: Add direct unit coverage for the 14 surviving mutants in encoding defaults, capacity management, comparison and hashing forwarders, and borrowed OsString conversion; sabotage-verify the tests and rerun cargo mutants for wtf-string. Mutation sweep: 132 tested, 97 caught, 35 unviable, 0 missed, 0 timed out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/wtf-string/COMPLETED-CHECKLIST.md | 8 +++ crates/wtf-string/COMPLETED-PLANS.md | 1 + crates/wtf-string/src/encoding.rs | 3 + crates/wtf-string/src/encoding/tests.rs | 70 +++++++++++++++++++ crates/wtf-string/src/string/os_str/tests.rs | 2 + crates/wtf-string/src/string/tests.rs | 71 ++++++++++++++++++++ 6 files changed, 155 insertions(+) create mode 100644 crates/wtf-string/src/encoding/tests.rs diff --git a/crates/wtf-string/COMPLETED-CHECKLIST.md b/crates/wtf-string/COMPLETED-CHECKLIST.md index 303ae0a9..7952073d 100644 --- a/crates/wtf-string/COMPLETED-CHECKLIST.md +++ b/crates/wtf-string/COMPLETED-CHECKLIST.md @@ -170,3 +170,11 @@ Append-only archive of completed milestones moved out of [CHECKLIST.md](CHECKLIS release-please prepends to in the already-released crates. The reserved seam -- a checked no-interior-NUL C-string companion (D-7, M-inf.1) -- is now recorded in the crate docs so users know the surface may grow there. + +## Moved 2026-09-01 -- M1 mutation-test hardening + +### M1 -- Close mutation-test gaps + +- [x] **M1.1** -- Add direct unit coverage for the 14 surviving mutants in encoding defaults, capacity + management, comparison and hashing forwarders, and borrowed `OsString` conversion; sabotage-verify the + tests and rerun `cargo mutants` for `wtf-string`. *(completed 2026-09-01 18:39:22 UTC-04:00)* diff --git a/crates/wtf-string/COMPLETED-PLANS.md b/crates/wtf-string/COMPLETED-PLANS.md index 5ee7688f..bcf0cbd2 100644 --- a/crates/wtf-string/COMPLETED-PLANS.md +++ b/crates/wtf-string/COMPLETED-PLANS.md @@ -5,3 +5,4 @@ Append-only archive of completed checklists, moved out of [PLANS.md](PLANS.md). | Path to CHECKLIST.md | Completion Date | Brief description | Design Notes | |---|---|---|---| | [CHECKLIST.md](CHECKLIST.md) | 2026-08-21 | `OsString`-shaped strings with native `u16` (WTF-16), conversion-free storage for Windows FFI. All ten planned milestones landed (M1 scaffold+design -> M10 docs/publication): an encoding-generic core (`WtfString` / `WtfStr`) with always-terminated storage, both the `Wtf16` and `Wtf8` arms, portable `str`/`String` conversions, the Win32 FFI pointer surface (counted + terminated input, buffer-fill and callee-allocated output), Windows-only lossless `OsStr` interop, a safe `OsString`-parity mutation surface, optional `windows`-crate `Param` interop, and a `no_std`/`alloc`-only baseline proven against a bare-metal target. Decisions D-1...D-18 are recorded. The checklist file remains for its parked `M-inf` horizon bucket, which holds no pending work. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | +| [CHECKLIST-mutation-hardening.md](COMPLETED-CHECKLIST.md#checklist-mutation-hardening) (deleted on completion; the link opens its archived entry) | 2026-09-01 | Added direct unit coverage for all 14 survivors from the full mutation run. The rerun tested 132 mutants: 97 caught, 35 unviable, 0 missed, and 0 timed out. | N/A | diff --git a/crates/wtf-string/src/encoding.rs b/crates/wtf-string/src/encoding.rs index 67995aca..4f470ddf 100644 --- a/crates/wtf-string/src/encoding.rs +++ b/crates/wtf-string/src/encoding.rs @@ -159,3 +159,6 @@ impl WtfEncoding for Wtf8 { f.write_char('"') } } + +#[cfg(test)] +mod tests; diff --git a/crates/wtf-string/src/encoding/tests.rs b/crates/wtf-string/src/encoding/tests.rs new file mode 100644 index 00000000..a9106168 --- /dev/null +++ b/crates/wtf-string/src/encoding/tests.rs @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Mike Grier + +use std::format; +use std::string::String; +use std::vec::Vec; + +use super::WtfEncoding; +use crate::WtfString; + +enum DefaultEncoding {} + +impl WtfEncoding for DefaultEncoding { + type Unit = u8; + const NUL: u8 = 0; + + fn encode_str(s: &str) -> Vec { + s.as_bytes().to_vec() + } + + fn decode(units: &[u8]) -> Option { + core::str::from_utf8(units).ok().map(String::from) + } + + fn decode_lossy(units: &[u8]) -> String { + String::from_utf8_lossy(units).into_owned() + } +} + +#[test] +fn default_eq_str_compares_encoded_units() { + for (units, text, expected) in [ + (&[][..], "", true), + (b"a", "a", true), + (b"abc", "abc", true), + (b"a\0b", "a\0b", true), + (b"line\n", "line\n", true), + (b"", "a", false), + (b"a", "", false), + (b"abc", "abd", false), + (b"abc", "ab", false), + (b"ab", "abc", false), + (b"A", "a", false), + (b"a\0b", "ab", false), + ] { + assert_eq!( + DefaultEncoding::eq_str(units, text), + expected, + "{units:?} vs {text:?}" + ); + } +} + +#[test] +fn default_debug_formats_lossy_decoding_like_a_string() { + for (units, expected) in [ + (&[][..], "\"\""), + (b"a", "\"a\""), + (b"abc", "\"abc\""), + (b"don't", "\"don't\""), + (b"quote\"", "\"quote\\\"\""), + (b"slash\\", "\"slash\\\\\""), + (b"tab\t", "\"tab\\t\""), + (b"line\n", "\"line\\n\""), + (b"return\r", "\"return\\r\""), + (b"nul\0", "\"nul\\0\""), + ] { + let value = WtfString::::from_units(units); + assert_eq!(format!("{value:?}"), expected, "{units:?}"); + } +} diff --git a/crates/wtf-string/src/string/os_str/tests.rs b/crates/wtf-string/src/string/os_str/tests.rs index ed9262d6..ce44dfb6 100644 --- a/crates/wtf-string/src/string/os_str/tests.rs +++ b/crates/wtf-string/src/string/os_str/tests.rs @@ -53,6 +53,8 @@ fn from_and_into_conversions_round_trip() { // reverse below. let from_owned_os: Wtf16String = os.clone().into(); assert_eq!(from_owned_os.as_units(), wtf.as_units()); + let from_os_string_ref: Wtf16String = (&os).into(); + assert_eq!(from_os_string_ref.as_units(), wtf.as_units()); let from_ref: OsString = (&wtf).into(); assert_eq!(from_ref, os); let borrowed: &Wtf16Str = &wtf; diff --git a/crates/wtf-string/src/string/tests.rs b/crates/wtf-string/src/string/tests.rs index 20f0dd9d..70b5be0b 100644 --- a/crates/wtf-string/src/string/tests.rs +++ b/crates/wtf-string/src/string/tests.rs @@ -3,7 +3,9 @@ // the `core` prelude does not provide. (Imported explicitly rather than via a // prelude glob, which would shadow `core`'s `panic!` and warn.) use std::borrow::ToOwned; +use std::cmp::Ordering; use std::format; +use std::hash::{Hash, Hasher}; use std::string::String; use std::vec; use std::vec::Vec; @@ -13,6 +15,21 @@ use super::{Wtf16, Wtf16Str, Wtf16String, WtfEncoding}; // The encoding's named terminator, so assertions don't embed the raw 0 tag. const NUL: u16 = Wtf16::NUL; +#[derive(Default)] +struct WriteCountingHasher { + bytes_written: usize, +} + +impl Hasher for WriteCountingHasher { + fn finish(&self) -> u64 { + self.bytes_written as u64 + } + + fn write(&mut self, bytes: &[u8]) { + self.bytes_written += bytes.len(); + } +} + // Matrix / property coverage over a shared corpus lives in a sibling submodule. mod matrix; @@ -252,6 +269,60 @@ fn ordering_is_binary_over_units() { assert_eq!(same1, same2); } +#[test] +fn borrowed_partial_order_and_hash_call_their_trait_implementations() { + let a = Wtf16Str::from_units(&[1, 2]); + let b = Wtf16Str::from_units(&[1, 3]); + assert_eq!(PartialOrd::partial_cmp(a, b), Some(Ordering::Less)); + + let mut hasher = WriteCountingHasher::default(); + Hash::hash(a, &mut hasher); + assert!( + hasher.bytes_written > 0, + "hashing a borrowed string must feed its units to the hasher" + ); +} + +#[test] +fn str_comparison_forwarders_are_exercised_directly() { + let borrowed = Wtf16Str::from_units(&[97, 98, 99]); + assert!(>::eq(borrowed, &"abc")); + assert!(!>::eq(borrowed, &"abd")); + + let owned = Wtf16String::from("abc"); + assert!(>::eq(&owned, "abc")); + assert!(!>::eq(&owned, "abd")); +} + +#[test] +fn capacity_operations_observably_change_dedicated_buffers() { + let mut reserved = Wtf16String::new(); + reserved.reserve_exact(64); + assert!( + reserved.capacity() >= 64, + "reserve_exact must grow a fresh buffer" + ); + + let mut fitted = Wtf16String::with_capacity(128); + fitted.push_str("abc"); + let fitted_before = fitted.capacity(); + fitted.shrink_to_fit(); + assert!( + fitted.capacity() < fitted_before, + "shrink_to_fit must release excess capacity on the Windows allocator" + ); + + let mut bounded = Wtf16String::with_capacity(128); + bounded.push_str("abc"); + let bounded_before = bounded.capacity(); + bounded.shrink_to(16); + assert!( + bounded.capacity() < bounded_before, + "shrink_to must release capacity above its requested lower bound" + ); + assert!(bounded.capacity() >= 16); +} + #[test] fn equality_and_hash_are_consistent_for_borrow() { use std::collections::HashSet; From 944fe6caf29676174dd36f539338a131d9da203c Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 18:56:33 -0400 Subject: [PATCH 164/361] fix(file-watcher)!: remove the reopen-by-id fast path, root-caused as impossible D-80 recorded that a handle from `OpenFileById`, once armed, "reliably fails to resolve the fault it was reopening for", attributed it to "the OpenFileById handle's interaction with IOCP association/arming specifically", and recorded the cause as not understood. That attribution was wrong, and it pointed future work at the wrong subsystem. A standalone probe -- no thread pool, no IOCP, no crate code -- reproduces it with `CreateFileW`, `OpenFileById` and a bare `ReadDirectoryChangesW`: CreateFileW, by path TRUE (pending) OpenFileById FALSE, 87 NtCreateFile by id, with FILE_DIRECTORY_FILE FALSE, 87 NtCreateFile by id, without it FALSE, 87 control: NtCreateFile by NAME, same options TRUE (pending) All five handles are identical everywhere it is natural to look: every one is asynchronous (FileModeInformation), every one granted 0x00100081 (FileAccessInformation), every one resolves the same path (FileNameInformation). The only variable that changes the outcome is whether the object was resolved by file ID or by name. That also disproves the two other readings on the table -- it is not SYNCHRONIZE or volume-hint semantics, and the by-name control through the identical NtCreateFile call rules out a defect in the measurement. Confirmed against the crate: re-enabling the fast path fails six tests, every one a fault-resolution test, with "the fault never resolved after being answered", and instrumenting the arm prints code 87. The read never completes because it never starts. So the path is removed rather than disabled. It could not have paid for itself in any case: `reopen_via_existing_handle` returned its candidate only when the reopened object's path already equalled the watcher's recorded canonical path, so by construction it could only ever hand back a handle to the object at the path the path-based fallback already opens. Gone with it: `DirectoryHandle::reopen_by_id`, `DirectoryId::file_reference`, and the four `reopen_by_id_*` identity tests that characterised the mechanism. What replaces them is `tests/reopen_by_id_cannot_be_watched.rs`, which asserts the OS limitation itself with the by-name control alongside, so this decision rests on something that executes rather than on a paragraph. It also settles the mutant that started this: `OpenFileById` on a directory succeeds with flags = 0, and the flags' only effect is sync-vs-async -- observable exclusively through an I/O this handle can never perform. Swept every other statement of the fact: D-80's decision row and detail section, the reopen paragraph above it, the docs on `directory_id`, `canonical_path` (both), `retry_reestablish` and `on_path_based_reopen`, a `monitor::tests` comment, and the M11.1/M11.2/M11.5 entries. `COMPLETED-PLANS.md` is left as dated, append-only history. Spawns M15.8: `WatcherInner::canonical_path` is now written and never read, and no warning will surface it, because `lock(&self.canonical_path)` counts as a read of the field. Completed item: M15.2: Explain, then either fix or document, why a handle from `reopen_by_id` rejects the very read the watcher exists to issue. Completed item: M-inf.4: Root-cause and, if fixed, re-enable M11.2's fast reopen path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 71 +++---- .../COMPLETED-CHECKLIST.md | 67 +++++- crates/windows-file-watcher/DESIGN-NOTES.md | 115 +++++++---- crates/windows-file-watcher/src/directory.rs | 99 +-------- .../src/directory/tests.rs | 148 -------------- .../windows-file-watcher/src/monitor/tests.rs | 8 +- crates/windows-file-watcher/src/watcher.rs | 100 +++------ .../tests/reopen_by_id_cannot_be_watched.rs | 192 ++++++++++++++++++ 8 files changed, 407 insertions(+), 393 deletions(-) create mode 100644 crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index b8c1fbd9..3a35cba9 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -87,18 +87,19 @@ after a reopen lands on a different directory. Independent of M10 above; M12 bel (`OpenFileById`, reopens by the file reference `DirectoryId` already carries) plus `DirectoryHandle::canonical_path` (`GetFinalPathNameByHandleW`, needed because `OpenFileById` is path-independent and would otherwise silently follow a moved/renamed directory). See D-80 and - [Reopening by file reference, and why the fast path is off](DESIGN-NOTES.md#reopening-by-file-reference-and-why-the-fast-path-is-off). + [Reopening by file reference, and why the fast path is gone](DESIGN-NOTES.md#reopening-by-file-reference). + **Superseded by M15.2:** `reopen_by_id` is removed -- Windows rejects a directory-change read on a + by-id open, so it could never produce a watchable handle. - [x] **M11.2** -- `WatcherInner::reopen` tries `ReOpenFile` against its still-live previous handle first (the old endpoint is not torn down until after this succeeds or fails), falling back to the existing path-based `DirectoryHandle::open` only when that fails. Verify empirically (real-OS test, per this crate's D-52 precedent of measuring rather than assuming Win32 behavior) that `ReOpenFile` behaves as documented for a `FILE_FLAG_BACKUP_SEMANTICS` directory handle. -> `WatcherInner::reopen_via_existing_handle` - implements the `OpenFileById`-plus-`canonical_path` mechanism above, but returns `None` unconditionally: - measured to hang or (once) crash the process with `STATUS_STACK_BUFFER_OVERRUN` once a handle obtained - this way is associated with the thread pool's IOCP and armed, for a reason not yet root-caused. Every - reopen therefore uses the path-based fallback only, which is fully implemented and tested (M11.3/M11.4 - below do not depend on the fast path). See D-80. + implemented the `OpenFileById`-plus-`canonical_path` mechanism above but returned `None` unconditionally, + pending root-cause of a failure then attributed to IOCP association. **Superseded by M15.2:** root-caused + to an OS limitation with nothing to do with IOCP, and the whole fast path removed. Every reopen is + path-based, which is what M11.3/M11.4 were already written against. See D-80. - [x] **M11.3** -- Track each `DirectoryWatcher`'s current `VolumeIdentity`, recorded (no comparison) at first establish, compared only on the path-based fallback path -- a `ReOpenFile` success needs no @@ -111,10 +112,12 @@ after a reopen lands on a different directory. Independent of M10 above; M12 bel - [x] **M11.5** -- Integration test: a manufactured reopen through `ReOpenFile` returns a handle to the same file (`DirectoryId` unchanged) while the original handle stays open; a deleted-and-recreated directory falls back to the path-based open and picks up its (possibly different) new identity, re-keying - `Resident.directories` correctly. -> `directory::tests` covers the file-reference-reopen identity claims - (`reopen_by_id_*`, including the rename hazard the fast path's disablement is about); `monitor::tests`'s + `Resident.directories` correctly. -> `monitor::tests`'s `a_path_based_reopen_that_lands_on_a_new_directory_rekeys_so_a_later_subscription_still_coalesces` covers - the re-keying claim end to end. + the re-keying claim end to end. **Superseded by M15.2** for the file-reference half: the `reopen_by_id_*` + identity tests are removed with the mechanism they characterised, and what replaces them is + [tests/reopen_by_id_cannot_be_watched.rs](tests/reopen_by_id_cannot_be_watched.rs), which asserts the OS + limitation that removal rests on. ## M12 -- Per-subscription volume-change confirmation (D-78) @@ -132,31 +135,22 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- - [x] **M15.1** -- Resolved the unreachable half of `StandingHold::drop`: it was the drain path until `take` took the release over, and it could not have run safely -- reaching it deadlocks on the `items` lock its caller already holds. Replaced by an exercised tripwire. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m151) -- [ ] **M15.2** -- Explain, then either fix or document, why a handle from `reopen_by_id` **rejects the - very read the watcher exists to issue**. Found while chasing a surviving mutant; the mutant is the - symptom and this is the disease. - **The measurement.** Open a temp directory with `DirectoryHandle::open`, reopen it with - `DirectoryHandle::reopen_by_id`, and issue the same overlapped `ReadDirectoryChangesW` on each - (DWORD-aligned buffer, `FILE_NOTIFY_CHANGE_FILE_NAME`, null `lpBytesReturned`, an `OVERLAPPED`, no - completion routine). The **original** handle accepts it -- returning TRUE with the operation pending, - which the call site in `watcher.rs` documents as normal. The **reopened** handle fails it with - `ERROR_INVALID_PARAMETER` (87). - **Why it matters.** `reopen_by_id` requests `FILE_LIST_DIRECTORY` and - `FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED` through `OpenFileById`, which reads as a handle - fit for watching. If it is not, the reopen path either cannot serve a watch at all, or serves it only - because some later step re-derives a usable handle -- and which of those is true is not visible from - the code. - **Three readings, and the engineer can tell them apart faster than a probe can.** (a) A real defect in - the reopen path. (b) A legitimate difference in what `OpenFileById` returns (an access right such as - `SYNCHRONIZE`, or volume-hint semantics) that the reopen path compensates for elsewhere. (c) A defect - in the measurement above, though it was run as a control against the original handle in the same - process and the original passed. - **What this explains.** `directory.rs:457`'s `|` -> `&` mutant survives -- the one that zeroes both - flags -- because every `reopen_by_id` test asserts only *which* directory came back, never that the - handle is usable afterwards. No test can close that gap until the behaviour above is understood, so - writing one now would encode whichever answer happened to be true. - A test asserting handle usability was written and then **removed rather than committed red**; it is - reconstructible from the measurement recorded here. +- [x] **M15.2** -- Explained why a `reopen_by_id` handle rejects the watcher's own read: Windows refuses a directory-change read on any by-id open, holding access, mode, create options and resolved path identical. The fast path is removed, not disabled. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m152) + +- [ ] **M15.8** -- Decide whether `WatcherInner::canonical_path` and `DirectoryHandle::canonical_path` + survive M15.2's removal. **Spawned by M15.2, and it changes M15.3's scope, so decide this first.** + **The state.** `WatcherInner::canonical_path` is now written on every install + ([src/watcher.rs](src/watcher.rs)) and **never read**. Its only reader was + `reopen_via_existing_handle`, which compared it against the reopened object's current path to catch + `OpenFileById` following a rename; D-80 removed that. The compiler does not flag it, because + `lock(&self.canonical_path)` counts as a read of the field -- so this is dead weight that no warning + will ever surface, which is exactly the shape M15.1 was. + **Why it is not just deleted.** Removing the field strands `DirectoryHandle::canonical_path`, which + strands the free `canonical_path` and its `GetFinalPathNameByHandleW` call -- and that call's 512-unit + retry is the entire subject of **M15.3**. Deleting it dissolves M15.3 rather than answering it. + **Two coherent outcomes.** Remove both (and close M15.3 as moot), or keep them for diagnostics and give + the field a reader that justifies it. What is not legitimate is leaving a write-only field with a doc + comment describing a reader that no longer exists. - [ ] **M15.3** -- Decide whether this crate should open paths longer than `MAX_PATH`, and note the consequence for `canonical_path`'s retry either way. @@ -262,11 +256,4 @@ when a post-v1 line of work takes one up. None is an open obligation of any curr - [ ] **M-inf.3** -- Per-volume capability cache: remember detailed-vs-coarse (and extended-record) support per volume so establish/re-establish need not re-probe each time (D-17/D-19). -- [ ] **M-inf.4** -- Root-cause and, if fixed, re-enable M11.2's fast reopen path - (`WatcherInner::reopen_via_existing_handle`, currently hard-coded to return `None`): a handle obtained via - `OpenFileById` hangs, or once crashed the process with `STATUS_STACK_BUFFER_OVERRUN`, once associated - with the thread pool's IOCP and armed (D-80). `DirectoryHandle::reopen_by_id`/`canonical_path` are each - independently correct per `directory::tests`; the defect is specifically in the IOCP-association/arm - path against such a handle. Deferred because it needs dedicated low-level debugging (likely a minimal - repro outside this crate) rather than blocking M11/M12 on it -- the path-based-only reopen it falls back - to is fully correct, just without the optimization. +- [x] **M-inf.4** -- Root-caused M11.2's fast reopen path: not an IOCP defect at all, but Windows refusing a directory-change read on any by-id open, so the path was removed rather than fixed. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m152) diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index 0c547379..e0a8480a 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -600,4 +600,69 @@ body. `queue.rs` now has **4 missed mutants, none in this impl**. **Blast-radius sweep.** The survivors were the symptom of a doc gone false by vacuity: `Entry`, `StandingHold`, `StandingState`, and `take` all described this `Drop` as the live release mechanism. Four restatements of one fact, none of which moved when the fact did; all four corrected here, plus the note in -`queue/tests.rs`. Recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> `Dead code that could not have run`. \ No newline at end of file +`queue/tests.rs`. Recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md) -> `Dead code that could not have run`. +## Moved 2026-09-01 -- M15.2 / M-inf.4: why a by-id reopen cannot be watched + +### M15.2 -- Explain, then either fix or document, why a handle from `reopen_by_id` rejects the very read the watcher exists to issue. *(completed 2026-09-01 19:05:00 -04:00)* + +Closes **M-inf.4** as well, which had parked exactly this root-cause question. + +**Root cause, measured with a control.** A standalone probe -- no thread pool, no IOCP, no crate code, +just `CreateFileW`, `OpenFileById`, `NtCreateFile` and a bare `ReadDirectoryChangesW`: + +| How the directory was opened | `ReadDirectoryChangesW` | +|---|---| +| `CreateFileW`, by path | TRUE (pending) | +| `OpenFileById` | FALSE, `ERROR_INVALID_PARAMETER` (87) | +| `NtCreateFile` by id, **with** `FILE_DIRECTORY_FILE` | FALSE, 87 | +| `NtCreateFile` by id, without `FILE_DIRECTORY_FILE` | FALSE, 87 | +| **control:** `NtCreateFile` **by name**, same options | TRUE (pending) | + +All five handles are identical everywhere it is natural to look: `FileModeInformation` reports each +asynchronous (neither `SYNCHRONOUS_IO` bit set), `FileAccessInformation` reports the same granted mask +`0x00100081`, and `FileNameInformation` resolves the same path. The only variable that changes the +outcome is whether the object was resolved **by file ID** or **by name**. + +**The checklist's three readings, adjudicated.** (b) is disproved -- it is not `SYNCHRONIZE` or +volume-hint semantics, since access and mode are byte-identical. (c) is disproved by the by-name control +through the identical `NtCreateFile` call. It is (a): the reopen path cannot serve a watch. + +**D-80's recorded cause was wrong, which mattered.** It attributed the failure to "the `OpenFileById` +handle's interaction with IOCP association/arming specifically" and recorded the cause as not understood. +IOCP is not involved at all. An attribution recorded as unexplained is not inert -- it had named a +subsystem, and naming the wrong one is worse than naming none, because it points the next investigation +away from the answer. + +**Confirmed against the crate.** Re-enabling the fast path fails six tests, every one a fault-resolution +test, with "the fault never resolved after being answered" -- D-80's symptom verbatim. Instrumenting the +arm prints `Os { code: 87, kind: InvalidInput }`. The read never completes because it never starts: the +arm fails, the watcher re-faults, retries, reopens by id again, and fails identically. + +**Removed, not disabled.** It is impossible rather than blocked, and it could not have paid for itself +either way: `reopen_via_existing_handle` returned its candidate only when the reopened object's path +already equalled the watcher's recorded canonical path, so by construction it could only ever hand back a +handle to the object *at the path the path-based fallback already opens*. Gone: +`WatcherInner::reopen_via_existing_handle`, `DirectoryHandle::reopen_by_id`, +`DirectoryId::file_reference`, and the four `reopen_by_id_*` identity tests that characterised the +mechanism. + +**What replaces them.** [tests/reopen_by_id_cannot_be_watched.rs](tests/reopen_by_id_cannot_be_watched.rs) +asserts the OS limitation itself, including the by-name control, so the decision rests on something that +executes rather than on a paragraph. If a future Windows accepts that read, the test fails and D-80 should +be revisited. + +**The surviving mutant is explained rather than closed.** `directory.rs`'s `|` -> `&` in `reopen_by_id` +zeroed both flags; measurement shows `OpenFileById` on a directory succeeds with **flags = 0** +(`FILE_FLAG_BACKUP_SEMANTICS` is not required for a by-id open the way it is for `CreateFileW`), and the +only difference is that the handle becomes synchronous. That property is observable exclusively through an +I/O this handle can never perform -- so the mutant was unkillable, and it is now moot: the function is +gone. + +**Blast-radius sweep.** D-80's decision row and detail section, the reopen paragraph above it, the field +and method docs on `WatcherInner::directory_id` / `canonical_path` / `DirectoryHandle::canonical_path`, +`retry_reestablish`, `on_path_based_reopen`, a `monitor::tests` comment, and the M11.1/M11.2/M11.5 +checklist entries all described the fast path as live or as pending root-cause; all corrected. +`COMPLETED-PLANS.md` is left alone as dated, append-only history. + +**Spawned M15.8:** `WatcherInner::canonical_path` is now write-only, and no warning will ever surface it +because `lock(&self.canonical_path)` counts as a read of the field. \ No newline at end of file diff --git a/crates/windows-file-watcher/DESIGN-NOTES.md b/crates/windows-file-watcher/DESIGN-NOTES.md index ffaa91d0..6a219215 100644 --- a/crates/windows-file-watcher/DESIGN-NOTES.md +++ b/crates/windows-file-watcher/DESIGN-NOTES.md @@ -98,7 +98,7 @@ threads of its own. | D-77 | **The per-subscription change-type filter that M4 reserved space for is *withdrawn*, not deferred: it is not faithfully implementable under D-6 coalescing, and the one shape of it that looks implementable (namespace-only) is actively harmful to the workload it appears to serve.** The kernel filter is expressed in *change classes* (`FILE_NOTIFY_CHANGE_SIZE`, `_LAST_WRITE`, `_ATTRIBUTES`, `_LAST_ACCESS`, `_SECURITY`, `_FILE_NAME`, `_DIR_NAME`) but records arrive as *action codes* (`FILE_ACTION_*`), and that mapping is lossy in exactly the wrong direction: all five non-namespace classes collapse into the single `ChangeKind::Modified` action. Because a directory has exactly one watcher armed with the *union* of its subscriptions' masks (D-6), a route asking for size-only changes receives `Modified` records it cannot attribute to a class, and must therefore either over-deliver (defeating the filter) or under-deliver (dropping changes it asked for). Restricting the feature to the namespace classes -- which *are* recoverable from the action code -- fails for a different reason: a name appearing is not a file being complete, its content streams in afterward as `Modified`, and the only workable completeness test on Windows is a quiescence heuristic (openable, parses, then quiet for N ms) built on precisely the `Modified` traffic such a filter discards. Filtering is therefore not a neutral reduction in volume; it destroys the crate's only evidence of ongoing work. The contract this crate keeps instead is *completeness*: a change notification is positive evidence that a file was **not** finished, never evidence that it was, and a client can only reason about quiescence if it sees every change. This also makes D-12's unfilterable `Desync` load-bearing rather than incidental -- a gap in the event set must invalidate any in-flight settling window, which is only sound while `Desync` cannot be filtered out. **This decision schedules no work**: there is deliberately no checklist item anywhere for a change-type filter, and the absence is intentional rather than an oversight. If a future need arises, the only implementable shape is a client-side predicate over the already-decoded `ChangeKind` (which cannot narrow the kernel mask and so buys no kernel-side efficiency), not a `FILE_NOTIFY_CHANGE_*` mask on `WatchOptions`; that shape is recorded here and remains unscheduled. Rationale and the full design discussion: [DESIGN-RATIONALE.md](DESIGN-RATIONALE.md) -> D-77. | | D-78 | **A reopen that lands on a different volume than before is a per-subscription confirmation, not a silent continuation or a directory-wide veto.** `WatcherInner::reopen` reopens by path and never checked whether the result is still the same volume, so removable media swapped for different media at the same path (the classic case: NTFS media replaced by FAT32) was silently absorbed, with the client learning about it, if at all, only as an ordinary `Established { Coarse }` should the new volume happen to need the fallback tier. See [Volume identity confirmation on reopen](#volume-identity-confirmation-on-reopen). | | D-79 | **Supersedes [D-54](#d-54): every fault/failure message now carries a `FaultDetail` (this crate's `OpenFailure` classification plus a `FailureCode`), not just which operation faulted.** A client asked to choose a retry delay, or told a subscription failed permanently, previously had no way to know *why* -- `FaultOperation` says only `Open` or `Arm`, and the raw error was logged (D-58) and discarded. `FailureCode` is `Win32(u32)` or `HResult(i32)` rather than one currency: every source in this crate today is a classic last-error API, so `Win32` is the only variant anything currently produces, but a value is kept in the currency it actually arrived in rather than converted through `HRESULT_FROM_WIN32`/`HRESULT_CODE` to force a single shape. See [Failure detail on every fault report](#failure-detail-on-every-fault-report). | -| D-80 | **M11.2's fast reopen path is disabled (returns `None` unconditionally), and reopens by `OpenFileById` (file reference), not `ReOpenFile` (handle), when re-enabled.** Both were measured against the actual OS (D-52's precedent) rather than assumed: `ReOpenFile` against a directory handle fails outright with `ERROR_ACCESS_DENIED` for an ordinary, unprivileged process (it needs `SeBackupPrivilege` *enabled*, which `FILE_FLAG_BACKUP_SEMANTICS` does not grant); `OpenFileById` reopens correctly by identity (confirmed delete-pending-safe and recreate-safe) but is path-independent, which is its own hazard (it would silently keep following a moved/renamed directory away from the path a client subscribed to -- caught by comparing `GetFinalPathNameByHandleW` before trusting it); and, independently of both, a handle obtained via `OpenFileById` hangs or (once) crashes with `STATUS_STACK_BUFFER_OVERRUN` once associated with the thread pool's `ThreadpoolIo`/IOCP and armed, for a reason not yet root-caused. See [Reopening by file reference, and why the fast path is off](#reopening-by-file-reference-and-why-the-fast-path-is-off). | +| D-80 | **M11.2's fast reopen path is removed: reopening a watched directory by file reference cannot work, because Windows rejects a directory-change read on a by-id open.** Superseding this decision's earlier revision, which had it merely *disabled* pending root-cause and attributed the failure to IOCP association; that attribution was measured wrong (see Round 3). Both were measured against the actual OS (D-52's precedent) rather than assumed: `ReOpenFile` against a directory handle fails outright with `ERROR_ACCESS_DENIED` for an ordinary, unprivileged process (it needs `SeBackupPrivilege` *enabled*, which `FILE_FLAG_BACKUP_SEMANTICS` does not grant); `OpenFileById` reopens correctly by identity (confirmed delete-pending-safe and recreate-safe) but is path-independent, which is its own hazard (it would silently keep following a moved/renamed directory away from the path a client subscribed to -- caught by comparing `GetFinalPathNameByHandleW` before trusting it); and, independently of both, a handle obtained via `OpenFileById` is rejected by `ReadDirectoryChangesW` with `ERROR_INVALID_PARAMETER` -- not because of anything this crate does, but because resolving the object **by file ID** rather than **by name** is itself what the read refuses, holding access mask, sync/async mode, create options, and resolved path all identical (controlled measurement, asserted by [tests/reopen_by_id_cannot_be_watched.rs](tests/reopen_by_id_cannot_be_watched.rs)). The fast path could not have paid for itself in any case: it returned a candidate only when its path already equalled the watcher's recorded canonical path, so it could only ever reopen the object at the path the path-based fallback opens anyway. See [Reopening by file reference, and why the fast path is gone](#reopening-by-file-reference). | | D-81 | **The consumer test surface reuses the delivery model rather than replacing it, and its already-reachable pieces -- `WatchId::from_raw` and every re-exported boundary type -- are blessed as-is, not re-gated.** A downstream consumer tests its own notification-handling code by feeding synthetic `Notification`s through a real `Receiver`: "go below" the `Monitor`, substituting the OS ingest while keeping the delivery model (`Notification`/`Receiver`/queue ordering/doorbell) intact. The reachable pieces shipped public in 0.1, so re-gating them would be a breaking change with no offsetting safety gain. The one further thing a consumer needs -- a `Receiver` it can feed -- was `pub` only inside a private module (hence unreachable), so it is *exposed*, not re-gated, under `test-util` (D-82). See [Consumer test surface](#consumer-test-surface). | | D-82 | **Everything a consumer needs but cannot otherwise reach is exposed behind an off-by-default `test-util` feature, not on the unconditional public surface: the feedable channel (`channel_with_bound` with `Sender`/`Delivery`/`Reservation`, previously `pub` only inside a private module) and valid-by-construction builders for the two unconstructible boundary types (`RelativeName`, `VolumeIdentity`).** This does not reverse [D-64](DESIGN-RATIONALE.md#the-m64-test-seam-is-a-private-constructor-not-a-public-feature-flag-d-64): D-64's seams serve the crate's own tests reaching internal state, for which `#[cfg(test)]`/`pub(crate)` is strictly better; this seam serves a downstream consumer's tests, which `#[cfg(test)]` cannot reach at all, and it exposes the delivery channel and public boundary constructors rather than internal state (so the retired `unstable-internals` objection does not apply). Feature-gating keeps the crate's internal queue sender, and identity/name construction, out of the production API. See [Consumer test surface](#consumer-test-surface). | | D-83 | **The consumer test surface tests the consumer's reactions, not whether this crate would ever emit a given sequence.** Builders are valid-by-construction in the type-safety sense (memory-safe, lossless), not production-domain-validating: a `RelativeName` can still carry a unit sequence the kernel itself never reports (an interior NUL, say), and an impossible ordering or an impossible relationship between two otherwise valid values (a `VolumeChanged` with equal `previous`/`current` serials, each individually a legal `VolumeIdentity`) both remain the consumer's responsibility, as with any hand-fed test double. This fidelity limit is documented on the surface so a passing handler test is not mistaken for confirmation that the crate produces that traffic. See [Consumer test surface](#consumer-test-surface). | @@ -303,27 +303,24 @@ avoids). `remove_route_from_volume_change` mirrors D-27's "leaving counts as declining": a route removed while its question is outstanding resolves as `Stop` for that route rather than wedging the awaiting set forever. -A reopen tries `OpenFileById` against the file reference `DirectoryId` already -computes, using whichever handle is currently installed only as the volume -hint (`hVolumeHint`) `OpenFileById` requires -- not itself the object being -reopened, so it stays valid even once that object is gone. Reopening by file -reference rather than by handle (`ReOpenFile`) or by path (`CreateFileW`) is -structurally incapable of landing on a different filesystem object than the -one the reference already names, so when it succeeds the volume is provably -unchanged and no `VolumeIdentity` comparison is needed for *that* purpose at -all. It fails only when the original object is genuinely gone (deleted, or its -media was ejected), which is exactly when the path-based fallback is needed -- -and only that fallback path can legitimately land on a different `DirectoryId`, -so only it re-keys `Resident.directories` (previously fixed at first -insertion, never updated -- a second latent bug this closes: a stale key would -have made a later new subscription to the same path fail to coalesce onto the -existing watcher and spin up a redundant second one). - -### Reopening by file reference, and why the fast path is off - -D-80: two rounds of measurement (D-52's precedent -- verify empirically, -never assume) replaced the reopen mechanism above's original design and then -suspended it entirely. +A reopen goes straight to the path-based `CreateFileW`. It was originally +designed to try `OpenFileById` first, against the file reference `DirectoryId` +already computes -- structurally incapable of landing on a different filesystem +object than the one the reference names, so a success would have proved the +volume unchanged without any `VolumeIdentity` comparison. That fast path was +removed once the read it exists to serve turned out to reject such a handle +outright (D-80, below). What remains is the fallback, and it is the part that +carries the real work anyway: only a path-based open can legitimately land on a +different `DirectoryId`, so it is what re-keys `Resident.directories` (previously +fixed at first insertion, never updated -- a latent bug this closes: a stale key +would have made a later new subscription to the same path fail to coalesce onto +the existing watcher and spin up a redundant second one). + +### Reopening by file reference, and why the fast path is gone + +D-80: three rounds of measurement (D-52's precedent -- verify empirically, +never assume) replaced the reopen mechanism above's original design, then +suspended it, and finally removed it. **Round 1 -- `ReOpenFile` does not work here.** The original design tried `ReOpenFile` against the watcher's still-live previous handle. Measured @@ -337,7 +334,8 @@ admin or backup-operator tool) does not have this privilege, so the mechanism is not viable for this crate's general audience. **Round 2 -- `OpenFileById` works for identity, but exposes a path hazard and -an unexplained IOCP defect.** `OpenFileById` opens by file reference number +a failure that looked like an IOCP defect.** `OpenFileById` opens by file +reference number (exactly what `DirectoryId` already carries) plus a volume-hint handle, and needs no special privilege. Measured correct on every identity question: it reopens the same object while delete-pending, ignores an unrelated object @@ -355,22 +353,63 @@ Even with that guarded, a further, independent defect was found: a handle obtained via `OpenFileById`, once handed into this crate's ordinary establish path (`UnassociatedEndpoint::assume_overlapped` -> `ThreadpoolIo::new` -> armed with `ReadDirectoryChangesW`), reliably fails to -resolve the fault it was reopening for -- the read never completes -- and on -one run crashed the process with `STATUS_STACK_BUFFER_OVERRUN`. Bisection -(temporarily short-circuiting `reopen_via_existing_handle` to return early) -localized this to the `OpenFileById` handle's interaction with IOCP -association/arming specifically: `DirectoryHandle::reopen_by_id` and -`canonical_path` are each independently correct per their own unit tests, and -the defect reproduces with the path check never reached. The cause is not -yet understood. - -**Current state:** `WatcherInner::reopen_via_existing_handle` returns `None` -unconditionally, so every reopen uses the path-based fallback -- the +resolve the fault it was reopening for -- the read never completes. + +**Round 3 -- root-caused, and it is neither IOCP nor this crate.** An earlier +revision of this note attributed that failure to "the `OpenFileById` handle's +interaction with IOCP association/arming specifically" and recorded that the +cause was not understood. That attribution was wrong, and it pointed future work +at the wrong subsystem. A standalone probe -- no thread pool, no IOCP, no crate +code, just `CreateFileW`, `OpenFileById`, and a bare `ReadDirectoryChangesW` -- +reproduces it exactly: + +| How the directory was opened | `ReadDirectoryChangesW` | +|---|---| +| `CreateFileW`, by path | TRUE (pending) | +| `OpenFileById` | FALSE, `ERROR_INVALID_PARAMETER` (87) | +| `NtCreateFile` by id, **with** `FILE_DIRECTORY_FILE` | FALSE, 87 | +| `NtCreateFile` by id, without `FILE_DIRECTORY_FILE` | FALSE, 87 | +| **control:** `NtCreateFile` **by name**, same options | TRUE (pending) | + +All five handles are indistinguishable where it would be natural to look: +`FileModeInformation` reports every one asynchronous (neither `SYNCHRONOUS_IO` +bit set), `FileAccessInformation` reports the identical granted mask +`0x00100081`, and `FileNameInformation` resolves the identical path. The only +variable that changes the outcome is whether the object was resolved **by file +ID** or **by name**. Windows does not accept a directory-change read on a by-id +open, and no combination of access, flags, or create options reaches it. + +That also explains the shape of the original symptom. The arm does not hang; it +fails immediately, the watcher re-enters its fault loop, retries, reopens by id +again, and fails identically -- so the fault is never resolved. Confirmed against +the crate: re-enabling the fast path fails six tests, every one of them a +fault-resolution test, with "the fault never resolved after being answered", and +instrumenting the arm prints `Os { code: 87, kind: InvalidInput }`. + +**Current state: the fast path is removed, not disabled.** It was not blocked +pending a root cause; it is impossible. And it could not have paid for itself in +any case: `reopen_via_existing_handle` returned its candidate only when the +reopened object's current path equalled the watcher's recorded canonical path, so +by construction it could only ever hand back a handle to the object *at the path +the path-based fallback already opens*. `WatcherInner::reopen_via_existing_handle`, +`DirectoryHandle::reopen_by_id`, and `DirectoryId::file_reference` are gone; +`retry_reestablish` goes straight to the path-based open, and the DirectoryId/VolumeIdentity comparison and `Resident.directories` re-keying -described above, which do not depend on the fast path and are unaffected. The -`OpenFileById`/`canonical_path` machinery is kept, verified, and ready; only -the wiring that would hand its result into IOCP association is disabled, -pending whoever root-causes that interaction. +described above are unchanged, having never depended on the fast path. + +The OS limitation itself is asserted by +[tests/reopen_by_id_cannot_be_watched.rs](tests/reopen_by_id_cannot_be_watched.rs), +including the by-name control, so this decision rests on something that executes +rather than on a paragraph. If a future Windows accepts that read, the test fails +and this decision should be revisited. + +Two things worth carrying out of this. First, an attribution recorded as +"unexplained" is not inert: it had named a subsystem, and naming the wrong one is +worse than naming none, because it directs the next investigation away from the +answer. Second, the reason this went unexplained for so long is that every +natural place to look -- access mask, sync/async mode, resolved path -- shows the +two handles as identical; only a control that varied *how the object was +resolved* while holding all of that fixed could isolate it. ### Failure detail on every fault report diff --git a/crates/windows-file-watcher/src/directory.rs b/crates/windows-file-watcher/src/directory.rs index fdb6827b..4e809869 100644 --- a/crates/windows-file-watcher/src/directory.rs +++ b/crates/windows-file-watcher/src/directory.rs @@ -30,7 +30,7 @@ //! is stable for as long as the file exists regardless of how it was reached. use std::os::windows::ffi::OsStringExt; -use std::os::windows::io::{AsRawHandle, BorrowedHandle, FromRawHandle, OwnedHandle}; +use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use std::path::{Path, PathBuf}; use wtf_string::Wtf16String; @@ -41,11 +41,10 @@ use windows_sys::Win32::Foundation::{ }; use windows_sys::Win32::Storage::FileSystem::{ BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_ATTRIBUTE_DIRECTORY, FILE_CASE_SENSITIVE_INFO, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_ID_DESCRIPTOR, FILE_ID_DESCRIPTOR_0, - FILE_LIST_DIRECTORY, FILE_NAME_NORMALIZED, FILE_SHARE_DELETE, FILE_SHARE_READ, - FILE_SHARE_WRITE, FileCaseSensitiveInfo, FileIdType, GetFileInformationByHandle, - GetFileInformationByHandleEx, GetFinalPathNameByHandleW, GetVolumeInformationByHandleW, - OPEN_EXISTING, OpenFileById, VOLUME_NAME_DOS, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, FILE_LIST_DIRECTORY, FILE_NAME_NORMALIZED, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FileCaseSensitiveInfo, + GetFileInformationByHandle, GetFileInformationByHandleEx, GetFinalPathNameByHandleW, + GetVolumeInformationByHandleW, OPEN_EXISTING, VOLUME_NAME_DOS, }; /// What a failed open means for the retry policy. @@ -287,16 +286,6 @@ pub(crate) struct DirectoryId { file_index: u64, } -impl DirectoryId { - /// The NTFS file reference number, in the exact form `OpenFileById`'s - /// `FILE_ID_DESCRIPTOR` needs (M11.2/D-78) -- this is the same value - /// `identify` already read via `GetFileInformationByHandle`, not a fresh - /// syscall. - pub(crate) fn file_reference(self) -> u64 { - self.file_index - } -} - /// Read a directory's identity from a live handle, rejecting one that turns out /// not to be a directory. /// @@ -408,71 +397,6 @@ impl DirectoryHandle { }) } - /// Reopen the directory identified by `file_id` on the same volume as - /// `volume_hint` (D-78/M11), rather than by path: `OpenFileById` opens by - /// file reference number, so it is structurally incapable of landing on a - /// different filesystem object than the one `file_id` already names -- - /// unlike a fresh `CreateFileW` against the original path, which cannot - /// tell a recreated directory from the one this watcher started on. - /// - /// Measured empirically in preference to `ReOpenFile` (D-52's precedent): - /// `ReOpenFile` against a directory handle consistently failed with - /// `ERROR_ACCESS_DENIED` on an ordinary, unprivileged process (it needs - /// `SeBackupPrivilege` *enabled*, not merely `FILE_FLAG_BACKUP_SEMANTICS`, - /// which only exempts the check on a fresh `CreateFileW`). `OpenFileById` - /// carries no such requirement here. - /// - /// `volume_hint` only needs to name *some* still-open handle on the same - /// volume as `file_id` -- it is never itself the object being reopened, - /// so it stays valid even once `file_id`'s own object is gone. - /// - /// # Errors - /// - /// Returns a classified [`OpenError`] if `OpenFileById` fails -- most - /// often because the original object no longer exists (deleted, or its - /// volume was ejected). That is exactly when the path-based fallback is - /// needed. - pub(crate) fn reopen_by_id( - volume_hint: BorrowedHandle<'_>, - file_id: u64, - ) -> Result { - let descriptor = FILE_ID_DESCRIPTOR { - dwSize: u32::try_from(size_of::()) - .expect("this fixed, small struct's size always fits a u32"), - Type: FileIdType, - Anonymous: FILE_ID_DESCRIPTOR_0 { - FileId: file_id.cast_signed(), - }, - }; - // SAFETY: `volume_hint` is borrowed and live for the duration of this - // call; `descriptor` is a fully initialized, valid `FILE_ID_DESCRIPTOR` - // the callee only reads. - let raw = unsafe { - OpenFileById( - volume_hint.as_raw_handle(), - &descriptor, - FILE_LIST_DIRECTORY, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - std::ptr::null(), - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, - ) - }; - if raw == INVALID_HANDLE_VALUE { - let source = std::io::Error::last_os_error(); - return Err(OpenError::new(classify(&source), source)); - } - // SAFETY: `OpenFileById` returned a live handle that this call - // exclusively owns. - let owned = unsafe { OwnedHandle::from_raw_handle(raw) }; - let identity = identify(owned.as_raw_handle())?; - let case_sensitive = is_case_sensitive_dir(owned.as_raw_handle()); - Ok(Self { - handle: owned, - identity, - case_sensitive, - }) - } - /// This directory's stable identity (D-6). pub(crate) fn identity(&self) -> DirectoryId { self.identity @@ -516,13 +440,12 @@ impl DirectoryHandle { /// queried fresh via `GetFinalPathNameByHandleW` rather than cached from /// whatever string originally opened it (M11.2/D-78). /// - /// `OpenFileById` reopens by file reference, which is path-independent: - /// it keeps finding the same object even after it is moved or renamed - /// elsewhere in the namespace. A client subscribed to a path expects to - /// watch *that path*, not "wherever this object ends up" -- so a fast - /// `OpenFileById` reopen must confirm the object is still where this - /// watcher's own canonical path last recorded it before trusting it, or - /// fall back to a path-based reopen instead. + /// Introduced to guard the by-reference fast reopen: `OpenFileById` is + /// path-independent, so it keeps finding the same object after a move or + /// rename, and a client subscribed to a path expects to watch *that path* + /// rather than wherever the object ends up. D-80 removed that reopen path, + /// so the only remaining caller records the result without reading it back + /// -- see M15.8, which owns whether this stays. /// /// # Errors /// diff --git a/crates/windows-file-watcher/src/directory/tests.rs b/crates/windows-file-watcher/src/directory/tests.rs index d8eb1fe7..2e74125e 100644 --- a/crates/windows-file-watcher/src/directory/tests.rs +++ b/crates/windows-file-watcher/src/directory/tests.rs @@ -264,154 +264,6 @@ fn every_failure_class_agrees_with_its_retry_policy() { assert!(!OpenFailure::InvalidPath.is_retryable()); } -// --- M11: measuring `OpenFileById` against a live directory handle -// empirically, per D-52's precedent of measuring rather than assuming Win32 -// behavior. (`ReOpenFile` was tried first and consistently failed with -// `ERROR_ACCESS_DENIED` against a directory on an ordinary, unprivileged -// process -- it needs `SeBackupPrivilege` *enabled*, which -// `FILE_FLAG_BACKUP_SEMANTICS` alone does not grant.) --- - -#[test] -fn reopen_by_id_preserves_identity_while_the_original_stays_open() { - let dir = TempDir::new("reopen-same-identity"); - let original = DirectoryHandle::open(dir.path()).expect("open"); - let original_identity = original.identity(); - - // SAFETY: `original`'s handle is live for the whole body of this test. - let hint = unsafe { std::os::windows::io::BorrowedHandle::borrow_raw(original.as_raw()) }; - let reopened = DirectoryHandle::reopen_by_id(hint, original_identity.file_reference()) - .expect("OpenFileById against a live handle's own file reference"); - - assert_eq!( - reopened.identity(), - original_identity, - "OpenFileById must reopen the same object its file reference already names" - ); - // The original handle is untouched by reopening it: a second, independent - // syscall against it still agrees. - assert_eq!(original.identity(), original_identity); - - drop(reopened); - drop(original); - dir.cleanup(); -} - -#[test] -fn reopen_by_id_survives_the_directory_being_deleted_from_under_it() { - // Measured, not assumed (D-52): a directory handle opened with - // `FILE_SHARE_DELETE` (this crate's own share mode) keeps its underlying - // object alive -- "delete pending" -- for as long as the handle stays - // open, even after every directory-entry reference to it is gone. This is - // exactly the state `WatcherInner::reopen_via_existing_handle` reopens - // against, so this measures precisely that, not a hypothetical. - let dir = TempDir::new("reopen-deleted"); - let original = DirectoryHandle::open(dir.path()).expect("open"); - let original_identity = original.identity(); - - std::fs::remove_dir(dir.path()).expect("unlink the directory while the handle is still open"); - - // SAFETY: `original`'s handle is still open -- only its directory entry - // was removed, not the handle itself -- and serves only as the volume - // hint here, not as the object being reopened. - let hint = unsafe { std::os::windows::io::BorrowedHandle::borrow_raw(original.as_raw()) }; - let reopened = DirectoryHandle::reopen_by_id(hint, original_identity.file_reference()) - .expect("OpenFileById against a delete-pending object's own file reference"); - - assert_eq!( - reopened.identity(), - original_identity, - "OpenFileById reopens the same (delete-pending) object its file reference names, \ - never a different one that happens to appear at the original path later" - ); - - drop(reopened); - drop(original); - // Nothing left on disk to clean up: the directory was already unlinked. -} - -#[test] -fn reopen_by_id_ignores_a_new_directory_recreated_at_the_same_path() { - // The critical measurement M11.2's design depends on: once a *new* - // directory exists at the original path, `OpenFileById` against the old - // file reference must keep reopening the *old* (delete-pending) object, - // never silently pick up the new one that happens to share the path. - let dir = TempDir::new("reopen-recreated"); - let original = DirectoryHandle::open(dir.path()).expect("open"); - let original_identity = original.identity(); - - std::fs::remove_dir(dir.path()).expect("unlink the directory while the handle is still open"); - std::fs::create_dir(dir.path()).expect("recreate a new directory at the same path"); - let fresh_identity = DirectoryHandle::open(dir.path()) - .expect("open the recreated directory") - .identity(); - assert_ne!( - original_identity, fresh_identity, - "a recreated directory must have a genuinely different identity for this test to mean anything" - ); - - // SAFETY: `original`'s handle is still open throughout, used only as the - // volume hint. - let hint = unsafe { std::os::windows::io::BorrowedHandle::borrow_raw(original.as_raw()) }; - let reopened = DirectoryHandle::reopen_by_id(hint, original_identity.file_reference()) - .expect("OpenFileById against a live file reference"); - - assert_eq!( - reopened.identity(), - original_identity, - "OpenFileById must never silently switch to a different object recreated at the same path" - ); - - drop(reopened); - drop(original); - dir.cleanup(); -} - -#[test] -fn reopen_by_id_follows_the_directory_if_it_is_renamed_and_canonical_path_detects_it() { - // The other side of `OpenFileById`'s path independence (M11.2): unlike a - // recreated-at-the-same-path object (a *different* file the reopen must - // ignore), a renamed *same* object is exactly what OpenFileById is - // supposed to keep following -- but `WatcherInner::reopen_via_existing_handle` - // must still notice the path no longer matches what this watcher was - // subscribed to, via `canonical_path`, rather than silently watching the - // directory at its new location under the old subscription. - let parent = TempDir::new("reopen-rename-parent"); - let original_path = parent.path().join("original"); - std::fs::create_dir(&original_path).expect("create the directory to be renamed"); - let original = DirectoryHandle::open(&original_path).expect("open"); - let original_identity = original.identity(); - let path_before = original - .canonical_path() - .expect("query the path before the rename"); - - let renamed_path = parent.path().join("renamed"); - std::fs::rename(&original_path, &renamed_path).expect("rename while the handle is open"); - - // SAFETY: `original`'s handle is still open throughout, used only as the - // volume hint. - let hint = unsafe { std::os::windows::io::BorrowedHandle::borrow_raw(original.as_raw()) }; - let reopened = DirectoryHandle::reopen_by_id(hint, original_identity.file_reference()) - .expect("OpenFileById against a live file reference"); - - assert_eq!( - reopened.identity(), - original_identity, - "a rename does not change the object's own identity" - ); - let path_after = reopened - .canonical_path() - .expect("query the path after the rename"); - assert_ne!( - path_before, path_after, - "OpenFileById follows the object to its new location, so the canonical path must \ - change -- this is exactly what `reopen_via_existing_handle` must detect and refuse" - ); - - drop(reopened); - drop(original); - parent.cleanup(); -} - #[test] fn volume_identity_equality_is_on_the_serial_alone() { // PR #20 review response: the filesystem name and volume label are both diff --git a/crates/windows-file-watcher/src/monitor/tests.rs b/crates/windows-file-watcher/src/monitor/tests.rs index 1b37e740..00c86515 100644 --- a/crates/windows-file-watcher/src/monitor/tests.rs +++ b/crates/windows-file-watcher/src/monitor/tests.rs @@ -477,10 +477,10 @@ fn a_path_based_reopen_that_lands_on_a_new_directory_rekeys_so_a_later_subscript monitor.quiesce(); assert_eq!(monitor.directory_count(), 1); - // Delete and recreate the watched directory: the fast, file-reference-based - // reopen path is currently disabled (D-80), so re-establishment always - // falls back to a path-based open here, which lands on a genuinely - // different `DirectoryId` than the one this watcher started under. + // Delete and recreate the watched directory: every reopen is a path-based + // open (D-80 removed the file-reference fast path), so re-establishment + // lands on a genuinely different `DirectoryId` than the one this watcher + // started under. std::fs::remove_dir_all(dir.path()).expect("delete the watched directory"); let deadline = Instant::now() + Duration::from_secs(10); loop { diff --git a/crates/windows-file-watcher/src/watcher.rs b/crates/windows-file-watcher/src/watcher.rs index aaa51f86..fdfad0aa 100644 --- a/crates/windows-file-watcher/src/watcher.rs +++ b/crates/windows-file-watcher/src/watcher.rs @@ -273,25 +273,29 @@ struct WatcherInner { /// crate), but never set outside a test. force_coarse: AtomicBool, /// The `DirectoryId` this watcher is currently known by -- i.e. the key - /// `Resident.directories` holds it under. Only a path-based reopen - /// fallback (M11.2) can legitimately change this; an `OpenFileById` - /// success is structurally incapable of landing on a different - /// filesystem object (D-78). + /// `Resident.directories` holds it under. A reopen is always path-based + /// (D-80 removed the by-reference fast path), and a path-based reopen can + /// legitimately land on a different filesystem object, so this is updated + /// on every one (M11.2/D-78). directory_id: Mutex, /// The last volume identity recorded from an installed handle (M11.3, - /// D-78 groundwork), compared against on the next path-based reopen - /// fallback. `None` only if `GetVolumeInformationByHandleW` itself failed - /// when this watcher was last installed. + /// D-78 groundwork), compared against on the next reopen. `None` only if + /// `GetVolumeInformationByHandleW` itself failed when this watcher was + /// last installed. volume_identity: Mutex>, /// This watcher's own canonical path (M11.2), recorded from the handle /// installed each time, via `GetFinalPathNameByHandleW` rather than /// `self.path` (a client-supplied string, possibly not even fully - /// resolved). `OpenFileById` is path-independent -- it keeps finding the - /// same object even after it is moved or renamed elsewhere -- so this is - /// what lets a fast reopen notice that and refuse to trust it, since a - /// client subscribed to a path expects to watch that path, not wherever - /// the object ends up. `None` only if the query itself failed when this - /// watcher was last installed. + /// resolved). `None` only if the query itself failed when this watcher was + /// last installed. + /// + /// **Currently written and never read.** Its one reader was the + /// by-reference fast reopen, which used it to notice that `OpenFileById` + /// had followed the object somewhere else and refuse to trust it; D-80 + /// removed that path, and nothing has consulted this since. Kept rather + /// than deleted only because removing it cascades into + /// `DirectoryHandle::canonical_path` and the retry M15.3 is about -- see + /// M15.8, which owns the decision. canonical_path: Mutex>, /// The resident-state map this watcher's directory entry lives in, bound /// once by [`DirectoryWatcher::bind_resident`] immediately after @@ -708,24 +712,18 @@ impl WatcherInner { /// `retry_timer`'s callback: attempt one re-establishment. /// - /// Tries `OpenFileById` against the file reference this watcher is - /// currently known by first (M11.2/D-78): structurally incapable of - /// landing on a different filesystem object, so no volume-identity - /// comparison or `DirectoryId` re-key is ever needed when it succeeds. - /// Falls back to the path-based `DirectoryHandle::open` only when that - /// fails -- most often because the original object is genuinely gone - /// (deleted, or its media was ejected). An open failure that is retryable - /// (D-22) re-enters the fault loop as an open-class fault; a permanent one - /// is the one edge that does not (`stopped`). An arm failure after a - /// successful open re-enters the fault loop as an arm-class fault. + /// Always a path-based `DirectoryHandle::open`. A by-reference reopen was + /// tried first here (M11.2/D-78) because it is structurally incapable of + /// landing on a different filesystem object; D-80 removed it, because + /// `ReadDirectoryChangesW` rejects a handle opened by file ID, so such a + /// reopen could never produce a watchable handle. An open failure that is + /// retryable (D-22) re-enters the fault loop as an open-class fault; a + /// permanent one is the one edge that does not (`stopped`). An arm failure + /// after a successful open re-enters the fault loop as an arm-class fault. fn retry_reestablish(self: &Arc) { if *lock(&self.gate) == ArmGate::TornDown { return; } - if let Some(handle) = self.reopen_via_existing_handle() { - self.finish_reopen(handle); - return; - } match DirectoryHandle::open(&self.path) { Ok(handle) => self.on_path_based_reopen(handle), Err(open_error) => { @@ -747,51 +745,9 @@ impl WatcherInner { } } - /// Try `OpenFileById` against the file reference this watcher is - /// currently known by, using the installed detailed endpoint's handle - /// only as the volume hint (M11.2). `None` if there is no detailed handle - /// installed (coarse mode, or nothing installed yet), if `OpenFileById` - /// itself failed, or if the reopened object's current path no longer - /// matches this watcher's own recorded canonical path -- `OpenFileById` - /// is path-independent, so it would otherwise silently keep following the - /// object after a move or rename elsewhere in the namespace, which a - /// client subscribed to a specific path does not expect. Either way, the - /// caller falls back to a path-based open. - /// - /// **Disabled for now (returns `None` unconditionally):** measured - /// (real-OS test, D-52's precedent) to hang or, once, crash with - /// `STATUS_STACK_BUFFER_OVERRUN` once a handle obtained this way is - /// associated with the thread pool's I/O completion port and armed -- - /// reproduced with `OpenFileById` alone, with `canonical_path`'s - /// comparison never reached. The cause is not yet understood; every - /// piece below (`DirectoryHandle::reopen_by_id`, `canonical_path`) is - /// independently verified correct by `directory::tests`, so the defect is - /// specifically in the IOCP-association/arm path against such a handle, - /// not in identity or path computation. Path-based reopen (this - /// function's caller's fallback) is unaffected and remains the only - /// active mechanism until this is root-caused. - fn reopen_via_existing_handle(&self) -> Option { - return None; - #[expect( - unreachable_code, - reason = "kept ready for when the hang/crash above is root-caused" - )] - let candidate = { - let endpoint = lock(&self.endpoint); - let Some(Endpoint::Detailed(io)) = endpoint.as_ref() else { - return None; - }; - let file_id = lock(&self.directory_id).file_reference(); - DirectoryHandle::reopen_by_id(io.handle(), file_id).ok()? - }; - let current_path = candidate.canonical_path().ok()?; - let expected_path = lock(&self.canonical_path).clone()?; - (current_path == expected_path).then_some(candidate) - } - - /// Only a path-based reopen can legitimately land on a different - /// directory or volume than before (M11.3/M11.4/M12) -- an `OpenFileById` - /// success cannot. Re-keys `Resident.directories` immediately if the + /// A path-based reopen can legitimately land on a different directory or + /// volume than before (M11.3/M11.4/M12), and since D-80 it is the only kind + /// there is. Re-keys `Resident.directories` immediately if the /// directory changed (identity is never subject to confirmation), then /// either installs `handle` straight away (no volume change, or nobody /// asked) or asks every `Confirm`-opted route and defers installing until diff --git a/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs b/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs new file mode 100644 index 00000000..bec61f9b --- /dev/null +++ b/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Mike Grier +//! Integration test: characterise the Windows behaviour that makes reopening a +//! watched directory by file reference (`OpenFileById`) useless to this crate. +//! +//! This test asserts a property of the operating system, not of this crate. It +//! exists because [D-80] rests on that property: the reopen-by-id fast path was +//! removed on the strength of it, and a design note asserting an OS limitation +//! with nothing executing it can only rot. If a future Windows accepts the read +//! below, this test fails and D-80 should be revisited. +//! +//! The mechanism, measured with a control rather than reasoned about: a handle +//! from `OpenFileById` is indistinguishable from a `CreateFileW` one by +//! synchronous/asynchronous mode, by granted access, and by the name the kernel +//! resolves for it -- yet `ReadDirectoryChangesW` rejects it with +//! `ERROR_INVALID_PARAMETER`. The only variable that changes the outcome is +//! whether the object was resolved **by file ID** or **by name**. +//! +//! [D-80]: the "Reopening by file reference" section of `DESIGN-NOTES.md`. +#![cfg(windows)] + +use std::os::windows::ffi::OsStrExt; +use std::path::Path; +use std::ptr; + +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_INVALID_PARAMETER, GetLastError, HANDLE, INVALID_HANDLE_VALUE, +}; +use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, + FILE_ID_DESCRIPTOR, FILE_ID_DESCRIPTOR_0, FILE_LIST_DIRECTORY, FILE_NOTIFY_CHANGE_FILE_NAME, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FileIdType, GetFileInformationByHandle, + OPEN_EXISTING, OpenFileById, ReadDirectoryChangesW, +}; +use windows_sys::Win32::System::IO::{CancelIo, OVERLAPPED}; + +/// Closes its handle on drop, so a failing assertion cannot leak one into the +/// rest of the suite. +struct Owned(HANDLE); + +impl Drop for Owned { + fn drop(&mut self) { + // SAFETY: `self.0` is a live handle this type exclusively owns. + unsafe { CloseHandle(self.0) }; + } +} + +fn wide_z(p: &Path) -> Vec { + p.as_os_str().encode_wide().chain(Some(0)).collect() +} + +/// Open a directory exactly as `DirectoryHandle::open` does. +fn open_by_path(dir: &Path) -> Owned { + let wide = wide_z(dir); + // SAFETY: `wide` is NUL-terminated and outlives the call; the security + // attributes and template handle are null by design. + let raw = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_LIST_DIRECTORY, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, + ptr::null_mut(), + ) + }; + assert!( + raw != INVALID_HANDLE_VALUE, + "CreateFileW on a directory this test just created must succeed, got {}", + // SAFETY: called immediately after the failing call above. + unsafe { GetLastError() } + ); + Owned(raw) +} + +/// The NTFS file reference of an open handle. +fn file_reference(handle: HANDLE) -> u64 { + // SAFETY: `handle` is live, and `info` is a valid out-parameter the callee + // only writes. + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + assert!( + // SAFETY: as above. + unsafe { GetFileInformationByHandle(handle, &mut info) } != 0, + "GetFileInformationByHandle on a live directory handle must succeed" + ); + (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow) +} + +/// Reopen by file reference, with the same access and flags the path-based open +/// above uses. +fn reopen_by_id(volume_hint: HANDLE, file_id: u64) -> Owned { + let descriptor = FILE_ID_DESCRIPTOR { + dwSize: u32::try_from(size_of::()).expect("a small fixed struct"), + Type: FileIdType, + Anonymous: FILE_ID_DESCRIPTOR_0 { + FileId: file_id.cast_signed(), + }, + }; + // SAFETY: `volume_hint` is live for the call, and `descriptor` is a fully + // initialised `FILE_ID_DESCRIPTOR` the callee only reads. + let raw = unsafe { + OpenFileById( + volume_hint, + &descriptor, + FILE_LIST_DIRECTORY, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + ptr::null(), + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, + ) + }; + assert!( + raw != INVALID_HANDLE_VALUE, + "OpenFileById against a live handle's own file reference must succeed, got {}", + // SAFETY: called immediately after the failing call above. + unsafe { GetLastError() } + ); + Owned(raw) +} + +/// Issue the exact read `watcher.rs` issues, and report whether Windows accepted +/// it. A pending read is cancelled before returning, so nothing is left armed. +fn read_directory_changes_accepted(handle: HANDLE) -> Result<(), u32> { + // `u32`-typed so the buffer is DWORD-aligned, which the API requires. + let mut buffer = vec![0u32; 1024]; + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + // SAFETY: issues one overlapped read into this test's own buffer, which the + // kernel owns until the operation is cancelled below. `lpBytesReturned` is + // null, which the SDK requires for an asynchronous call, and no completion + // routine is used. + let ok = unsafe { + ReadDirectoryChangesW( + handle, + buffer.as_mut_ptr().cast(), + u32::try_from(buffer.len() * size_of::()).expect("a fixed small buffer"), + 0, + FILE_NOTIFY_CHANGE_FILE_NAME, + ptr::null_mut(), + &mut overlapped, + None, + ) + }; + if ok == 0 { + // SAFETY: called immediately after the failing call above. + return Err(unsafe { GetLastError() }); + } + // SAFETY: `handle` is live, and cancelling is what keeps the (kernel-owned) + // buffer from outliving this frame. + unsafe { CancelIo(handle) }; + Ok(()) +} + +#[test] +fn a_directory_reopened_by_file_id_cannot_be_watched() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = std::env::temp_dir().join(format!( + "windows-file-watcher-reopen-{}-{nonce}", + std::process::id() + )); + std::fs::create_dir(&dir).expect("create temp dir"); + + let original = open_by_path(&dir); + let reopened = reopen_by_id(original.0, file_reference(original.0)); + + // The control. Without it, a failure below could just as easily mean this + // test builds the read wrongly -- which is exactly the reading the original + // investigation had to rule out. + assert_eq!( + read_directory_changes_accepted(original.0), + Ok(()), + "the control must pass: a path-opened directory handle accepts this very \ + read, so a rejection below is about how the handle was obtained and not \ + about how the read is built" + ); + + // The property D-80 rests on. Both handles requested identical access and + // identical flags, and are indistinguishable by mode, granted access, and + // the name the kernel resolves for them; only the *resolution* differs. + assert_eq!( + read_directory_changes_accepted(reopened.0), + Err(ERROR_INVALID_PARAMETER), + "Windows must still reject a directory-change read on a by-id open. If \ + this now succeeds, the OS limitation D-80 removed the reopen-by-id fast \ + path over no longer holds, and that decision should be revisited" + ); + + drop(reopened); + drop(original); + std::fs::remove_dir_all(&dir).expect("cleanup"); +} From 580e001f62b0a0e13e4a126b76cd70980e085331 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 19:05:31 -0400 Subject: [PATCH 165/361] refactor(file-watcher): drop the write-only canonical_path, keep the query M15.8 was framed as remove-both or keep-both, because the stored field and the `DirectoryHandle` query looked like one unit. They are not: the only reader worth having reads the new handle, not stored state, so the two settle differently. The field goes. `WatcherInner::canonical_path` was written on every install and read by nothing -- its one reader was the by-reference fast reopen D-80 removed -- and no warning would ever have surfaced it, because `lock(&self.canonical_path)` counts as a read of the field. That is the same invisible-dead-code shape as M15.1's unreachable `Drop`. The query stays, and gains a caller that uses its result. The "reopened on a different volume than before" warning printed `self.path`, the client-supplied string that `WatcherInner`'s own doc comment calls "possibly not even fully resolved" -- the one moment that string is least worth printing, because changed resolution is exactly what happened. It now names the path the handle resolves to. It also had no tests at all: the four `reopen_by_id_*` tests were its only coverage and went with M15.2, so keeping it meant keeping an untested Win32 helper. Two added -- that it reports where the handle actually is (compared against the OS's own answer, not the opening string), and that a fresh query follows a rename, which a cached implementation would fail. Verified by injection, which also confirms the boundary this item had to establish: `buffer.truncate(written)` -> `truncate(written + 1)` is now caught, while `written < buffer.len()` -> `<=` still survives. That second one is M15.3's mutant exactly, needing a 512+ unit canonical path to reach, so M15.3 is left standing and unanswered rather than dissolved. The transferable rule, recorded in D-80: a diagnostic wants the live handle, not a cached copy, so needing the value is not a reason to keep the field. Completed item: M15.8: Decide whether `WatcherInner::canonical_path` and `DirectoryHandle::canonical_path` survive M15.2's removal. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 15 +---- .../COMPLETED-CHECKLIST.md | 35 +++++++++++- crates/windows-file-watcher/DESIGN-NOTES.md | 11 ++++ crates/windows-file-watcher/src/directory.rs | 11 ++-- .../src/directory/tests.rs | 57 +++++++++++++++++++ crates/windows-file-watcher/src/watcher.rs | 28 ++++----- 6 files changed, 118 insertions(+), 39 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 3a35cba9..3361d5ef 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -137,20 +137,7 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- - [x] **M15.2** -- Explained why a `reopen_by_id` handle rejects the watcher's own read: Windows refuses a directory-change read on any by-id open, holding access, mode, create options and resolved path identical. The fast path is removed, not disabled. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m152) -- [ ] **M15.8** -- Decide whether `WatcherInner::canonical_path` and `DirectoryHandle::canonical_path` - survive M15.2's removal. **Spawned by M15.2, and it changes M15.3's scope, so decide this first.** - **The state.** `WatcherInner::canonical_path` is now written on every install - ([src/watcher.rs](src/watcher.rs)) and **never read**. Its only reader was - `reopen_via_existing_handle`, which compared it against the reopened object's current path to catch - `OpenFileById` following a rename; D-80 removed that. The compiler does not flag it, because - `lock(&self.canonical_path)` counts as a read of the field -- so this is dead weight that no warning - will ever surface, which is exactly the shape M15.1 was. - **Why it is not just deleted.** Removing the field strands `DirectoryHandle::canonical_path`, which - strands the free `canonical_path` and its `GetFinalPathNameByHandleW` call -- and that call's 512-unit - retry is the entire subject of **M15.3**. Deleting it dissolves M15.3 rather than answering it. - **Two coherent outcomes.** Remove both (and close M15.3 as moot), or keep them for diagnostics and give - the field a reader that justifies it. What is not legitimate is leaving a write-only field with a doc - comment describing a reader that no longer exists. +- [x] **M15.8** -- Settled the write-only tail of M15.2's removal: the stored `canonical_path` field is gone, `DirectoryHandle::canonical_path` stayed and now has a caller that uses its result plus the tests it never had. M15.3 stands, confirmed by injection. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m158) - [ ] **M15.3** -- Decide whether this crate should open paths longer than `MAX_PATH`, and note the consequence for `canonical_path`'s retry either way. diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index e0a8480a..ffb27a14 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -665,4 +665,37 @@ checklist entries all described the fast path as live or as pending root-cause; `COMPLETED-PLANS.md` is left alone as dated, append-only history. **Spawned M15.8:** `WatcherInner::canonical_path` is now write-only, and no warning will ever surface it -because `lock(&self.canonical_path)` counts as a read of the field. \ No newline at end of file +because `lock(&self.canonical_path)` counts as a read of the field. +## Moved 2026-09-01 -- M15.8: the write-only tail of M15.2's removal + +### M15.8 -- Decide whether `WatcherInner::canonical_path` and `DirectoryHandle::canonical_path` survive M15.2's removal. *(completed 2026-09-01 19:20:00 -04:00)* + +**The finding that reframed the question.** M15.8 was written as remove-both or keep-both, because the +field and the method looked like one unit. They are not: the only reader worth having reads the **new +handle**, not stored state. So the field and the method were settled separately, and the answer is +different for each. + +**The field is gone.** `WatcherInner::canonical_path` was written on every install and read by nothing -- +its one reader was the by-reference fast reopen D-80 removed. No compiler warning would ever have surfaced +it, because `lock(&self.canonical_path)` counts as a read of the field: the same invisible-dead-code shape +as M15.1's unreachable `Drop`. + +**The method stayed, and gained a caller that uses its result.** The "reopened on a different volume than +before" warning printed `self.path` -- the client-supplied string that `WatcherInner`'s own doc comment +calls "possibly not even fully resolved". That is the one moment that string is least worth printing, +because changed resolution is exactly what happened. It now names the path the handle *resolves to*, with +`None` itself informative (the new handle would not report a path at all). + +**It had no tests at all.** The four `reopen_by_id_*` tests were `canonical_path`'s only coverage and went +with M15.2, so keeping it meant keeping an untested Win32 helper. Two tests added: that it reports where +the handle actually is (compared against the OS's own answer, not against the opening string), and that a +fresh query follows a rename -- being fresh rather than cached is the entire reason it exists, and a +cached implementation would pass a weaker test. + +**Verified by injection, and the boundary with M15.3 confirmed.** `buffer.truncate(written)` -> +`truncate(written + 1)` is now **caught**; `written < buffer.len()` -> `<=` still **survives**, which is +M15.3's mutant exactly -- it needs a 512+ unit canonical path to reach. So M15.3 is left standing and +unanswered rather than dissolved, which is what this item had to determine. + +**The transferable rule:** a diagnostic wants the live handle, not a cached copy. Needing the *value* is +not a reason to keep the *field*. \ No newline at end of file diff --git a/crates/windows-file-watcher/DESIGN-NOTES.md b/crates/windows-file-watcher/DESIGN-NOTES.md index 6a219215..f35eebba 100644 --- a/crates/windows-file-watcher/DESIGN-NOTES.md +++ b/crates/windows-file-watcher/DESIGN-NOTES.md @@ -411,6 +411,17 @@ natural place to look -- access mask, sync/async mode, resolved path -- shows th two handles as identical; only a control that varied *how the object was resolved* while holding all of that fixed could isolate it. +**The tail of the removal (M15.8).** Deleting the fast path left +`WatcherInner::canonical_path` written on every install and read by nothing, and +no compiler warning would ever have said so, because `lock(&self.canonical_path)` +counts as a read of the field. The stored copy is gone; +`DirectoryHandle::canonical_path` stayed, and now has a caller that uses its +result -- the "reopened on a different volume" warning names the path the handle +*resolves to* rather than echoing back the client's own string, which is the one +moment that string is least worth printing, because changed resolution is exactly +what happened. The rule that decided it: a diagnostic wants the live handle, not +a cached copy, so needing the *value* is not a reason to keep the *field*. + ### Failure detail on every fault report Before D-79, `Notification::RetryQuestion` carried only diff --git a/crates/windows-file-watcher/src/directory.rs b/crates/windows-file-watcher/src/directory.rs index 4e809869..6165d4bc 100644 --- a/crates/windows-file-watcher/src/directory.rs +++ b/crates/windows-file-watcher/src/directory.rs @@ -440,12 +440,11 @@ impl DirectoryHandle { /// queried fresh via `GetFinalPathNameByHandleW` rather than cached from /// whatever string originally opened it (M11.2/D-78). /// - /// Introduced to guard the by-reference fast reopen: `OpenFileById` is - /// path-independent, so it keeps finding the same object after a move or - /// rename, and a client subscribed to a path expects to watch *that path* - /// rather than wherever the object ends up. D-80 removed that reopen path, - /// so the only remaining caller records the result without reading it back - /// -- see M15.8, which owns whether this stays. + /// The distinction is the point: the string a client passed may be + /// relative, unnormalised, or routed through a junction or mapped drive + /// that no longer leads where it did. This reports where the handle + /// actually is, which is what a diagnostic about a *changed* resolution has + /// to say to be worth printing. /// /// # Errors /// diff --git a/crates/windows-file-watcher/src/directory/tests.rs b/crates/windows-file-watcher/src/directory/tests.rs index 2e74125e..933306d3 100644 --- a/crates/windows-file-watcher/src/directory/tests.rs +++ b/crates/windows-file-watcher/src/directory/tests.rs @@ -264,6 +264,63 @@ fn every_failure_class_agrees_with_its_retry_policy() { assert!(!OpenFailure::InvalidPath.is_retryable()); } +// --- `canonical_path`: where the handle actually is, not what opened it --- + +#[test] +fn canonical_path_reports_where_the_handle_actually_is() { + // The point of this call is that it does *not* echo back the string that + // opened the handle, so the test compares against the OS's own answer for + // the same directory rather than against that string. + let dir = TempDir::new("canonical-basic"); + let handle = DirectoryHandle::open(dir.path()).expect("open"); + + let reported = handle.canonical_path().expect("canonical path"); + let expected = std::fs::canonicalize(dir.path()).expect("std canonicalize"); + assert_eq!( + reported, expected, + "the reported path must name the same object the OS resolves this \ + directory to" + ); + + drop(handle); + dir.cleanup(); +} + +#[test] +fn canonical_path_follows_a_rename_rather_than_reporting_the_opening_string() { + // A handle keeps naming its object across a rename, so the path a client + // opened with can go stale while the handle stays perfectly valid. Being + // fresh rather than cached is the whole reason this exists -- a diagnostic + // that printed the opening string here would name a directory that is no + // longer there. + let dir = TempDir::new("canonical-rename"); + let handle = DirectoryHandle::open(dir.path()).expect("open"); + let before = handle.canonical_path().expect("canonical path before"); + + let renamed = dir.path().with_file_name(format!( + "windows-file-watcher-canonical-renamed-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&renamed); + std::fs::rename(dir.path(), &renamed).expect("rename the open directory"); + + let after = handle.canonical_path().expect("canonical path after"); + assert_ne!( + before, after, + "a fresh query must notice the rename; an equal answer would mean the \ + path had been cached at open" + ); + assert_eq!( + after, + std::fs::canonicalize(&renamed).expect("std canonicalize"), + "and it must name where the object went, not merely differ" + ); + + drop(handle); + let _ = std::fs::remove_dir_all(&renamed); + dir.cleanup(); +} + #[test] fn volume_identity_equality_is_on_the_serial_alone() { // PR #20 review response: the filesystem name and volume label are both diff --git a/crates/windows-file-watcher/src/watcher.rs b/crates/windows-file-watcher/src/watcher.rs index fdfad0aa..fa605a5f 100644 --- a/crates/windows-file-watcher/src/watcher.rs +++ b/crates/windows-file-watcher/src/watcher.rs @@ -283,20 +283,6 @@ struct WatcherInner { /// `GetVolumeInformationByHandleW` itself failed when this watcher was /// last installed. volume_identity: Mutex>, - /// This watcher's own canonical path (M11.2), recorded from the handle - /// installed each time, via `GetFinalPathNameByHandleW` rather than - /// `self.path` (a client-supplied string, possibly not even fully - /// resolved). `None` only if the query itself failed when this watcher was - /// last installed. - /// - /// **Currently written and never read.** Its one reader was the - /// by-reference fast reopen, which used it to notice that `OpenFileById` - /// had followed the object somewhere else and refuse to trust it; D-80 - /// removed that path, and nothing has consulted this since. Kept rather - /// than deleted only because removing it cascades into - /// `DirectoryHandle::canonical_path` and the retry M15.3 is about -- see - /// M15.8, which owns the decision. - canonical_path: Mutex>, /// The resident-state map this watcher's directory entry lives in, bound /// once by [`DirectoryWatcher::bind_resident`] immediately after /// construction (M11.4) -- unset for a watcher built directly by a unit @@ -785,9 +771,17 @@ impl WatcherInner { .collect(); if awaiting.is_empty() { drop(routes); + // The path the handle *resolves to*, not `self.path`: a volume + // change is precisely the case where the client's own string names + // something that no longer leads where it used to, so echoing that + // string back says nothing about what actually happened. `None` + // here is itself informative -- the new handle would not report a + // path at all. log::warn!( - "windows-file-watcher: {:?} reopened on a different volume than before", - self.path + "windows-file-watcher: {:?} reopened on a different volume than before, and now \ + resolves to {:?}", + self.path, + handle.canonical_path().ok() ); self.finish_reopen(handle); return; @@ -1093,7 +1087,6 @@ impl WatcherInner { if let Ok(identity) = handle.volume_identity() { *lock(&self.volume_identity) = Some(identity); } - *lock(&self.canonical_path) = handle.canonical_path().ok(); self.case_sensitive .store(handle.is_case_sensitive(), Ordering::Relaxed); self.establish_detailed(handle)?; @@ -1248,7 +1241,6 @@ impl DirectoryWatcher { force_coarse: AtomicBool::new(force_coarse), directory_id: Mutex::new(initial_id), volume_identity: Mutex::new(None), - canonical_path: Mutex::new(None), resident: OnceLock::new(), volume_change: Mutex::new(None), // Overwritten by `install` below, from the real handle, before From 742e4db48cd74ea3281f2cd06f400b1eb370c6d8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 19:18:51 -0400 Subject: [PATCH 166/361] fix(waitable-queues): repair the PR #56 review findings Completed items: SH-7.1, SH-7.2, SH-7.3, SH-7.4, SH-7.5, SH-7.6, SH-7.7 Each finding was verified against the source before being accepted, and every behavioural repair is sabotage-verified: reverting it turns the suite red. Completed item: SH-7.1: reserving_mpsc reported Full from a claim word that was never current. push and reserve load the claim relaxed, then test room by computing position.wrapping_sub(head); once other producers claim and publish past a stale position and the consumer drains them, head overtakes it, the subtraction wraps, and an EMPTY queue reports Full (recording a refusal) while reserve returns None. Both now re-read the claim and retry when it moved. A new CLAIM race hook drives that window deterministically on one thread. Completed item: SH-7.2: the NUMA cross-check compared a count against a highest identifier. GetNumaHighestNodeNumber reports the highest node number and Windows does not promise dense numbering, so nodes 0 and 2 were reported as a parsing regression on correct hardware. Observation now carries highest_numa_node and compares highest against highest. Completed item: SH-7.3: outermost_partitioning_cache called a level a partition without checking that it is one. Deduplicating by equal processor set is right for the measured L1i/L1d case but leaves distinct-but-overlapping sets, which a consumer double-counts. A level now qualifies only when its distinct sets are pairwise disjoint. Full coverage is deliberately not required, and a test states why. Completed item: SH-7.4: windows-waitable-queues could not build documentation on docs.rs -- it is Windows-only and imports std::os::windows unconditionally, but omitted the target metadata every sibling published Windows-only crate carries. Completed item: SH-7.5: inject-mutant.ps1 replaced every occurrence on the line rather than the first. It passed 1 to the STATIC [regex]::Replace overload, whose fourth parameter is RegexOptions, so the value meant IgnoreCase; no static overload takes a count. Replacement is now done by offset, an ambiguous line is refused unless -Column names the occurrence, the baseline is verified green before any verdict is trusted, --all-features is the default, and the mutating write moved inside the guarded region. Completed item: SH-7.6: run-numa-spikes.ps1 checked the exit code of cargo build but not of cargo run, then decided vacuity by searching output for VACUOUS. A crashed spike printed no such line, so the summary announced "NOT vacuous -- this runner has more than one NUMA node" on the strength of a stack trace and the script still exited 0. A failed run is now an instrument failure. Completed item: SH-7.7: check-publishable.ps1 and inject-mutant.ps1 now route output through one sink; run-sabotage.ps1 performs its patching write inside the try whose finally restores the file; run-mutants.ps1 stamps its output directory per run, so a second sweep of the same scope no longer overwrites the analysis its documentation promises to preserve; and the placement probe's scratch directories carry the process id, so two concurrent test processes cannot delete each other's fixtures. One further finding was checked and does not hold: GetSystemDirectoryW returning exactly the buffer length is unreachable, since success excludes the terminator and failure includes it and so exceeds the buffer. The guard is widened to >= anyway, with the boundary written down so the next reader need not redo it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 67 +++++ .../src/bin/placement_probe/tests.rs | 11 +- .../src/request_cost.rs | 8 +- crates/windows-platform-probes/src/tests.rs | 63 +++++ .../windows-platform-probes/src/topology.rs | 28 ++- crates/windows-topology-sys/src/topology.rs | 40 ++- .../src/topology/tests.rs | 61 +++++ crates/windows-waitable-queues/Cargo.toml | 7 + .../windows-waitable-queues/src/race_hooks.rs | 24 +- .../src/reserving_mpsc.rs | 34 +++ .../src/reserving_mpsc/tests.rs | 91 +++++++ tools/check-publishable.ps1 | 28 ++- tools/inject-mutant.ps1 | 232 +++++++++++++++--- tools/run-mutants.ps1 | 15 +- tools/run-numa-spikes.ps1 | 75 ++++-- tools/run-sabotage.ps1 | 6 +- 16 files changed, 705 insertions(+), 85 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 068246b3..965ac7fe 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -327,3 +327,70 @@ D-31 says cannot be supported. disagreed once about this crate's behaviour, and no test we run here covers a machine we do not own. Keep a short in-suite smoke run over the same engine so the code cannot rot, and keep it out of the fast unit suite, which must stay under a second. + +## M7: PR #56 automated-review round + +The findings an automated review raised against the pull request that lands this work, verified against +the source before being accepted. Each item names what was checked, so a later reader can tell a real +repair from a reviewer's guess that was taken on trust. + +- [x] **SH-7.1** -- **`reserving_mpsc` reports `Full` from a claim word that was never current.** + `push` and `reserve` load the claim word relaxed, then test room with + `has_room_beyond_reservations(position, reserved)`, which computes + `position.wrapping_sub(head)`. If other producers claim and publish past `position` and the consumer + drains them while this thread is between the load and the room check, `head` passes the stale + `position` and the subtraction wraps to near `u32::MAX` -- so the queue reports `Full` (and records a + refusal) at the moment it is empty, and `reserve` returns `None` for the same reason. The compare-and- + swap that would have caught the staleness is never reached, because both paths return before it. + Re-read the claim and retry when it moved; report no room only from a word still current. + +- [x] **SH-7.2** -- **The NUMA cross-check compares a count against a highest identifier.** + `windows-platform-probes`'s `Observation::cross_check` compares `numa_domains` (a count of memory + domains) with `GetNumaHighestNodeNumber() + 1`. Windows documents that value as the highest node + *number*, and does not guarantee node numbers are dense -- nodes 0 and 2 give a count of 2 and a + highest of 2, and the probe then reports a parsing regression on correct hardware. Memory domains + already carry the node number in `Domain::id`, so compare highest against highest. + +- [x] **SH-7.3** -- **A cache level is called a partition without checking that it is one.** + `cache_partitions_at_level` deduplicates by equal processor set, which is exactly right for the + measured case it was written for (L1i and L1d over identical sets). It does not establish a + *partition*: `Topology` is deliberately constructible by hand and by deserialization (D-12), so + distinct-but-overlapping sets reach `outermost_partitioning_cache`, which returns them as domains a + consumer then double-counts. Require the distinct sets to be pairwise disjoint before a level + qualifies as partitioning. + +- [x] **SH-7.4** -- **`windows-waitable-queues` cannot build its documentation on docs.rs.** The crate + is Windows-only and imports `std::os::windows::io` unconditionally, but its manifest omits the + `[package.metadata.docs.rs]` target block that every other published Windows-only crate here carries, + so docs.rs would build it for its default Linux target and fail. Add the same block. + +- [x] **SH-7.5** -- **The mutant injector replaces every occurrence on the line, not the first.** + `tools/inject-mutant.ps1` calls the *static* `[regex]::Replace(input, pattern, replacement, 1)`, whose + fourth parameter is `RegexOptions` -- `1` is `IgnoreCase`, not a replacement count, and no static + overload takes a count at all. The tool therefore does precisely what its own header comment says it + exists to avoid. Fix the replacement, refuse a line whose pattern occurs more than once unless a + column disambiguates it, verify the baseline is green before trusting a "caught", run with all + features so a feature-gated mutation is not reported as surviving, perform the mutating write inside + the guarded region so a failed write still restores, and route its output through one sink. + +- [x] **SH-7.6** -- **A spike that fails to run is reported as a finding about the machine.** + `tools/run-numa-spikes.ps1` checks the exit code of `cargo build` but not of `cargo run`, then decides + vacuity by searching the output for `VACUOUS`. A crashed spike prints no such line, so the summary + says "**NOT vacuous -- this runner has more than one NUMA node**" and the script exits 0. That is the + instrument breaking while claiming a result, which the script's own documentation says is the one + thing worth failing over. + +- [x] **SH-7.7** -- **Two tools write output from several sites, and two hazards remain in the + sabotage/mutation harness.** `tools/check-publishable.ps1` and `tools/inject-mutant.ps1` each call + `Write-Host` from several places, against the repository's one-output-sink rule. + `tools/run-sabotage.ps1` performs its patching write before entering the `try` whose `finally` + restores the file, so a write that throws part-way leaves the clean source damaged. + `tools/run-mutants.ps1` derives a deterministic output directory per package or file, so a second run + of the same scope overwrites the analysis the parameter documentation promises to preserve. + The placement probe's tests name scratch directories without the process id, so two concurrent test + processes -- which the documented `-j 2` mutation workflow creates -- delete each other's fixtures. + +- [ ] **SH-7.8** -- **Reply to every thread and resolve the ones that are addressed**, including the one + finding that was checked and found not to hold: `GetSystemDirectoryW` returning exactly the buffer + length is unreachable (success excludes the terminator, failure includes it and so exceeds the + buffer), though the guard is widened anyway so the next reader need not redo the analysis. diff --git a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs index d7b4d9a3..252ceb0e 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs @@ -13,8 +13,17 @@ use super::write_backup_to_new_file; /// A directory of this test's own, so a failure cannot be caused by, or blamed /// on, another test's files. +/// +/// Keyed by process id as well as by name. The names are unique within one test +/// binary, but the documented mutation workflow runs two `cargo test` processes +/// at once (`-j 2`), and both would otherwise resolve to the same path under the +/// system temp directory -- so one would delete the other's fixture mid-test and +/// the failure would look like a defect in the code under test. fn scratch(name: &str) -> std::path::PathBuf { - let dir = std::env::temp_dir().join(format!("placement-probe-backup-{name}")); + let dir = std::env::temp_dir().join(format!( + "placement-probe-backup-{}-{name}", + std::process::id() + )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("a scratch directory"); dir diff --git a/crates/windows-platform-probes/src/request_cost.rs b/crates/windows-platform-probes/src/request_cost.rs index 01fb9b9d..ffca9876 100644 --- a/crates/windows-platform-probes/src/request_cost.rs +++ b/crates/windows-platform-probes/src/request_cost.rs @@ -198,7 +198,13 @@ fn system_directory() -> std::path::PathBuf { // SAFETY: writes at most `buffer.len()` units into a buffer of that size. let written = unsafe { GetSystemDirectoryW(buffer.as_mut_ptr(), buffer.len() as u32) }; let written = written as usize; - if written == 0 || written > buffer.len() { + // `>=`, not `>`. On success the count excludes the terminator, so it can + // reach at most `buffer.len() - 1`; on failure it is the required size + // *including* the terminator, so it is at least `buffer.len() + 1`. Exactly + // `buffer.len()` is therefore unreachable from either branch -- and treating + // it as a failure costs nothing while removing the need for the next reader + // to redo that analysis before trusting a possibly-unterminated buffer. + if written == 0 || written >= buffer.len() { return std::path::PathBuf::from(r"C:\Windows\System32"); } std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..written])) diff --git a/crates/windows-platform-probes/src/tests.rs b/crates/windows-platform-probes/src/tests.rs index 2b0c5d11..515df7a1 100644 --- a/crates/windows-platform-probes/src/tests.rs +++ b/crates/windows-platform-probes/src/tests.rs @@ -740,6 +740,69 @@ fn the_shipping_parse_agrees_with_the_raw_win32_counters() { ); } +/// An observation with nothing to complain about, for a test to perturb one +/// field of. Every host reachable here has a single NUMA node, so the sparse +/// case below cannot be measured and has to be constructed. +fn agreeing_observation() -> crate::topology::Observation { + crate::topology::Observation { + online_processors: 4, + groups: 1, + numa_domains: 1, + memoryless_numa_domains: 0, + highest_numa_node: Some(0), + packages: 1, + cores: Vec::new(), + caches: Vec::new(), + raw_active_processors: 4, + raw_group_count: 1, + raw_highest_numa_node: Some(0), + } +} + +#[test] +fn sparse_numa_node_numbers_are_not_reported_as_a_parsing_regression() { + // `GetNumaHighestNodeNumber` reports the highest node *number*, which + // Windows does not promise equals the node count. Nodes 0 and 2 are a valid + // sparse topology: two domains, highest number two. Comparing the count + // against `highest + 1` called that a disagreement, so the probe's asserted + // test would fail on hardware that is reporting itself correctly. + let mut observation = agreeing_observation(); + observation.numa_domains = 2; + observation.highest_numa_node = Some(2); + observation.raw_highest_numa_node = Some(2); + + assert!( + observation.cross_check().is_empty(), + "a sparse node numbering is a valid machine, not a parse error" + ); +} + +#[test] +fn a_numa_node_the_topology_crate_never_saw_is_still_reported() { + // The other direction, so the sparse tolerance cannot pass by never + // complaining: Windows names a node the crate's parse did not produce, and + // that is the disagreement this cross-check exists to surface. + let mut observation = agreeing_observation(); + observation.raw_highest_numa_node = Some(3); + + let complaints = observation.cross_check(); + assert_eq!(complaints.len(), 1, "{complaints:?}"); + assert!(complaints[0].contains("NUMA nodes"), "{complaints:?}"); +} + +#[test] +fn a_topology_reporting_no_numa_node_at_all_disagrees_with_a_raw_one() { + // The `None` arm, which the count form could not express: Windows names a + // node and the crate's parse produced no memory domain whatsoever. + let mut observation = agreeing_observation(); + observation.numa_domains = 0; + observation.highest_numa_node = None; + + let complaints = observation.cross_check(); + assert_eq!(complaints.len(), 1, "{complaints:?}"); + assert!(complaints[0].contains("none"), "{complaints:?}"); +} + #[test] fn every_core_reports_processors_and_smt_agrees_with_the_count() { let observation = crate::topology::measure().expect("topology discovery"); diff --git a/crates/windows-platform-probes/src/topology.rs b/crates/windows-platform-probes/src/topology.rs index e841b91b..574632da 100644 --- a/crates/windows-platform-probes/src/topology.rs +++ b/crates/windows-platform-probes/src/topology.rs @@ -86,6 +86,15 @@ pub struct Observation { /// with CXL expanders or HBM tiers, and the reason a domain count cannot be /// used as a thread count. pub memoryless_numa_domains: usize, + /// The largest NUMA node number the topology crate reported, or `None` when + /// it reported no memory domain at all. + /// + /// Kept beside the count because the two answer different questions and + /// Windows only promises the second one: node numbers are not guaranteed + /// dense, so a machine with nodes 0 and 2 has a count of two and a highest + /// of two. Comparing the count against `GetNumaHighestNodeNumber` would + /// call that correct machine a parsing regression. + pub highest_numa_node: Option, /// Physical packages (sockets). pub packages: usize, /// Every physical core. @@ -172,12 +181,19 @@ impl Observation { )); } if let Some(highest) = self.raw_highest_numa_node - && self.numa_domains != highest as usize + 1 + && self.highest_numa_node != Some(highest) { + // Highest against highest, deliberately, and not a count against + // `highest + 1`. `GetNumaHighestNodeNumber` reports the largest node + // *number*, which Windows does not promise equals the node count -- + // nodes 0 and 2 are a valid sparse topology, and the count form + // would report a regression on hardware that is reporting itself + // correctly. complaints.push(format!( - "NUMA domains: topology crate says {}, GetNumaHighestNodeNumber implies {}", - self.numa_domains, - highest + 1 + "NUMA nodes: topology crate's highest node is {}, GetNumaHighestNodeNumber says {}", + self.highest_numa_node + .map_or_else(|| "none".to_string(), |n| n.to_string()), + highest )); } complaints @@ -197,6 +213,7 @@ pub fn measure() -> io::Result { let mut groups = 0usize; let mut numa_domains = 0usize; let mut memoryless_numa_domains = 0usize; + let mut highest_numa_node: Option = None; let mut packages = 0usize; let mut cores = Vec::new(); let mut by_level: Vec<(u8, Vec)> = Vec::new(); @@ -207,6 +224,8 @@ pub fn measure() -> io::Result { DomainKind::Package => packages += 1, DomainKind::Memory { .. } => { numa_domains += 1; + highest_numa_node = + Some(highest_numa_node.map_or(domain.id, |seen: u32| seen.max(domain.id))); if domain.processors.is_empty() { memoryless_numa_domains += 1; } @@ -268,6 +287,7 @@ pub fn measure() -> io::Result { groups, numa_domains, memoryless_numa_domains, + highest_numa_node, packages, cores, caches, diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index f50bc98d..b3e0bacc 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -252,6 +252,11 @@ impl Topology { /// Deduplication is by processor set, which is the thing a caller /// partitioning work actually cares about; the first domain covering each /// distinct set is kept, so the returned ids are stable for a topology. + /// + /// **Distinct is not disjoint.** Two sets that overlap without being equal + /// both survive this, so the result is a set of domains rather than a + /// proven partition. [`Self::outermost_partitioning_cache`] is where that + /// stronger property is required and checked. pub fn cache_partitions_at_level(&self, level: u8) -> Vec<&Domain> { let mut partitions: Vec<&Domain> = Vec::new(); for domain in self.caches_at_level(level) { @@ -278,13 +283,46 @@ impl Topology { /// during the 2026-08-30 session reports **no L3 at all**, with two L2 /// domains of six processors forming the real cluster boundary. /// + /// # A level must be a partition, not merely a set of domains + /// + /// [`Self::cache_partitions_at_level`] deduplicates by equal processor set, + /// which is what the measured case needs (L1i and L1d cover identical + /// sets). That alone does **not** make the result a partition: two distinct + /// sets can still overlap. Real hardware does not do this, but a + /// `Topology` is deliberately constructible by hand and by deserialization + /// (see [`Provenance`](crate::Provenance)), so this method cannot assume + /// hardware produced it -- and a caller splitting work across overlapping + /// "partitions" double-counts the processors in the intersection and + /// overwrites their domain assignment, silently. + /// + /// So a level qualifies only when its distinct sets are **pairwise + /// disjoint**. Full coverage of the online processors is deliberately *not* + /// required: a processor with no cache reported at a level is a gap in what + /// the firmware said, not evidence that the domains which *were* reported + /// overlap, and rejecting the level would discard a true boundary over it. + /// /// Defined here, in the crate that owns the topology, so that every /// consumer asks the same question rather than restating the rule and /// drifting from it. pub fn outermost_partitioning_cache(&self) -> Option<(u8, Vec<&Domain>)> { self.cache_levels().into_iter().rev().find_map(|level| { let partitions = self.cache_partitions_at_level(level); - (partitions.len() > 1).then_some((level, partitions)) + (partitions.len() > 1 && Self::are_pairwise_disjoint(&partitions)) + .then_some((level, partitions)) + }) + } + + /// Whether no two of these domains claim the same processor. + /// + /// Quadratic on purpose: the input is one cache level's distinct domains, + /// which is a handful even on a large machine, and pairwise intersection + /// asks the question directly rather than through a set type this crate + /// would otherwise not need. + fn are_pairwise_disjoint(domains: &[&Domain]) -> bool { + domains.iter().enumerate().all(|(i, left)| { + domains[i + 1..] + .iter() + .all(|right| left.processors.is_disjoint(&right.processors)) }) } diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index cc97f52c..1ad1dcb5 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -686,3 +686,64 @@ fn a_single_core_split_l1_partitions_nothing() { fn a_machine_with_no_cache_at_all_has_no_partitioning_cache() { assert!(synthetic().outermost_partitioning_cache().is_none()); } + +#[test] +fn a_level_whose_domains_overlap_is_not_a_partition() { + // `Topology` is deliberately constructible by hand and by deserialization, + // so `outermost_partitioning_cache` cannot assume hardware produced its + // input. Two L2 domains that share processor 1 are distinct sets, so + // deduplication keeps both -- and a caller told they are partitions places + // work on processor 1 twice and overwrites its domain assignment. + let mut topo = split_l1_machine(1, 3); + topo.domains.pop(); // the shared last level, which divides nothing + for (id, mask) in [(200u32, 0b011usize), (201, 0b110)] { + topo.domains.push(Domain { + kind: DomainKind::Cache { + level: 2, + associativity: 8, + line_size: 64, + size_bytes: 1024 * 1024, + cache_type: CacheKind::Unified, + }, + id, + processors: ProcessorSet::from_group_mask(0, mask), + }); + } + + // Both survive deduplication, which only removes *equal* sets... + assert_eq!(topo.cache_partitions_at_level(2).len(), 2); + // ...but overlapping domains are not a partition, so no level qualifies. + assert!( + topo.outermost_partitioning_cache().is_none(), + "overlapping cache domains must not be reported as partitions" + ); +} + +#[test] +fn a_level_whose_domains_are_disjoint_but_incomplete_still_partitions() { + // The deliberate limit of the disjointness rule. A processor with no cache + // reported at this level is a gap in what the firmware said; the domains + // that *were* reported still divide the processors they cover, so + // discarding the level over the gap would throw away a true boundary. + let mut topo = split_l1_machine(1, 3); + topo.domains.pop(); + for (id, mask) in [(300u32, 0b0001usize), (301, 0b0010)] { + topo.domains.push(Domain { + kind: DomainKind::Cache { + level: 2, + associativity: 8, + line_size: 64, + size_bytes: 1024 * 1024, + cache_type: CacheKind::Unified, + }, + id, + processors: ProcessorSet::from_group_mask(0, mask), + }); + } + + let (level, partitions) = topo + .outermost_partitioning_cache() + .expect("two disjoint L2 domains divide what they cover"); + assert_eq!(level, 2); + assert_eq!(partitions.len(), 2); +} diff --git a/crates/windows-waitable-queues/Cargo.toml b/crates/windows-waitable-queues/Cargo.toml index 33b9d961..aeb5c6ca 100644 --- a/crates/windows-waitable-queues/Cargo.toml +++ b/crates/windows-waitable-queues/Cargo.toml @@ -22,6 +22,13 @@ categories = ["concurrency", "os::windows-apis", "data-structures"] # for what it commits us to. publish = true +# The crate is Windows-only -- every public item is behind `cfg(windows)` and +# the implementation imports `std::os::windows::io` unconditionally -- so +# docs.rs must build it on a Windows target or the build fails outright. +[package.metadata.docs.rs] +default-target = "x86_64-pc-windows-msvc" +targets = ["x86_64-pc-windows-msvc"] + [lib] path = "src/lib.rs" diff --git a/crates/windows-waitable-queues/src/race_hooks.rs b/crates/windows-waitable-queues/src/race_hooks.rs index f783da83..4ad83635 100644 --- a/crates/windows-waitable-queues/src/race_hooks.rs +++ b/crates/windows-waitable-queues/src/race_hooks.rs @@ -5,12 +5,13 @@ //! //! # Why these exist at all //! -//! Two places in this crate consist of two statements whose *order* is the -//! whole correctness argument, and whose wrong order is a permanent hang rather -//! than an occasional stall: `Consumer::arm` and [`Doorbell::clear`]. Proving -//! such an order is load-bearing means placing a racing operation strictly -//! between the two statements, and that is not an interleaving a scheduler can -//! be asked for -- the window is tens of nanoseconds wide. +//! Three places in this crate consist of statements whose *order* -- or whose +//! freshness -- is the whole correctness argument, and whose wrong form is a +//! permanent hang or a wrong answer rather than an occasional stall: +//! `Consumer::arm`, [`Doorbell::clear`], and the reserving queue's claim loop. +//! Proving such an order is load-bearing means placing a racing operation +//! strictly between the statements, and that is not an interleaving a scheduler +//! can be asked for -- the window is tens of nanoseconds wide. //! //! # Why a hook rather than a hand-written copy of the code //! @@ -43,6 +44,7 @@ type Slot = RefCell>>; thread_local! { static ARM_HOOK: Slot = const { RefCell::new(None) }; static CLEAR_HOOK: Slot = const { RefCell::new(None) }; + static CLAIM_HOOK: Slot = const { RefCell::new(None) }; } /// One named race window. @@ -56,6 +58,16 @@ pub(crate) const ARM: Hook = Hook(&ARM_HOOK); /// resetting the event and clearing the flag that mirrors it. pub(crate) const CLEAR: Hook = Hook(&CLEAR_HOOK); +/// Fires inside the reserving queue's claim loop, between reading the claim +/// word and testing whether that claim leaves room. +/// +/// The window this opens is not an ordering one: the two readings the room test +/// combines -- a position from the claim word, and `head` -- are taken at +/// different instants, so a claim that goes stale here makes the test answer +/// about a state that never existed. Firing a racing producer and consumer in +/// this window is what makes that reachable on one thread. +pub(crate) const CLAIM: Hook = Hook(&CLAIM_HOOK); + impl Hook { /// Runs the installed hook, if any. Called from the code under test. pub(crate) fn run(&self) { diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 8fc00bc8..8895b517 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -395,6 +395,15 @@ impl Shared { /// `occupied + reserved >= capacity`, because both terms can reach 2^31 and /// their sum would overflow the width the positions are counted in. The /// invariant guarantees `reserved <= capacity`, so this cannot underflow. + /// + /// **The answer is only meaningful for a claim word that is still current.** + /// `position` comes from a claim word and `head` is read here, so the two + /// need not describe the same instant: if other producers claim and publish + /// past a stale `position` and the consumer drains them, `head` overtakes it + /// and the subtraction wraps to near [`u32::MAX`] -- "full" computed from a + /// pair of readings that never coexisted. Callers therefore treat a `false` + /// as provisional and re-read the claim before reporting it (see + /// [`Producer::push`]). fn has_room_beyond_reservations(&self, position: u32, reserved: u32) -> bool { let capacity = self.capacity_u32(); debug_assert!( @@ -564,8 +573,22 @@ impl Producer { let position = loop { let position = position_of(word); let reserved = reserved_of(word); + #[cfg(test)] + crate::race_hooks::CLAIM.run(); if !self.shared.has_room_beyond_reservations(position, reserved) { + // Provisional, not authoritative. `position` came from `word` + // and `head` was read inside the check, so a `word` that has + // since moved makes the two readings describe different + // instants -- and once `head` passes a stale `position` the + // subtraction wraps, so an *empty* queue reports full. Re-read + // the claim: if it moved, this answer was computed from a + // snapshot that never existed, so retry rather than refuse. + let current = self.shared.claim.0.load(Ordering::Relaxed); + if current != word { + word = current; + continue; + } // Report disconnection in preference to fullness: a full queue // whose consumer is gone will never drain, and telling the // caller to retry would be telling it to spin forever. @@ -623,8 +646,19 @@ impl Producer { loop { let position = position_of(word); let reserved = reserved_of(word); + #[cfg(test)] + crate::race_hooks::CLAIM.run(); if !self.shared.has_room_beyond_reservations(position, reserved) { + // Provisional for the reason `push`'s matching check is: a + // stale `word` and a freshly-read `head` need not describe the + // same instant, and once `head` passes a stale `position` the + // subtraction wraps and an empty queue refuses a reservation. + let current = self.shared.claim.0.load(Ordering::Relaxed); + if current != word { + word = current; + continue; + } return None; } diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index e81aa9b4..f26eef4c 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -24,6 +24,7 @@ use crate::{Disposal, Options}; // handle is named for the role, and a caller who wants only the methods says so. use crate::Consumer as _; use crate::{Bounded, PushError, RecvError, Reserving}; +use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use std::thread; @@ -749,6 +750,96 @@ fn the_real_arm_still_blesses_a_wait_when_its_window_stays_empty() { assert!(safe_to_wait, "nothing arrived, so waiting is right"); } +// --------------------------------------------------------------------------- +// The claim word going stale under the room test, which is the one window where +// "full" can be computed from two readings that never coexisted. +// --------------------------------------------------------------------------- + +/// Drains the queue after filling it, so `head` overtakes a claim position read +/// before any of it happened. +/// +/// Returned as a closure that fires **once**: the hook sits inside the claim +/// loop, so a closure that acted on every call would move the queue forward +/// again on each retry and the loop could never catch up with it. +fn advance_past(tx: Producer, rx: Rc>, items: u32) -> impl FnMut() { + let mut fired = false; + move || { + if fired { + return; + } + fired = true; + for i in 0..items { + tx.push(i).expect("the queue starts empty, so this fits"); + } + for _ in 0..items { + rx.pop().expect("what was just pushed is takeable"); + } + } +} + +#[test] +fn a_push_whose_claim_goes_stale_retries_instead_of_reporting_full() { + // The defect this guards. `push` reads the claim word, and the room test + // then reads `head`. If the queue fills and drains in between, `head` + // passes the position that word carried and `position.wrapping_sub(head)` + // wraps to near `u32::MAX` -- so an EMPTY queue reports `Full`, and records + // a refusal for it. The compare-and-swap that would have caught the + // staleness is never reached, because the room test returns first. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let rx = Rc::new(rx); + let racing = advance_past(tx.clone(), Rc::clone(&rx), 4); + + let outcome = race_hooks::CLAIM.with(racing, || tx.push(99)); + + assert!( + outcome.is_ok(), + "the queue is empty when the claim is made, so the push must land: {outcome:?}" + ); + assert_eq!( + rx.pop(), + Some(99), + "the item the retry claimed must actually be in the queue" + ); + assert_eq!( + tx.refused(), + 0, + "a retried claim is not backpressure and must not be counted as one" + ); +} + +#[test] +fn a_reservation_whose_claim_goes_stale_retries_instead_of_failing() { + // `reserve` shares the room test, so it shares the window: the same stale + // pair made an empty queue refuse a reservation. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let rx = Rc::new(rx); + let racing = advance_past(tx.clone(), Rc::clone(&rx), 4); + + let reservation = race_hooks::CLAIM.with(racing, || tx.reserve()); + + let reservation = reservation.expect("the queue is empty when the claim is made"); + reservation.send(7).expect("the consumer is still here"); + assert_eq!(rx.pop(), Some(7)); +} + +#[test] +fn a_genuinely_full_queue_still_reports_full_through_the_window() { + // The other direction, so the retry cannot pass by never refusing. Nothing + // races here, so the claim word the room test rejected is still current and + // the refusal is authoritative. + let (tx, rx) = bounded::(2).expect("2 is a valid capacity"); + tx.push(1).expect("there is room"); + tx.push(2).expect("there is room"); + + let outcome = race_hooks::CLAIM.with(|| {}, || tx.push(3)); + + assert!( + matches!(outcome, Err(PushError::Full(3))), + "a full queue must still refuse, and hand the item back: {outcome:?}" + ); + assert_eq!(tx.refused(), 1, "a real refusal is still counted"); + assert_eq!(rx.pop(), Some(1)); +} // --------------------------------------------------------------------------- // Through the traits, which is where this shape and `slotwise_mpsc` visibly differ. // --------------------------------------------------------------------------- diff --git a/tools/check-publishable.ps1 b/tools/check-publishable.ps1 index 72e26c75..c46a16f3 100644 --- a/tools/check-publishable.ps1 +++ b/tools/check-publishable.ps1 @@ -26,6 +26,22 @@ param( $ErrorActionPreference = 'Stop' +# The single output sink. Every message this tool emits goes through here, so +# the destination and the formatting stay separable from the call sites that +# produce the content -- the repository's one-output-sink rule. +function Write-Report { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message, + [ValidateSet('info', 'good', 'bad')][string] $Level = 'info' + ) + $colour = switch ($Level) { + 'good' { 'Green' } + 'bad' { 'Red' } + default { 'Gray' } + } + Write-Host $Message -ForegroundColor $colour +} + $configPath = Join-Path $RepositoryRoot 'release-please-config.json' $workflowPath = Join-Path $RepositoryRoot '.github/workflows/publish-crate.yml' @@ -72,13 +88,13 @@ foreach ($crate in $managed) { } if ($missing.Count -gt 0) { - Write-Host "Release-managed crates that cannot be published:" -ForegroundColor Red - $missing | ForEach-Object { Write-Host " $_" -ForegroundColor Red } - Write-Host '' - Write-Host "Add them to .github/workflows/publish-crate.yml: the tag list, the dispatch choices," - Write-Host "and the workspace_crates registry the sibling-dependency wait reads." + Write-Report 'Release-managed crates that cannot be published:' -Level bad + $missing | ForEach-Object { Write-Report " $_" -Level bad } + Write-Report '' + Write-Report 'Add them to .github/workflows/publish-crate.yml: the tag list, the dispatch choices,' + Write-Report 'and the workspace_crates registry the sibling-dependency wait reads.' exit 1 } -Write-Host "All $($managed.Count) release-managed crates have a publish trigger." -ForegroundColor Green +Write-Report "All $($managed.Count) release-managed crates have a publish trigger." -Level good exit 0 diff --git a/tools/inject-mutant.ps1 b/tools/inject-mutant.ps1 index a8aa2f9f..359d965c 100644 --- a/tools/inject-mutant.ps1 +++ b/tools/inject-mutant.ps1 @@ -1,75 +1,231 @@ # Copyright (c) Mike Grier. -# -# Line-targeted mutant injection, for confirming a test actually kills a mutant. -# -# Deliberately NOT a string replace: ` state.reserved += 1;` occurs four -# times in one file here, and replacing all of them mutated a tested line while -# reporting an untested one as caught -- inverting the conclusion. cargo-mutants -# names a file, a line and a column, so use them. -# -# Judges by exit code, and treats a hang as caught, for the reasons in -# tools/README-sabotage.md. +<# +.SYNOPSIS + Line-targeted mutant injection, for confirming a test actually kills a + mutant that cargo-mutants reported as surviving. + +.DESCRIPTION + Deliberately NOT a whole-file string replace: ` state.reserved += 1;` + occurs four times in one file here, and replacing all of them mutated a + tested line while reporting an untested one as caught -- inverting the + conclusion. cargo-mutants names a file, a line and a column, so use them. + + Three rules are encoded here because each was learned by getting it wrong. + + ONE OCCURRENCE, OR SAY WHICH. Targeting a line is not enough when the + pattern appears on it twice: the tool would mutate whichever it reached + first and report a verdict about the other. A line with more than one match + is refused unless -Column names the one meant. An earlier version passed a + replacement count to the STATIC [regex]::Replace overload, whose fourth + parameter is RegexOptions -- so the `1` meant IgnoreCase, and every + occurrence on the line was replaced. There is no static overload taking a + count at all, which is why the replacement is now done by offset. + + THE BASELINE MUST BE GREEN FIRST. This judges by exit code, so an unrelated + compile error or a pre-existing failure would be recorded as the mutant + being "caught" -- a false clean bill of health for a test that does not + exist. The unmodified suite runs once before the first mutation, and a red + one aborts the run. + + ALL FEATURES, BY DEFAULT. A mutation inside `#[cfg(feature = "...")]` code + is compiled out along with its tests when that feature is off, so the suite + passes trivially and the mutant is reported as surviving. Measured on this + workspace: 57 of 61 `windows-topology-sys` survivors and 147 of 247 + `windows-file-watcher` survivors were that artifact. The documented mutation + workflow runs with all features, and so does this. + + A hang counts as caught, for the reasons in tools/README-sabotage.md: a + missing wakeup does not fail a test, it stops one. + +.PARAMETER File + Repository-relative source file to mutate. + +.PARAMETER Line + One or more 1-based line numbers, each mutated and tested in turn. + +.PARAMETER Column + Optional 1-based column per line, as cargo-mutants reports it, naming which + occurrence to replace when the line carries more than one. Supply either one + column for every line, or none at all. + +.PARAMETER Find + Text to replace. Matched literally, never as a regex. + +.PARAMETER Replace + Literal replacement text. Empty removes the matched text. + +.PARAMETER TestFilter + Optional positional filter passed to `cargo test`. + +.PARAMETER Package + Crate to test. + +.PARAMETER DefaultFeaturesOnly + Test with default features instead of all of them. Off by default; read the + feature note above before using it. + +.PARAMETER TimeoutSeconds + Bound on each test run. A run that exceeds it is killed and counted caught. + +.OUTPUTS + Exits non-zero if any mutant survived, or if the run could not be trusted. +#> param( [Parameter(Mandatory = $true)][string] $File, [Parameter(Mandatory = $true)][int[]] $Line, + [int[]] $Column = @(), [Parameter(Mandatory = $true)][string] $Find, [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Replace, [string] $TestFilter = '', [string] $Package = 'windows-file-watcher', + [switch] $DefaultFeaturesOnly, [int] $TimeoutSeconds = 180 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $utf8NoBom = [System.Text.UTF8Encoding]::new($false) + +# The single output sink. Every message this tool emits goes through here, so +# the destination and the formatting stay separable from the call sites that +# produce the content -- the repository's one-output-sink rule. +function Write-Report { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message, + [ValidateSet('info', 'good', 'warn', 'bad')][string] $Level = 'info' + ) + $colour = switch ($Level) { + 'good' { 'Green' } + 'warn' { 'DarkYellow' } + 'bad' { 'Red' } + default { 'Gray' } + } + Write-Host $Message -ForegroundColor $colour +} + +# Runs `cargo test` under a wall-clock bound and reports which of three outcomes +# occurred. A hang is distinct from a failure because it is what a lost-wakeup +# mutant looks like, and collapsing the two would hide that. +function Invoke-Suite { + param([string] $Repository, [string[]] $CargoArgs, [int] $Seconds) + + $out = Join-Path $env:TEMP ('mutline-' + [guid]::NewGuid().ToString('N') + '.txt') + $proc = Start-Process -FilePath 'cargo' -ArgumentList $CargoArgs -WorkingDirectory $Repository ` + -PassThru -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError "$out.err" + try { + if ($proc.WaitForExit($Seconds * 1000)) { + $outcome = if ($proc.ExitCode -eq 0) { 'passed' } else { 'failed' } + return [pscustomobject]@{ Outcome = $outcome; Code = $proc.ExitCode } + } + Get-CimInstance Win32_Process -Filter "ParentProcessId=$($proc.Id)" -ErrorAction SilentlyContinue | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + return [pscustomobject]@{ Outcome = 'hung'; Code = $null } + } + finally { + Remove-Item -LiteralPath $out, "$out.err" -Force -ErrorAction SilentlyContinue + } +} + $repo = (git rev-parse --show-toplevel).Replace('/', '\') $path = Join-Path $repo $File +if ($Column.Count -ne 0 -and $Column.Count -ne $Line.Count) { + Write-Report '-Column must name a column for every line, or be omitted entirely.' -Level bad + exit 2 +} + +$cargoArgs = @('test', '-p', $Package, '--locked') +if (-not $DefaultFeaturesOnly) { $cargoArgs += '--all-features' } +if ($TestFilter) { $cargoArgs += $TestFilter } + $original = Get-Content -LiteralPath $path -foreach ($n in $Line) { + +# Resolve every target before touching anything, so a stale line number cannot +# leave the tree half-patched or produce a verdict about the wrong expression. +$targets = @() +for ($i = 0; $i -lt $Line.Count; $i++) { + $n = $Line[$i] $index = $n - 1 - if ($original[$index] -notmatch [regex]::Escape($Find)) { - Write-Host ("line {0} does not contain '{1}': {2}" -f $n, $Find, $original[$index].Trim()) -ForegroundColor Red + if ($index -lt 0 -or $index -ge $original.Count) { + Write-Report ('line {0} is outside {1}, which has {2} lines' -f $n, $File, $original.Count) -Level bad + exit 2 + } + + $hits = [regex]::Matches($original[$index], [regex]::Escape($Find)) + if ($hits.Count -eq 0) { + Write-Report ("line {0} does not contain '{1}': {2}" -f $n, $Find, $original[$index].Trim()) -Level bad + exit 2 + } + + $columns = ($hits | ForEach-Object { $_.Index + 1 }) -join ', ' + if ($Column.Count -ne 0) { + # cargo-mutants reports 1-based columns; .NET match indices are 0-based. + $wanted = $Column[$i] - 1 + $chosen = $hits | Where-Object { $_.Index -eq $wanted } | Select-Object -First 1 + if (-not $chosen) { + Write-Report ("line {0} has no '{1}' at column {2}; it starts at column(s) {3}" -f ` + $n, $Find, $Column[$i], $columns) -Level bad + exit 2 + } + } + elseif ($hits.Count -gt 1) { + # Refused rather than guessed. Mutating the wrong occurrence yields a + # verdict about an expression nobody asked about, and it reads exactly + # like a real result. + Write-Report ("line {0} contains '{1}' {2} times (columns {3}); name one with -Column" -f ` + $n, $Find, $hits.Count, $columns) -Level bad exit 2 } + else { + $chosen = $hits[0] + } + + $targets += [pscustomobject]@{ + Line = $n; Index = $index; Start = $chosen.Index; Length = $chosen.Length + } } -foreach ($n in $Line) { +# The baseline, before the first mutation. Judging a mutant by exit code is only +# sound if the unmodified tree exits zero; otherwise every mutant is "caught" +# for a reason that has nothing to do with the tests. +Write-Report 'Baseline: running the unmodified suite.' +$baseline = Invoke-Suite -Repository $repo -CargoArgs $cargoArgs -Seconds $TimeoutSeconds +if ($baseline.Outcome -ne 'passed') { + Write-Report ('Baseline is NOT green ({0}); every verdict below would be meaningless. Fix the suite first.' -f ` + $baseline.Outcome) -Level bad + exit 3 +} +Write-Report 'Baseline is green. Injecting.' + +$survivors = 0 +foreach ($target in $targets) { $mutated = [System.Collections.ArrayList]::new($original) - $index = $n - 1 - # Replace only the first occurrence on that one line. - $pattern = [regex]::Escape($Find) - $mutated[$index] = [regex]::Replace($mutated[$index], $pattern, $Replace.Replace('$', '$$'), 1) + $text = [string] $mutated[$target.Index] + $mutated[$target.Index] = $text.Remove($target.Start, $target.Length).Insert($target.Start, $Replace) - [System.IO.File]::WriteAllText($path, (($mutated -join "`n") + "`n"), $utf8NoBom) try { - $args = @('test', '-p', $Package, '--locked') - if ($TestFilter) { $args += $TestFilter } - $out = Join-Path $env:TEMP 'mutline.txt' - $proc = Start-Process -FilePath 'cargo' -ArgumentList $args -WorkingDirectory $repo ` - -PassThru -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError "$out.err" - if ($proc.WaitForExit($TimeoutSeconds * 1000)) { - $verdict = if ($proc.ExitCode -eq 0) { '*** SURVIVED ***' } else { 'caught' } - $detail = "exit $($proc.ExitCode)" - } - else { - Get-CimInstance Win32_Process -Filter "ParentProcessId=$($proc.Id)" -ErrorAction SilentlyContinue | - ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } - Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue - $verdict = 'caught' - $detail = "HUNG past ${TimeoutSeconds}s" - } + # Inside the guarded region, so a write that throws part-way through -- + # leaving the file truncated -- still reaches the restoring `finally`. + [System.IO.File]::WriteAllText($path, (($mutated -join "`n") + "`n"), $utf8NoBom) + $run = Invoke-Suite -Repository $repo -CargoArgs $cargoArgs -Seconds $TimeoutSeconds + $verdict = if ($run.Outcome -eq 'passed') { '*** SURVIVED ***' } else { 'caught' } + $detail = if ($run.Outcome -eq 'hung') { "HUNG past ${TimeoutSeconds}s" } else { "exit $($run.Code)" } } finally { [System.IO.File]::WriteAllText($path, (($original -join "`n") + "`n"), $utf8NoBom) if ((Get-Content -LiteralPath $path -Raw) -ne (($original -join "`n") + "`n")) { # Content compare is approximate across line-ending conventions, so # only warn; the authoritative check is the caller's `git status`. - Write-Host " (verify $File is restored)" -ForegroundColor DarkYellow + Write-Report " (verify $File is restored)" -Level warn } } - $colour = if ($verdict -eq 'caught') { 'Green' } else { 'Red' } - Write-Host ("{0}:{1} '{2}' -> '{3}' {4} ({5})" -f $File, $n, $Find, $Replace, $verdict, $detail) -ForegroundColor $colour + $level = if ($verdict -eq 'caught') { 'good' } else { $survivors++; 'bad' } + Write-Report ("{0}:{1} '{2}' -> '{3}' {4} ({5})" -f ` + $File, $target.Line, $Find, $Replace, $verdict, $detail) -Level $level } + +if ($survivors -gt 0) { exit 1 } +exit 0 diff --git a/tools/run-mutants.ps1 b/tools/run-mutants.ps1 index 26ef9ee0..3e579318 100644 --- a/tools/run-mutants.ps1 +++ b/tools/run-mutants.ps1 @@ -98,9 +98,10 @@ `-TimeoutSeconds` is non-zero. .PARAMETER OutputDirectory - Where to write `mutants.out`. Defaults under `.scratch/`, so a run never - overwrites a previous run's results in the repository root -- which has - already lost one analysis. + Where to write `mutants.out`. Defaults under `.scratch/`, and carries a + per-run timestamp, so a run never overwrites a previous run's results -- + neither the repository root's `mutants.out`, which has already lost one + analysis, nor an earlier sweep of the same scope. .EXAMPLE .\tools\run-mutants.ps1 -File crates/windows-file-watcher/src/watcher.rs @@ -122,7 +123,13 @@ $ErrorActionPreference = 'Stop' $repo = (git rev-parse --show-toplevel).Replace('/', '\') if (-not $OutputDirectory) { $leaf = if ($File) { [System.IO.Path]::GetFileNameWithoutExtension($File) } else { $Package } - $OutputDirectory = Join-Path $repo ".scratch\mutants-$leaf" + # Stamped per run, not merely per scope. A path derived from the package or + # file alone is the same path every time, so a second run of the same scope + # overwrites the analysis this parameter promises to preserve -- and two + # concurrent runs write into one directory. The stamp sorts chronologically, + # so the most recent run is the last one listed. + $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss') + $OutputDirectory = Join-Path $repo ".scratch\mutants-$leaf-$stamp" } $werKey = 'HKCU:\Software\Microsoft\Windows\Windows Error Reporting' diff --git a/tools/run-numa-spikes.ps1 b/tools/run-numa-spikes.ps1 index 62b74f95..1a1ff566 100644 --- a/tools/run-numa-spikes.ps1 +++ b/tools/run-numa-spikes.ps1 @@ -20,8 +20,11 @@ cost is a minute; the payoff is that if a multi-node runner ever appears, the answer is already in that build's log. - This script never fails on a spike's result. It exits non-zero only if a - spike fails to BUILD, which is a real defect in the instrument. + This script never fails on a spike's *result*. It exits non-zero when a + spike fails to BUILD or fails to RUN, both of which are defects in the + instrument rather than findings about the machine. The job that runs it + carries `continue-on-error`, so a red step here reports the rot without + blocking the workflow. .PARAMETER Summary Optional path to append a rendered summary to, for $env:GITHUB_STEP_SUMMARY. @@ -37,6 +40,21 @@ param( $ErrorActionPreference = 'Stop' +# The single output sink. Every message this tool emits goes through here, so +# the destination and the formatting stay separable from the call sites that +# produce the content -- the repository's one-output-sink rule. +function Write-Report { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message, + [ValidateSet('info', 'warning', 'error')][string] $Level = 'info' + ) + switch ($Level) { + 'warning' { Write-Host "::warning::$Message" } + 'error' { Write-Host "::error::$Message" } + default { Write-Host $Message } + } +} + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') $spikeDir = Join-Path $repoRoot 'crates\windows-ioring-sys\design-sessions\spikes' @@ -63,14 +81,14 @@ $spikes = @( ) New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null -$buildFailures = 0 +$instrumentFailures = 0 $sections = New-Object System.Collections.Generic.List[string] foreach ($spike in $spikes) { $source = Join-Path $spikeDir $spike.File if (-not (Test-Path $source)) { - Write-Host "::warning::spike source missing: $source" - $buildFailures++ + Write-Report "spike source missing: $source" -Level warning + $instrumentFailures++ continue } @@ -92,24 +110,25 @@ windows-sys = { version = "0.61.2", default-features = false, features = [$featu Set-Content -Path (Join-Path $work 'Cargo.toml') -Value $manifest -Encoding utf8 Copy-Item $source (Join-Path $work 'src\main.rs') -Force - Write-Host "=== building $($spike.Name) ===" + Write-Report "=== building $($spike.Name) ===" Push-Location $work try { $build = & cargo build --quiet 2>&1 $buildExit = $LASTEXITCODE if ($buildExit -ne 0) { - # A build failure is a defect in the instrument, and is the one - # thing here worth failing over. - Write-Host "::error::spike $($spike.Name) failed to build" - $build | ForEach-Object { Write-Host $_ } - $buildFailures++ + # A build failure is a defect in the instrument, and is one of the + # two things here worth failing over. + Write-Report "spike $($spike.Name) failed to build" -Level error + $build | ForEach-Object { Write-Report $_ } + $instrumentFailures++ $sections.Add("### $($spike.Name)`n`n**FAILED TO BUILD** -- the instrument is broken, not the machine.`n") continue } - Write-Host "=== running $($spike.Name) ===" + Write-Report "=== running $($spike.Name) ===" $output = & cargo run --quiet 2>&1 | Out-String - Write-Host $output + $runExit = $LASTEXITCODE + Write-Report $output } finally { Pop-Location @@ -119,12 +138,22 @@ windows-sys = { version = "0.61.2", default-features = false, features = [$featu $transcript = Join-Path $OutputDirectory "$($spike.Name).txt" Set-Content -Path $transcript -Value $output -Encoding utf8 - $vacuous = $output -match 'VACUOUS' - $verdict = if ($vacuous) { - 'vacuous on this runner (single NUMA node) -- expected, and the spike said so itself' + if ($runExit -ne 0) { + # The other one. Vacuity is decided by searching the output for + # `VACUOUS`, and a spike that crashed printed no such line -- so + # without this the summary would announce "**NOT vacuous -- this runner + # has more than one NUMA node**" on the strength of a stack trace, and + # the script would still exit 0. That is the instrument breaking while + # claiming a result about the machine. + Write-Report "spike $($spike.Name) failed to run (exit $runExit)" -Level error + $instrumentFailures++ + $verdict = "**FAILED TO RUN (exit $runExit)** -- the instrument is broken, so this says nothing about the machine." + } + elseif ($output -match 'VACUOUS') { + $verdict = 'vacuous on this runner (single NUMA node) -- expected, and the spike said so itself' } else { - '**NOT vacuous -- this runner has more than one NUMA node. Read the output.**' + $verdict = '**NOT vacuous -- this runner has more than one NUMA node. Read the output.**' } $sections.Add(@" @@ -144,17 +173,17 @@ if ($Summary) { $header = @" ## NUMA spike results -Observational. These never fail the build; a red step here means a spike failed -to **build**, which is a defect in the instrument rather than a finding about -the machine. +Observational. A spike's *result* never fails the build; a red step here means a +spike failed to **build** or to **run**, which is a defect in the instrument +rather than a finding about the machine. "@ Add-Content -Path $Summary -Value ($header + ($sections -join "`n")) } -if ($buildFailures -gt 0) { - Write-Host "::error::$buildFailures spike(s) failed to build" +if ($instrumentFailures -gt 0) { + Write-Report "$instrumentFailures spike(s) failed to build or run" -Level error exit 1 } -Write-Host "all spikes built and ran" +Write-Report 'all spikes built and ran' exit 0 diff --git a/tools/run-sabotage.ps1 b/tools/run-sabotage.ps1 index 7fb46dc6..ef518b2a 100644 --- a/tools/run-sabotage.ps1 +++ b/tools/run-sabotage.ps1 @@ -315,8 +315,12 @@ foreach ($sabotage in $selected) { } $transcript = Join-Path $OutputDirectory ((($sabotage.name -replace '[^A-Za-z0-9]+', '-')) + '.txt') - [System.IO.File]::WriteAllText($target, $patched, $utf8NoBom) try { + # Inside the guarded region, not before it. A write that throws part-way + # through -- having already truncated the file -- would otherwise never + # reach the `finally` that restores it, and the tool's whole promise is + # that it leaves the tree as it found it. + [System.IO.File]::WriteAllText($target, $patched, $utf8NoBom) $run = Invoke-Sabotaged -CargoArgs $testArgs -WorkingDirectory $repoRoot ` -TranscriptPath $transcript -BuildSeconds $BuildTimeoutSeconds -TestSeconds $TimeoutSeconds } From dbcf93233a2b1e8c487e084155259942238cf955 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 19:38:55 -0400 Subject: [PATCH 167/361] docs(file-watcher): record D-85, paths are the caller's verbatim M15.3 asked whether this crate should open paths longer than MAX_PATH. It had offered "prefix `\\?\` in `wide_path`" as one coherent outcome; measurement says that outcome is wrong, and that the item's premise was measuring the test harness rather than the crate. `\\?\` is a path parsing mode, not a length switch. Adopting it on a caller's behalf changes what their path means, on paths that have nothing to do with length -- measured against a short directory that opens fine today: C:/Users/.../dir opens verbatim, ERROR_FILE_NOT_FOUND prefixed C:\...\dir\. opens verbatim, ERROR_INVALID_NAME prefixed C:\...\dir\subdir\.. opens verbatim, ERROR_INVALID_NAME prefixed Relative paths would stop resolving entirely, which this crate supports on purpose: `open_file_target` normalises a bare leaf's empty parent to `.`. And "a directory deeper than MAX_PATH cannot be opened" was a property of the harness. Long paths without the prefix need the machine's LongPathsEnabled policy *and* the application's longPathAware manifest. On this machine the policy was already set; the same probe source, same machine, differing only by an embedded manifest, went from ERROR_PATH_NOT_FOUND to opening a 300-character path. A library cannot set its consumer's manifest, and a Rust test binary has none. So the pass-through is now a stated decision rather than an unexamined default, documented where a caller meets it: `wide_path`, `DirectoryHandle::open`, and `Session::subscribe`. The decision also records the traversal rule and states plainly that it schedules no work: Win32 has no relative open, so traversal must build child paths that can exceed MAX_PATH even when the caller's did not -- but this crate never lengthens a path (recursion is the kernel's, names stay relative per D-8, and the only structural operation is `open_file_target`'s `parent()`, which shortens). If traversal is ever added, the base is `canonical_path`, not the caller's string, because GetFinalPathNameByHandleW returns the `\\?\` form after Win32 has applied the caller's parsing mode. Checked rather than assumed: no code compares the two forms today. Spawns M15.9 (guard tests pinning the pass-through, so a future "helpful" prefix fails the suite) and M15.10 (the junction fixture for the 512-unit retry -- the back door M15.3 called hypothetical works, no elevation, a 53-character junction resolving to a 578-character target). Completed item: M15.3: Decide whether this crate should open paths longer than `MAX_PATH`, and note the consequence for `canonical_path`'s retry either way. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 43 +++++++------ .../COMPLETED-CHECKLIST.md | 56 ++++++++++++++++- crates/windows-file-watcher/DESIGN-NOTES.md | 61 ++++++++++++++++++- crates/windows-file-watcher/src/directory.rs | 13 ++++ crates/windows-file-watcher/src/session.rs | 9 +++ 5 files changed, 162 insertions(+), 20 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 3361d5ef..eb4287d4 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -139,24 +139,31 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- - [x] **M15.8** -- Settled the write-only tail of M15.2's removal: the stored `canonical_path` field is gone, `DirectoryHandle::canonical_path` stayed and now has a caller that uses its result plus the tests it never had. M15.3 stands, confirmed by injection. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m158) -- [ ] **M15.3** -- Decide whether this crate should open paths longer than `MAX_PATH`, and note the - consequence for `canonical_path`'s retry either way. - **`wide_path` passes the caller's path to `CreateFileW` verbatim, with no `\\?\` prefix**, so a - directory deeper than `MAX_PATH` fails to open with `ERROR_PATH_NOT_FOUND` even though - `std::fs::create_dir_all` will happily create it (Rust prefixes internally). Measured while building a - fixture for the item below: the directory existed on disk and `DirectoryHandle::open` refused it. - **The knock-on.** `canonical_path` sizes a 512-unit buffer and retries when - `GetFinalPathNameByHandleW` says the path did not fit. Reaching that retry needs a canonical path of - 512+ units, and the only way in through `open` is a path longer than `MAX_PATH` -- which cannot be - opened. So the retry is unreachable via the crate's own API, which is why its `<` survives being - changed to `>` and `<=` (the `>` case loops forever and shows up as a timeout rather than a failure). - There *is* a back door -- a short junction pointing at a deep target, since - `GetFinalPathNameByHandleW` returns the resolved target -- so the code is not dead, merely unreachable - by the obvious route. That is the fixture to build if the retry is worth testing as it stands. - **Two coherent outcomes.** Support long paths (prefix `\\?\` in `wide_path`, which also makes the - retry reachable and testable), or state the `MAX_PATH` limit as deliberate and note that the retry - covers only the junction case. Silence is the one option that leaves a caller to discover the limit - from a `NotFound` that names nothing. +- [x] **M15.3** -- Decided: a caller's path goes to Win32 verbatim, and long-path support is the consuming application's call, not this crate's (D-85). The proposed `\\?\` prefix was measured to break forward slashes, `.`, `..` and relative paths that work today. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m153) + +- [ ] **M15.9** -- Guard D-85's pass-through with tests, so a future "helpful" `\\?\` prefix fails the + suite instead of silently changing what callers' paths mean. **Deliberately scoped out of M15.3, which + recorded the decision only.** + **What to pin, all measured against a short directory that opens fine today** -- each of these would + break under a blanket prefix, which is exactly why they are the guard: `C:/Users/.../dir` (forward + slashes) becomes `ERROR_FILE_NOT_FOUND`; `...\dir\.` and `...\dir\subdir\..` become + `ERROR_INVALID_NAME`; a bare relative leaf stops resolving at all. + **And the other direction:** a caller's own `\\?\`-prefixed path must arrive intact and open, since + that is one of the two routes D-85 leaves a caller who wants long-path behaviour. + **Not** a long-path test: a Rust test binary has no `longPathAware` manifest, so a >`MAX_PATH` open + fails in-suite regardless of machine policy. Asserting that failure would pin the harness, not the + crate -- see M15.10 for the part that can be tested. + +- [ ] **M15.10** -- Test `canonical_path`'s 512-unit retry through the junction back door. **The back + door is confirmed to work, so this is now a fixture to build rather than a question to answer.** + **Measured:** `mklink /J` needs no elevation, and a **53-character** junction path resolving to a + **578-character** target is enough -- `GetFinalPathNameByHandleW` returns the resolved target, so + `open` only ever sees the short path while `canonical_path` must grow its buffer. Setup creates the + deep target through an explicitly `\\?\`-prefixed string, so the fixture does not depend on any + library prefixing on its behalf. + **Why it is worth doing:** the retry is the last untested branch in `canonical_path`, and its `<` -> + `>` mutant does not merely fail, it **loops forever** -- a defect that would surface as a hung suite + rather than a red test. `<` -> `<=` currently survives (verified by injection in M15.8). - [ ] **M15.4** -- Isolate the last two notification-filter categories, or record that they cannot be isolated from outside. Two mutants in `ALL_NOTIFY_FILTERS` survive: replacing the `|` before diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index ffb27a14..0b7e4126 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -698,4 +698,58 @@ M15.3's mutant exactly -- it needs a 512+ unit canonical path to reach. So M15.3 unanswered rather than dissolved, which is what this item had to determine. **The transferable rule:** a diagnostic wants the live handle, not a cached copy. Needing the *value* is -not a reason to keep the *field*. \ No newline at end of file +not a reason to keep the *field*. +## Moved 2026-09-01 -- M15.3: paths are the caller's, verbatim (D-85) + +### M15.3 -- Decide whether this crate should open paths longer than `MAX_PATH`, and note the consequence for `canonical_path`'s retry either way. *(completed 2026-09-01 19:45:00 -04:00)* + +**Decision: D-85 -- a caller's path reaches Win32 verbatim, and long-path support is the consuming +application's call, not this crate's.** That is what the code already did; what changed is that it is now +a stated decision with the measurements behind it, rather than an unexamined default. + +**The item's own proposed fix was measurably wrong.** M15.3 offered "prefix `\\?\` in `wide_path`" as a +coherent outcome. `\\?\` is a path *parsing mode*, not a length switch, and adopting it on a caller's +behalf changes what their path means -- measured on a **short** directory that opens fine today, so this +is nothing to do with length: + +| what the caller passed | verbatim | prefixed | +|---|---|---| +| `C:/Users/.../dir` (forward slashes) | opens | `ERROR_FILE_NOT_FOUND` | +| `C:\...\dir\.` | opens | `ERROR_INVALID_NAME` | +| `C:\...\dir\subdir\..` | opens | `ERROR_INVALID_NAME` | + +Relative paths would stop resolving entirely, and this crate supports them deliberately -- +`open_file_target` normalises a bare leaf's empty `parent()` to `.` precisely so `subscribe("target.txt")` +works. + +**The item's premise was measuring the harness, not the crate.** M15.3 recorded that a directory deeper +than `MAX_PATH` "cannot be opened". Long-path support without the prefix needs the machine's +`LongPathsEnabled` policy **and** the application's `longPathAware` manifest. On this machine the policy +was already `1`; the same probe source, same machine, differing only by an embedded manifest: + +| build | verbatim 300-character path | `\\?\` form | +|---|---|---| +| no manifest | `ERROR_PATH_NOT_FOUND` | opens | +| + `longPathAware` manifest | **opens** | opens | + +A library cannot set its consumer's manifest, and a consumer that has not opted in should not have this +crate opt in behind its back. A Rust test binary has no such manifest, which is the whole reason this +looked like a crate defect. + +**The traversal hazard does not arise here, and the decision says so rather than leaving it silent.** +Win32 has no relative open, so traversal must *build* child paths that can exceed `MAX_PATH` even when the +caller's did not -- the one case where the caller's parsing mode is genuinely not enough. This crate never +lengthens a path: recursion is the kernel's (`bWatchSubtree`), names stay relative (D-8), and the only +structural path operation in production code is `open_file_target`'s `parent()`, which shortens. So the +decision **schedules no work** for traversal; it records the rule for if it ever appears -- build on +`DirectoryHandle::canonical_path`, not the caller's string, because `GetFinalPathNameByHandleW` returns the +`\\?\` form *after* Win32 has applied the caller's parsing mode, making the switch meaning-preserving. + +**One consequence checked rather than assumed:** because the canonical form is a different parsing mode +from the caller's, mixing them in a comparison would be a bug. Nothing does -- `opened_path` is stored +verbatim and only ever reopened, never matched against a canonical path. + +**Deferred deliberately, as work items rather than prose:** M15.9 (guard tests pinning the pass-through, so +a future "helpful" prefix fails the suite) and M15.10 (the junction fixture for the 512-unit retry -- the +back door M15.3 called hypothetical is confirmed to work, no elevation needed, a 53-character junction +resolving to a 578-character target). \ No newline at end of file diff --git a/crates/windows-file-watcher/DESIGN-NOTES.md b/crates/windows-file-watcher/DESIGN-NOTES.md index f35eebba..8eba6698 100644 --- a/crates/windows-file-watcher/DESIGN-NOTES.md +++ b/crates/windows-file-watcher/DESIGN-NOTES.md @@ -103,6 +103,7 @@ threads of its own. | D-82 | **Everything a consumer needs but cannot otherwise reach is exposed behind an off-by-default `test-util` feature, not on the unconditional public surface: the feedable channel (`channel_with_bound` with `Sender`/`Delivery`/`Reservation`, previously `pub` only inside a private module) and valid-by-construction builders for the two unconstructible boundary types (`RelativeName`, `VolumeIdentity`).** This does not reverse [D-64](DESIGN-RATIONALE.md#the-m64-test-seam-is-a-private-constructor-not-a-public-feature-flag-d-64): D-64's seams serve the crate's own tests reaching internal state, for which `#[cfg(test)]`/`pub(crate)` is strictly better; this seam serves a downstream consumer's tests, which `#[cfg(test)]` cannot reach at all, and it exposes the delivery channel and public boundary constructors rather than internal state (so the retired `unstable-internals` objection does not apply). Feature-gating keeps the crate's internal queue sender, and identity/name construction, out of the production API. See [Consumer test surface](#consumer-test-surface). | | D-83 | **The consumer test surface tests the consumer's reactions, not whether this crate would ever emit a given sequence.** Builders are valid-by-construction in the type-safety sense (memory-safe, lossless), not production-domain-validating: a `RelativeName` can still carry a unit sequence the kernel itself never reports (an interior NUL, say), and an impossible ordering or an impossible relationship between two otherwise valid values (a `VolumeChanged` with equal `previous`/`current` serials, each individually a legal `VolumeIdentity`) both remain the consumer's responsibility, as with any hand-fed test double. This fidelity limit is documented on the surface so a passing handler test is not mistaken for confirmation that the crate produces that traffic. See [Consumer test surface](#consumer-test-surface). | | D-84 | **The delivery contract was under-specified, and a second implementation of it -- not a test suite -- is what proved that.** PR #42's example harness promised contract-legal schedules only (its own D-5), which made its generator a second implementation of *this* crate's contract. Converging it took **19 automated review rounds**: eight fixed generated sequences this crate could never emit, five corrected the contract prose itself, and one found a real shipped reliability defect ([`has_room`](#the-has_room-finding-in-this-crate)) on [D-29](#d-29)'s backpressure path. All 278 of this crate's own tests passed throughout and were never going to fail -- they assert what the watcher *does*, and every gap was in what the contract *permits*. The gap categories are workspace-wide and recorded once, in [the workspace design notes](../../DESIGN-NOTES.md#specifying-a-delivery-contract); the decisions amended in response were [D-9](#d-9) (renames never joined), [D-12](#d-12)/[D-30](#d-30) (branch and terminal paths), [D-17](#d-17) (per-tier emission legality), [D-27](#d-27)/[D-28](#d-28) (a fault question is unconditional, and enters as `Arm`), [D-50](#d-50)/[D-78](#d-78) (volume identity: distinct serials, and continuity across reopens), and [D-83](#d-83) (fidelity is type-safety, not production-domain). See [What the second implementation exposed](#what-the-second-implementation-exposed). | +| D-85 | **A caller's path is passed to Win32 verbatim: this crate never adds a `\\?\` prefix, and whether a path longer than `MAX_PATH` opens is the consuming application's decision, not this crate's.** `\\?\` is not a longer-path switch, it is a *different parsing mode*, and adopting it on a caller's behalf silently changes what their path means -- measured, on short paths that open fine today: forward slashes fail with `ERROR_FILE_NOT_FOUND`, and a trailing `.` or an interior `..` fail with `ERROR_INVALID_NAME`. Relative paths would stop resolving entirely, which this crate supports on purpose (`open_file_target` normalises a bare leaf's empty parent to `.`). Long paths *without* the prefix are gated on the machine's `LongPathsEnabled` policy **and** the application's `longPathAware` manifest -- measured: the same source, on the same machine, opens a 300-character path only once that manifest is present. A library cannot set its consumer's manifest, so a caller who wants long-path behaviour either opts in at the application level or passes an explicitly `\\?\`-prefixed path, and both work here because the path is passed through untouched. See [Paths are the caller's, verbatim](#paths-are-the-callers-verbatim). | ### Queue mediation @@ -874,4 +875,62 @@ claims, and the second does not follow from the first: this body was unreachable symptom of a doc that had gone false by vacuity -- `Entry`, `StandingHold`, `StandingState`, and `take` all described this `Drop` as the live release mechanism, so a reader would have trusted a fallback that could not work. Four -restatements of one fact, none of which moved when the fact did. \ No newline at end of file +restatements of one fact, none of which moved when the fact did. +### Paths are the caller's, verbatim + +D-85. `wide_path` hands `CreateFileW` exactly the units the caller supplied. That +is a deliberate decision, not an omission, and the temptation it resists is to +"helpfully" prepend `\\?\` so that longer paths work. + +**`\\?\` is a parsing mode, not a length switch.** It turns off the Win32 path +parser wholesale: no forward-slash translation, no `.`/`..` resolution, no +trailing-dot-or-space stripping, no reserved-name interception, and no relative +paths at all. Adopting it on a caller's behalf therefore changes what their path +*means*, and it does so on paths that have nothing to do with `MAX_PATH`. +Measured on a short directory that opens fine today: + +| what the caller passed | verbatim | if this crate had prefixed it | +|---|---|---| +| `C:/Users/.../dir` | opens | `ERROR_FILE_NOT_FOUND` | +| `C:\...\dir\.` | opens | `ERROR_INVALID_NAME` | +| `C:\...\dir\subdir\..` | opens | `ERROR_INVALID_NAME` | + +Relative paths would stop working entirely, and this crate supports them on +purpose -- `open_file_target` normalises a bare leaf's empty `parent()` to `.` +precisely so `subscribe("target.txt", ...)` resolves. + +**Long paths are the application's call, not the library's.** Opening past +`MAX_PATH` without the prefix requires *both* the machine's +`LongPathsEnabled` policy and the application's own `longPathAware` manifest. +Measured: the same binary source, on the same machine with the policy already +enabled, fails a 300-character open with `ERROR_PATH_NOT_FOUND` and succeeds once +the manifest is embedded. A library cannot set its consumer's manifest, and a +consumer that has deliberately not opted in should not have this crate opt in for +it behind its back. So the caller has two routes, and passing the path through +untouched is what keeps both open: opt in at the application level, or pass an +explicitly `\\?\`-prefixed path, which this crate forwards unchanged and Win32 +then honours. + +A caveat worth stating plainly, since it is the reason this looked like a defect: +a Rust test binary has no such manifest, so a long-path open fails inside this +crate's own test suite regardless of machine policy. That is a property of the +harness, not of the crate. + +**Path construction, if it is ever needed here.** Win32 has no relative open, so +any traversal has to *build* a child path, and a built path can exceed `MAX_PATH` +even when the caller's own did not -- the one case where the caller's parsing +mode is genuinely not enough. This crate does not have that problem today and the +decision schedules no work for it: recursion is the kernel's (`bWatchSubtree`), +notification names stay relative (D-8), and the only structural path operation in +the crate is `open_file_target`'s `parent()`, which shortens. If traversal is +ever added, the base to build on is `DirectoryHandle::canonical_path`, not the +caller's string: `GetFinalPathNameByHandleW` returns the `\\?\` form *after* +Win32 has already applied the caller's parsing mode, so switching to `\\?\` +semantics at that point preserves the meaning the caller's path had, rather than +reinterpreting it. + +One consequence to keep in view: because that canonical form is a different +parsing mode from what the caller supplied, it must not be compared against, or +handed back as, the caller's own path. Today it is used only in a diagnostic, and +nothing in the crate compares the two forms -- `opened_path` is stored verbatim +and only ever reopened, never matched against a canonical path. \ No newline at end of file diff --git a/crates/windows-file-watcher/src/directory.rs b/crates/windows-file-watcher/src/directory.rs index 6165d4bc..066ee571 100644 --- a/crates/windows-file-watcher/src/directory.rs +++ b/crates/windows-file-watcher/src/directory.rs @@ -256,6 +256,15 @@ pub(crate) fn classify(error: &std::io::Error) -> OpenFailure { /// Encode a path as a NUL-terminated wide string. /// +/// Otherwise **verbatim**: the caller's units reach Win32 unchanged, and in +/// particular this never prepends `\\?\` (D-85). That prefix is a different path +/// *parsing mode*, not a longer-path switch -- it would stop forward slashes, +/// `.`, `..`, and relative paths from resolving, all of which work today -- and +/// whether a path past `MAX_PATH` opens without it depends on the machine's +/// `LongPathsEnabled` policy together with the consuming *application's* +/// `longPathAware` manifest, neither of which is a library's to decide. A caller +/// who wants `\\?\` semantics passes a `\\?\` path, and it arrives intact. +/// /// An interior NUL is rejected rather than passed on: Win32 would stop at it and /// silently open a *different, shorter* path than the caller named, which is a /// correctness hole rather than a mere inconvenience. `Wtf16String` keeps content @@ -358,6 +367,10 @@ pub struct DirectoryHandle { impl DirectoryHandle { /// Open `path` for change notification. /// + /// `path` is passed to `CreateFileW` verbatim -- see [`wide_path`] and D-85 + /// for why no `\\?\` prefix is added, and what that means for paths longer + /// than `MAX_PATH`. + /// /// # Errors /// /// Returns a classified [`OpenError`]; see [`OpenFailure`] for what each diff --git a/crates/windows-file-watcher/src/session.rs b/crates/windows-file-watcher/src/session.rs index c56615ea..7b2ced99 100644 --- a/crates/windows-file-watcher/src/session.rs +++ b/crates/windows-file-watcher/src/session.rs @@ -66,6 +66,15 @@ impl Session { /// returned handle owns the subscription's lifetime -- dropping it cancels -- /// so it must be kept for as long as the client wants notifications. /// + /// `path` reaches Win32 **verbatim** (D-85). It may be relative, may use + /// forward slashes, and may contain `.` or `..`, because this crate does not + /// prepend `\\?\` on a caller's behalf -- that prefix selects a different + /// path parsing mode in which none of those resolve. The consequence worth + /// knowing: a path longer than `MAX_PATH` opens only if **your** + /// application declares `longPathAware` in its manifest and the machine has + /// `LongPathsEnabled` set, or if you pass an already-`\\?\`-prefixed path, + /// which is forwarded unchanged. + /// /// # Errors /// /// Returns [`io::ErrorKind::WouldBlock`] if the notification queue has no From eb0057201d5a7caf2d1b9bedf652c3f6a82cd653 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 19:41:10 -0400 Subject: [PATCH 168/361] fix(waitable-queues): repair the PR #56 second review round Completed item: SH-7.8: reply to every thread and resolve the ones addressed. Seven further findings, each verified against the source before being accepted. The crate-level `cfg(windows)` gate. The crate documentation states that every public item is gated and that non-Windows builds produce an empty shell, and nothing in the crate implemented that -- so the stated platform contract was simply untrue. Gated every module and re-export, matching the sibling Windows-only crates. The ioring drop test was not race-free, and its comment asserted otherwise. `DROP_RUNS` was a process-wide static read as `after > before`, so another test dropping any ring between the two loads satisfied the assertion on its own -- masking precisely the mutant the test exists to catch, since cargo test runs tests as threads in one process. The counter is now thread-local and the count exact. Sabotage-verified: removing the Drop body still turns the suite red. Three corrupted SAFETY comments. `spsc`, `slotwise_mpsc` and `reserving_mpsc` each carried a stray `//` and a literal TAB where "teardown" should read -- an escape-hazard artifact that obscured the rationale for forcing Sync onto a field holding a boxed FnMut. The remaining two changed PowerShell tools now route output through one sink, completing what the previous commit started, and M34.2's inventory records that the scripts are done rather than deferred -- with the reason the deferral does not apply to them, and what the Rust binaries still owe. A stale manifest comment called crates.io publication "an open decision" when PT-5.3 decided it (yes, but not until the download path is walked) and PT-5.6 tracks the blocked publication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 2 +- CHECKLIST.md | 24 +++++++++--- crates/windows-ioring-sys/src/ring.rs | 27 +++++++++----- crates/windows-ioring-sys/src/ring/tests.rs | 23 +++++++----- crates/windows-placement-probe/Cargo.toml | 14 ++++--- crates/windows-waitable-queues/src/lib.rs | 25 ++++++++++++- .../src/reserving_mpsc.rs | 5 ++- .../src/slotwise_mpsc.rs | 5 ++- crates/windows-waitable-queues/src/spsc.rs | 5 ++- tools/run-mutants.ps1 | 29 ++++++++++++--- tools/run-sabotage.ps1 | 37 ++++++++++++++----- 11 files changed, 142 insertions(+), 54 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 965ac7fe..3a84978b 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -390,7 +390,7 @@ repair from a reviewer's guess that was taken on trust. The placement probe's tests name scratch directories without the process id, so two concurrent test processes -- which the documented `-j 2` mutation workflow creates -- delete each other's fixtures. -- [ ] **SH-7.8** -- **Reply to every thread and resolve the ones that are addressed**, including the one +- [x] **SH-7.8** -- **Reply to every thread and resolve the ones that are addressed**, including the one finding that was checked and found not to hold: `GetSystemDirectoryW` returning exactly the buffer length is unreachable (success excludes the terminator, failure includes it and so exceeds the buffer), though the guard is widened anyway so the next reader need not redo the analysis. diff --git a/CHECKLIST.md b/CHECKLIST.md index 9a7d376a..96a67001 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -138,13 +138,27 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. [core_affinity.rs](crates/windows-platform-probes/src/bin/core_affinity.rs) and [peer_index_cache.rs](crates/windows-platform-probes/src/bin/peer_index_cache.rs). The banner helpers that hardcode stdout are part of it, not an exception to it. - **Deferred from that pull request deliberately, and the reason is sequencing rather than doubt.** It - is one refactor across seven binaries; done properly it means choosing the seam once and applying it - uniformly, which is a large diff touching every probe's output. Landing it inside a 90-commit branch - already under review would mix it with unrelated correctness work. + **The PowerShell tools are NOT part of this item, because they are already done.** A later review + round on the same pull request observed that the inventory above named only Rust binaries while five + scripts emitted from many sites, so those were converted in that pull request rather than queued + here: [inject-mutant.ps1](tools/inject-mutant.ps1), + [check-publishable.ps1](tools/check-publishable.ps1), + [run-numa-spikes.ps1](tools/run-numa-spikes.ps1), [run-mutants.ps1](tools/run-mutants.ps1) and + [run-sabotage.ps1](tools/run-sabotage.ps1) each now route everything through one `Write-Report` + sink. They were small enough to convert in place, which is exactly why they did not need deferring. + (`run-sabotage.ps1`'s `Exit-WithMessage` is deliberately outside its sink: that path writes to + stderr and exits, and there the destination is part of the meaning.) + **What remains deferred is the seven Rust binaries, and the reason is sequencing rather than doubt.** + It is one refactor across seven binaries; done properly it means choosing the seam once and applying + it uniformly, which is a large diff touching every probe's output. Landing it inside a 90-commit + branch already under review would mix it with unrelated correctness work. **The point of the rule is that output becomes testable, so the conversion is not done until something tests it.** An abstraction introduced without a capture-based test spends the cost and - skips the benefit -- do not check this item off on the refactor alone. + skips the benefit -- do not check this item off on the refactor alone. That test is what the Rust + binaries still owe; a PowerShell sink is a function whose destination can be swapped, but this + workspace runs no PowerShell test harness in which to assert against it, and inventing one to cover + five diagnostic scripts is not a cost this item is willing to spend without deciding to adopt such a + harness first. Start with `placement_probe`: its output is a published artifact that strangers paste into a discussion thread, so "can this be captured and asserted end to end?" has real value there rather than being architectural tidiness. diff --git a/crates/windows-ioring-sys/src/ring.rs b/crates/windows-ioring-sys/src/ring.rs index 3edffa0f..7cfde391 100644 --- a/crates/windows-ioring-sys/src/ring.rs +++ b/crates/windows-ioring-sys/src/ring.rs @@ -991,14 +991,12 @@ impl Drop for IoRing { fn drop(&mut self) { // A count of how many times this body has run, so a test can confirm // the rundown-and-close actually executes rather than trusting the - // impl exists. Read as "increased by at least this many" rather than - // an exact value: other rings drop concurrently on the same counter - // from other tests, but that only ever adds to it, and a mutation - // that replaces this whole body removes the increment along with - // everything else -- so it is caught regardless of what else the - // suite is doing at the same time. + // impl exists. The counter is thread-local: a process-wide one is + // incremented by every other test's rings as they drop, which would + // let an `after > before` assertion be satisfied by somebody else's + // drop and mask the very mutation it exists to catch. #[cfg(test)] - DROP_RUNS.fetch_add(1, Ordering::Relaxed); + DROP_RUNS.with(|runs| runs.set(runs.get() + 1)); // Best-effort rundown: a ring with an operation still outstanding at // drop time is a use bug (M3's Batch/Token are the sanctioned way to @@ -1016,10 +1014,19 @@ impl Drop for IoRing { } } -/// How many times [`IoRing`]'s `Drop` impl has run; see its use there. +/// How many times [`IoRing`]'s `Drop` impl has run **on the calling thread**; +/// see its use there. +/// +/// Thread-local rather than a shared `static`, and that is load-bearing rather +/// than tidiness. `cargo test` runs tests as threads in one process, so a +/// process-wide counter is incremented by every other test's rings as they +/// drop -- and an assertion of the form `after > before` is then satisfied by +/// *somebody else's* drop, which is exactly the mutant it was written to catch. +/// A thread-local is only touched by rings dropped on this test's own thread. #[cfg(test)] -pub(crate) static DROP_RUNS: std::sync::atomic::AtomicUsize = - std::sync::atomic::AtomicUsize::new(0); +thread_local! { + pub(crate) static DROP_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} #[cfg(test)] mod tests; diff --git a/crates/windows-ioring-sys/src/ring/tests.rs b/crates/windows-ioring-sys/src/ring/tests.rs index ab24229f..315e6bc7 100644 --- a/crates/windows-ioring-sys/src/ring/tests.rs +++ b/crates/windows-ioring-sys/src/ring/tests.rs @@ -2,7 +2,6 @@ use super::{Completion, InjectedFailure, IoRing, Op, OpSupport}; use crate::IoRingErrorExt; use crate::capability::{RingVersion, capabilities}; -use std::sync::atomic::Ordering; #[test] fn op_support_starts_empty() { @@ -167,17 +166,21 @@ fn dropping_a_ring_actually_runs_its_drop_body() { // the real body, so a mutation that replaces the whole body removes the // increment along with everything else. // - // Read as "increased by at least one" rather than "increased by exactly - // one": other tests' rings drop concurrently on this same counter, but - // that only ever adds further increments, and can never mask this one -- - // so the assertion is race-free despite the shared static. - let before = super::DROP_RUNS.load(Ordering::Relaxed); + // The counter is thread-local, and an EXACT count is asserted. An earlier + // version used a process-wide static and asserted `after > before`, which + // is not race-free: `cargo test` runs tests as threads in one process, so + // another test dropping any ring between the two reads satisfies the + // assertion on its own -- masking precisely the mutant this exists to + // catch. Only rings dropped on this thread can move a thread-local, and + // this test drops exactly one. + let before = super::DROP_RUNS.with(std::cell::Cell::get); let ring = IoRing::new(8, 8).expect("create ring"); drop(ring); - let after = super::DROP_RUNS.load(Ordering::Relaxed); - assert!( - after > before, - "dropping a ring must run its Drop impl at least once (before={before}, after={after})" + let after = super::DROP_RUNS.with(std::cell::Cell::get); + assert_eq!( + after, + before + 1, + "dropping one ring must run its Drop impl exactly once (before={before}, after={after})" ); } diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index 93dd07ae..353cb881 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -3,12 +3,14 @@ [package] name = "windows-placement-probe" version = "0.1.0" -# Publication to crates.io is an open decision -- see PT-5.3 in -# CHECKLIST-placement-tool.md. The distribution that matters is the CI-built -# binary attached to a GitHub release, because the download is the provenance: -# a binary built here is traceable to the commit that produced it in a way a -# local build of identical source is not. `false` until that decision is made, -# so the crate cannot be published by accident before it is meant to be. +# Publishing to crates.io is decided -- yes, but not yet (PT-5.3 in +# CHECKLIST-placement-tool.md, reasoning in DESIGN-NOTES.md). The distribution +# that matters is the CI-built binary attached to a GitHub release, because the +# download is the provenance: a binary built here is traceable to the commit +# that produced it in a way a local build of identical source is not. Publishing +# only after that path exists and has been walked end to end keeps the strong +# path the one a runner meets first. `false` until PT-5.6 performs the +# publication, so the crate cannot go out early by accident. publish = false authors.workspace = true edition.workspace = true diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 5f30d55f..29edb5ae 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -233,23 +233,45 @@ #![warn(missing_docs)] #![warn(unsafe_op_in_unsafe_fn)] +// Every item is gated, so the crate builds to an empty shell off Windows rather +// than failing: the implementation rests on `std::os::windows::io` and +// `windows-sys` throughout, and the whole premise -- readiness that *is* a +// waitable `HANDLE` -- has no meaning on another platform. This mirrors the +// sibling Windows-only crates here, and the crate documentation above states +// the same contract, so the two cannot drift apart. + +#[cfg(windows)] mod blocking; +#[cfg(windows)] mod capacity; +#[cfg(windows)] pub mod disposal; +#[cfg(windows)] mod doorbell; +#[cfg(windows)] mod error; +#[cfg(windows)] mod metrics; +#[cfg(windows)] mod options; -#[cfg(test)] +#[cfg(all(windows, test))] mod race_hooks; +#[cfg(windows)] pub mod reserving_mpsc; +#[cfg(windows)] pub mod slotwise_mpsc; +#[cfg(windows)] pub mod spsc; +#[cfg(windows)] pub mod traits; +#[cfg(windows)] pub use disposal::Disposal; +#[cfg(windows)] pub use error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; +#[cfg(windows)] pub use options::Options; +#[cfg(windows)] pub use traits::{Bounded, Claim, Consumer, Drain, Observable, Producer, Reserving, Waitable}; /// Pads and aligns a value onto its own cache line. @@ -263,5 +285,6 @@ pub use traits::{Bounded, Claim, Consumer, Drain, Observable, Producer, Reservin /// 128 rather than 64: that is the cache line on aarch64, and on x86-64 the /// adjacent-line prefetcher pulls pairs of 64-byte lines, so 64 does not /// reliably separate them. +#[cfg(windows)] #[repr(align(128))] struct CacheAligned(T); diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 8895b517..39476671 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -367,8 +367,9 @@ struct Shared { // publishes it. The write of the item therefore happens-before the read, and no // two threads ever touch the same slot's contents at the same time. `T: Send` is // required and sufficient because an item is moved between threads and never -// referenced from both.// -// The eardown field is deliberately NOT covered by that argument, because it +// referenced from both. +// +// The `teardown` field is deliberately NOT covered by that argument, because it // cannot be: it holds a boxed FnMut, which is Send but not Sync, so this // impl is forcing Sync onto a field that does not have it. That is sound for // a narrower reason -- the field is unreachable through a shared reference. It diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index 74d629e4..63b018e1 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -283,8 +283,9 @@ struct Shared { // publishes it. The write of the item therefore happens-before the read, and no // two threads ever touch the same slot's contents at the same time. `T: Send` // is required and sufficient because an item is moved between threads and never -// referenced from both.// -// The eardown field is deliberately NOT covered by that argument, because it +// referenced from both. +// +// The `teardown` field is deliberately NOT covered by that argument, because it // cannot be: it holds a boxed FnMut, which is Send but not Sync, so this // impl is forcing Sync onto a field that does not have it. That is sound for // a narrower reason -- the field is unreachable through a shared reference. It diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index e79d16c9..ce79daf4 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -240,8 +240,9 @@ struct Shared { // publishes its position with a release store that the other acquires, so the // write of an item happens-before the read of that item. `T: Send` is required // and sufficient because an item is moved between the threads and never -// referenced from both.// -// The eardown field is deliberately NOT covered by that argument, because it +// referenced from both. +// +// The `teardown` field is deliberately NOT covered by that argument, because it // cannot be: it holds a boxed FnMut, which is Send but not Sync, so this // impl is forcing Sync onto a field that does not have it. That is sound for // a narrower reason -- the field is unreachable through a shared reference. It diff --git a/tools/run-mutants.ps1 b/tools/run-mutants.ps1 index 3e579318..eabf715f 100644 --- a/tools/run-mutants.ps1 +++ b/tools/run-mutants.ps1 @@ -120,6 +120,23 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# The single output sink. Every message this tool emits goes through here, so +# the destination and the formatting stay separable from the call sites that +# produce the content -- the repository's one-output-sink rule. +function Write-Report { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message, + [ValidateSet('info', 'detail', 'note', 'warn')][string] $Level = 'info' + ) + $colour = switch ($Level) { + 'detail' { 'DarkGray' } + 'note' { 'Cyan' } + 'warn' { 'Yellow' } + default { 'Gray' } + } + Write-Host $Message -ForegroundColor $colour +} + $repo = (git rev-parse --show-toplevel).Replace('/', '\') if (-not $OutputDirectory) { $leaf = if ($File) { [System.IO.Path]::GetFileNameWithoutExtension($File) } else { $Package } @@ -145,14 +162,14 @@ if ($previous -eq 1) { # -- the one hole in the restore, since a hard kill runs no cleanup. Say so, # because silently treating it as the user's own preference would restore it # to 1 afterwards and leave the machine permanently changed by a crash. - Write-Host "NOTE: DontShowUI was already 1. If a previous run was killed, clear it after:" -ForegroundColor Yellow - Write-Host " Remove-ItemProperty '$werKey' -Name DontShowUI" -ForegroundColor Yellow + Write-Report "NOTE: DontShowUI was already 1. If a previous run was killed, clear it after:" -Level warn + Write-Report " Remove-ItemProperty '$werKey' -Name DontShowUI" -Level warn } try { if (-not $hadKey) { New-Item -Path $werKey -Force | Out-Null } Set-ItemProperty -Path $werKey -Name DontShowUI -Value 1 -Type DWord - Write-Host "WER dialogs suppressed for this run (DontShowUI=1)." -ForegroundColor Cyan + Write-Report "WER dialogs suppressed for this run (DontShowUI=1)." -Level note $argv = @('mutants', '-p', $Package, '-j', $Jobs, '--output', $OutputDirectory, '--all-features') @@ -164,7 +181,7 @@ try { } if ($File) { $argv += @('--file', $File) } - Write-Host "cargo $($argv -join ' ')" -ForegroundColor DarkGray + Write-Report "cargo $($argv -join ' ')" -Level detail & cargo @argv $code = $LASTEXITCODE } @@ -185,7 +202,7 @@ finally { Remove-ItemProperty -Path $werKey -Name DontShowUI -ErrorAction SilentlyContinue if (-not $hadKey) { Remove-Item -Path $werKey -ErrorAction SilentlyContinue } } - Write-Host "WER dialog setting restored." -ForegroundColor Cyan + Write-Report "WER dialog setting restored." -Level note } $out = Join-Path $OutputDirectory 'mutants.out' @@ -194,7 +211,7 @@ foreach ($name in 'caught', 'missed', 'timeout', 'unviable') { $count = if (Test-Path $path) { (Get-Content $path | Measure-Object -Line).Lines } else { 0 } "{0,-9} {1}" -f $name, $count } -Write-Host "results: $out" -ForegroundColor DarkGray +Write-Report "results: $out" -Level detail # cargo-mutants exits non-zero when anything survived, which is the normal # outcome of an investigative run rather than a failure of this script. diff --git a/tools/run-sabotage.ps1 b/tools/run-sabotage.ps1 index ef518b2a..d9123534 100644 --- a/tools/run-sabotage.ps1 +++ b/tools/run-sabotage.ps1 @@ -114,6 +114,25 @@ $ErrorActionPreference = 'Stop' $utf8NoBom = [System.Text.UTF8Encoding]::new($false) +# The single output sink. Every message this tool emits goes through here, so +# the destination and the formatting stay separable from the call sites that +# produce the content -- the repository's one-output-sink rule. `Exit-WithMessage` +# below is deliberately NOT routed through it: that path writes to stderr and +# then exits, and is the one case where the destination is part of the meaning. +function Write-Report { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message, + [ValidateSet('info', 'note', 'good', 'bad')][string] $Level = 'info' + ) + $colour = switch ($Level) { + 'note' { 'Cyan' } + 'good' { 'Green' } + 'bad' { 'Red' } + default { 'Gray' } + } + Write-Host $Message -ForegroundColor $colour +} + # Writes to stderr and exits with a code, rather than Write-Error, which under # $ErrorActionPreference = 'Stop' raises a terminating error that propagates out # of this script and aborts whatever invoked it. A diagnostic tool reporting a @@ -267,7 +286,7 @@ foreach ($sabotage in $selected) { } } -Write-Host 'Baseline: running the unmodified suite.' -ForegroundColor Cyan +Write-Report 'Baseline: running the unmodified suite.' -Level note $baselinePath = Join-Path $OutputDirectory 'baseline.txt' $baseline = Invoke-Sabotaged -CargoArgs $testArgs -WorkingDirectory $repoRoot ` -TranscriptPath $baselinePath -BuildSeconds $BuildTimeoutSeconds -TestSeconds $TimeoutSeconds @@ -280,7 +299,7 @@ if ($baseline.Outcome -ne 'passed') { "nothing while looking like a clean bill of health. Fix the suite first." ) -join "`n") 2 } -Write-Host 'Baseline is green. Sweeping.' -ForegroundColor Cyan +Write-Report 'Baseline is green. Sweeping.' -Level note '' $results = @() @@ -353,8 +372,8 @@ foreach ($sabotage in $selected) { Actual = $actual; Ok = $ok; Patch = (Format-Patch -Find $find -Replace $replace) } - $colour = if ($ok) { 'Green' } else { 'Red' } - Write-Host ("{0,-58} {1}" -f $sabotage.name, $actual) -ForegroundColor $colour + $level = if ($ok) { 'good' } else { 'bad' } + Write-Report ("{0,-58} {1}" -f $sabotage.name, $actual) -Level $level } '' @@ -362,17 +381,17 @@ $results | Select-Object Sabotage, Expected, Actual, Ok | Format-Table -AutoSize $unexpected = @($results | Where-Object { -not $_.Ok }) if ($unexpected.Count -eq 0) { - Write-Host "All $($results.Count) sabotages behaved as declared." -ForegroundColor Green + Write-Report "All $($results.Count) sabotages behaved as declared." -Level good exit 0 } '' -Write-Host 'UNEXPECTED RESULTS -- read the patch before concluding the tests have a hole.' -ForegroundColor Red -Write-Host 'A sabotage that does not actually break anything will be survived for an honest reason.' -ForegroundColor Red +Write-Report 'UNEXPECTED RESULTS -- read the patch before concluding the tests have a hole.' -Level bad +Write-Report 'A sabotage that does not actually break anything will be survived for an honest reason.' -Level bad foreach ($result in $unexpected) { '' - Write-Host " $($result.Sabotage)" -ForegroundColor Red - Write-Host " expected $($result.Expected), got: $($result.Actual)" + Write-Report " $($result.Sabotage)" -Level bad + Write-Report " expected $($result.Expected), got: $($result.Actual)" $result.Patch } '' From b371b95e5fe84fb5e59d37481ee6488624db21ba Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 19:46:19 -0400 Subject: [PATCH 169/361] fix(ioring): make DROP_RUNS's comment a plain one, not a doc comment rustdoc does not document items produced by a macro invocation, so a doc comment on the thread_local! is an unused_doc_comments warning -- which CI escalates via -D warnings, failing both the clippy job and the no-default-features ioring job. Verified against all three CI clippy invocations with warnings as errors, and against the encoding check's glued-doc-comment guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-ioring-sys/src/ring.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/windows-ioring-sys/src/ring.rs b/crates/windows-ioring-sys/src/ring.rs index 7cfde391..64f5ada5 100644 --- a/crates/windows-ioring-sys/src/ring.rs +++ b/crates/windows-ioring-sys/src/ring.rs @@ -1014,15 +1014,19 @@ impl Drop for IoRing { } } -/// How many times [`IoRing`]'s `Drop` impl has run **on the calling thread**; -/// see its use there. -/// -/// Thread-local rather than a shared `static`, and that is load-bearing rather -/// than tidiness. `cargo test` runs tests as threads in one process, so a -/// process-wide counter is incremented by every other test's rings as they -/// drop -- and an assertion of the form `after > before` is then satisfied by -/// *somebody else's* drop, which is exactly the mutant it was written to catch. -/// A thread-local is only touched by rings dropped on this test's own thread. +// How many times `IoRing`'s `Drop` impl has run **on the calling thread**; see +// its use there. +// +// A plain comment rather than a doc comment: rustdoc does not document items +// produced by a macro invocation, so a doc comment here is an +// `unused_doc_comments` warning, which CI escalates with `-D warnings`. +// +// Thread-local rather than a shared `static`, and that is load-bearing rather +// than tidiness. `cargo test` runs tests as threads in one process, so a +// process-wide counter is incremented by every other test's rings as they +// drop -- and an assertion of the form `after > before` is then satisfied by +// *somebody else's* drop, which is exactly the mutant it was written to catch. +// A thread-local is only touched by rings dropped on this test's own thread. #[cfg(test)] thread_local! { pub(crate) static DROP_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; From 8fdcdcc391b610bfbad717de35db529cd6ad7eeb Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 19:47:41 -0400 Subject: [PATCH 170/361] docs: record why two crates treat paths oppositely, and flag the enumerator While settling D-85 (windows-file-watcher passes a caller's path to Win32 verbatim), it became clear that windows-file-enumeration-sys had independently taken the opposite stance: it resolves ordinary paths, caps them at MAX_PATH so acceptance does not depend on the host's longPathAware manifest, and tells the caller to supply a fully qualified \\?\ path for anything longer. Both are right, and side by side they look like an inconsistency somebody should tidy up -- which is the failure mode. They follow from one question, does this layer ever build a path the caller did not give it, and the answers differ: - A layer that never constructs a path passes the caller's through verbatim, preserving their parsing mode, and lets long-path behaviour follow the host application's manifest, because that is the application's call. The watcher opens one handle per watched directory and lets the kernel do subtree recursion, so it never joins anything. - A layer that will construct paths demands a form it can build on, up front, because Win32 has no relative open and a built path can pass MAX_PATH even when the caller's did not. Discovering that mid-traversal is the worst possible moment, so the contract front-loads it. Recorded once in the workspace design notes, with D-85 and the enumeration crate's D-7 cross-referenced to it in both directions. It also records the conversion rule both crates reached independently: move into \\?\ form from output Win32 has already normalised under the caller's own parsing mode (GetFullPathNameW, GetFinalPathNameByHandleW), never by prefixing the raw string -- after normalisation preserves meaning, before silently reinterprets it. Adds crates/windows-file-enumeration-sys/CHECKLIST.md with REVIEW-1, registered in that crate's PLANS.md. It is a review item and schedules no change: lib.rs says recursive traversal belongs in a separate layer that does not exist yet, so the request path contract has never been exercised by the consumer it was designed for. The questions it asks are whether the deliberate MAX_PATH cap on ordinary paths stays out of the way once descent is real, whether moving into \\?\ form mid-descent is specified for every namespace the crate accepts (UNC needs \\?\UNC\..., and \\.\ device forms take no prefix at all), and whether EnumerationRequest::path()'s resolved form is stated as the intended base rather than left to be inferred. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DESIGN-NOTES.md | 57 +++++++++++++++++++ .../windows-file-enumeration-sys/CHECKLIST.md | 41 +++++++++++++ .../DESIGN-NOTES.md | 2 +- crates/windows-file-enumeration-sys/PLANS.md | 1 + crates/windows-file-watcher/DESIGN-NOTES.md | 2 +- 5 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 crates/windows-file-enumeration-sys/CHECKLIST.md diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index 73c06db9..ea4fe4b5 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -1758,3 +1758,60 @@ threads, so a resident value would be stranded on process-shared infrastructure; thread-local destructors are unreliable on Windows, so a value whose `Drop` must run cannot live there; and submission and completion happen on different threads by construction, so the binding would be absent exactly where completion occurs. + +## A layer's path contract is decided by whether it constructs paths + +Two crates here treat a caller's path in opposite ways, and side by side they look +like an inconsistency somebody should tidy up. They are not. Each follows from one +question -- **does this layer ever build a path the caller did not give it?** -- +and the answer differs, so the contracts differ. This is recorded once, here, +because the failure mode is a well-meant "harmonisation" that breaks whichever +crate loses. + +Some background the rest of this depends on, measured rather than assumed. The +`\\?\` prefix is not a longer-path switch; it selects a **different path parsing +mode**. Under it Win32 stops translating forward slashes, stops resolving `.` and +`..`, stops stripping trailing dots and spaces, stops intercepting reserved device +names, and accepts only fully qualified paths. And whether an *unprefixed* path may +exceed `MAX_PATH` is not the library's to decide: it needs the machine's +`LongPathsEnabled` policy **and** the host executable's `longPathAware` manifest. +Measured on one machine with that policy already enabled, the same probe source +failed a 300-character open with `ERROR_PATH_NOT_FOUND` and succeeded once the +manifest was embedded -- the manifest was the only variable. + +**A layer that never constructs a path passes the caller's through verbatim.** +There is nothing to build, so nothing needs a form that is safe to build on, and +the caller's parsing mode is preserved exactly. Long-path behaviour then varies +with the host application's manifest, which is correct: it is the application's +call, and this kind of layer has no business making it on the application's behalf. +`windows-file-watcher` is this shape -- it opens one handle per watched directory +and lets the kernel do subtree recursion, so it never joins anything. See +[D-85](crates/windows-file-watcher/DESIGN-NOTES.md#d-85). + +**A layer that will construct paths demands a form it can build on, up front.** +Win32 has no relative open, so descending means appending to an absolute path, and +a built path can pass `MAX_PATH` even when the caller's did not. Discovering that +mid-traversal is the worst possible time, so the contract front-loads it: resolve +ordinary inputs once, cap them where behaviour is unambiguous, and tell the caller +to supply a fully qualified `\\?\` path for anything longer -- an actionable +synchronous error rather than a `NotFound` that names nothing. +`windows-file-enumeration-sys` is this shape. See +[D-7](crates/windows-file-enumeration-sys/DESIGN-NOTES.md#d-7) and its +[Request path contract](crates/windows-file-enumeration-sys/DESIGN-NOTES.md#request-path-contract). + +The two also differ on the manifest, and deliberately in opposite directions. +The watcher lets behaviour follow the host, because it only ever hands the caller's +own path back to Win32. The enumerator refuses to: it requires both the input and +the resolved form to fit the ordinary limit specifically **so that acceptance does +not depend on the host executable's manifest**, which keeps one input meaning one +thing in every host that links it. A layer whose results feed further path +construction cannot afford host-dependent acceptance; a layer that builds nothing +can. + +**The conversion rule, shared.** When a layer does need to move a caller's path +into `\\?\` form, it must convert from a form Win32 has *already* normalised under +the caller's own parsing mode -- `GetFullPathNameW`'s output, or +`GetFinalPathNameByHandleW`'s -- never by prefixing the caller's raw string. Doing +it after normalisation preserves what the path meant; doing it before silently +reinterprets it. Both crates arrived at this independently, which is why it is +written down once. \ No newline at end of file diff --git a/crates/windows-file-enumeration-sys/CHECKLIST.md b/crates/windows-file-enumeration-sys/CHECKLIST.md new file mode 100644 index 00000000..bc4b5b00 --- /dev/null +++ b/crates/windows-file-enumeration-sys/CHECKLIST.md @@ -0,0 +1,41 @@ +# Checklist: windows-file-enumeration-sys + +The crate's implementation milestones are M5 through M7 in the workspace +[CHECKLIST.md](../../CHECKLIST.md). This file holds work owned by the crate +itself. Status is tracked in [PLANS.md](PLANS.md). + +## M-inf -- Horizon (ungated) + +- [ ] **REVIEW-1** -- Review the request path contract against a traversal layer, before one is built. + **This is a review item: it schedules no change.** Its output is an answer -- possibly "no change + needed" -- and any work it turns out to imply becomes its own item afterwards. Raised from the + `windows-file-watcher` side while recording + [D-85](../windows-file-watcher/DESIGN-NOTES.md#d-85) and the shared principle in + [the workspace design notes](../../DESIGN-NOTES.md#path-contracts-follow-path-construction); + flagged here because that principle's *building* half lives in this crate and nothing else would + bring it up. + **Context, not conclusions.** [lib.rs](src/lib.rs) states that recursive traversal belongs in a + separate layer composing this one, so that layer does not exist yet and the contract has never been + exercised by the consumer it was designed for. Facts noted while reading, each of which the review + should confirm rather than take on trust: + - [D-7](DESIGN-NOTES.md#d-7) accepts an *ordinary* path only if both the input and the + `GetFullPathNameW`-resolved form fit `MAX_PATH`, deliberately, so acceptance does not depend on + the host executable's `longPathAware` manifest. + - Win32 has no relative open, so descending means appending to an absolute path -- from a path + already at or near that cap. + - The contract's stated remedy for an over-limit path is "supply a fully qualified `\\?\` path", + which for a traversal layer means converting partway down rather than at the caller's boundary. + - `EnumerationRequest::path()` is `pub` and returns the stored resolved form, so a traversal layer + has access to a Win32-normalised base rather than only the caller's original string. + **Questions the review should answer.** Does D-7's contract still hold once descent is a real + consumer, or does the `MAX_PATH` cap on ordinary paths become reachable in normal use? If a + traversal layer must move into `\\?\` form mid-descent, is that conversion specified anywhere, and + is it the same conversion for every namespace the crate accepts -- a UNC path needs `\\?\UNC\...` + rather than a bare prefix, and a `\\.\` device form does not take one at all? Does + manifest-independence still mean what it should for requests a traversal layer *derives* rather than + receives? And is `path()`'s resolved form the intended base for that, stated as such, rather than + something a traversal layer is left to infer? + **The trap worth naming.** Converting *before* Win32 has normalised under the caller's own parsing + mode silently reinterprets the path; converting *after* preserves it. Both crates reached that rule + independently, which is why it is recorded once at the workspace level -- but reaching for the + prefix at the wrong moment is exactly what a traversal layer under length pressure would do. \ No newline at end of file diff --git a/crates/windows-file-enumeration-sys/DESIGN-NOTES.md b/crates/windows-file-enumeration-sys/DESIGN-NOTES.md index 7e4b70a4..b81bf586 100644 --- a/crates/windows-file-enumeration-sys/DESIGN-NOTES.md +++ b/crates/windows-file-enumeration-sys/DESIGN-NOTES.md @@ -21,7 +21,7 @@ making submission, delivery, cancellation, and resource bounds explicit. | D-4 | **Opening a directory uses the submitter's explicitly captured `ImpersonationToken`.** Ordinary begin captures before publishing its SQ message; an explicit-token form lets traversal reuse one captured context. Later refills use the already-open handle and do not impersonate. | | D-5 | **Native values remain native where they express the contract.** Paths and names use `wtf-string` for native-width WTF-16 storage, and Microsoft `windows-sys` value types remain public where no additional crate-owned invariant is required. | | D-6 | **Superseded by D-7 through D-15.** FE-2 closed the v1 public-contract questions that this scaffold deliberately left open. | -| D-7 | **A request owns one NUL-free WTF-16 path snapshot with explicit long-path behavior.** Ordinary path forms are resolved when the request is built and must fit the ordinary Win32 limit; long paths must arrive as fully qualified `\\?\` inputs and remain verbatim. | +| D-7 | **A request owns one NUL-free WTF-16 path snapshot with explicit long-path behavior.** Ordinary path forms are resolved when the request is built and must fit the ordinary Win32 limit; long paths must arrive as fully qualified `\\?\` inputs and remain verbatim. This is the *building* half of a workspace-wide principle -- a layer that constructs paths demands a form it can build on, while one that never constructs any passes the caller's through verbatim (`windows-file-watcher`); see [the workspace design notes](../../DESIGN-NOTES.md#path-contracts-follow-path-construction). | | D-8 | **The CQ has only entry and terminal records, and preserves per-request native order without promising a stable sort.** A failed terminal owns its error; dot entries are never delivered. | | D-9 | **Every inline `FILE_ID_EXTD_DIR_INFO` field with defined consumer meaning is always returned in native units.** Undefined `FileIndex` is omitted; only the separately queried volume serial is selected. | | D-10 | **File identity distinguishes an always-present 128-bit file ID from optional volume qualification.** Requests select omitted, best-effort, or required volume qualification without per-entry opens. | diff --git a/crates/windows-file-enumeration-sys/PLANS.md b/crates/windows-file-enumeration-sys/PLANS.md index 20056367..de0dd9a7 100644 --- a/crates/windows-file-enumeration-sys/PLANS.md +++ b/crates/windows-file-enumeration-sys/PLANS.md @@ -8,3 +8,4 @@ The crate's implementation is M5 through M7 in the workspace | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| +| [CHECKLIST.md](CHECKLIST.md) | not started | REVIEW-1: review the request path contract against a traversal layer before one is built -- whether the deliberate `MAX_PATH` cap on ordinary paths survives descent, and whether moving into `\\?\` form mid-descent is specified for every namespace the crate accepts. Raised from the `windows-file-watcher` side; schedules no change of its own. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [workspace DESIGN-NOTES.md](../../DESIGN-NOTES.md#path-contracts-follow-path-construction) | diff --git a/crates/windows-file-watcher/DESIGN-NOTES.md b/crates/windows-file-watcher/DESIGN-NOTES.md index 8eba6698..de9579d7 100644 --- a/crates/windows-file-watcher/DESIGN-NOTES.md +++ b/crates/windows-file-watcher/DESIGN-NOTES.md @@ -103,7 +103,7 @@ threads of its own. | D-82 | **Everything a consumer needs but cannot otherwise reach is exposed behind an off-by-default `test-util` feature, not on the unconditional public surface: the feedable channel (`channel_with_bound` with `Sender`/`Delivery`/`Reservation`, previously `pub` only inside a private module) and valid-by-construction builders for the two unconstructible boundary types (`RelativeName`, `VolumeIdentity`).** This does not reverse [D-64](DESIGN-RATIONALE.md#the-m64-test-seam-is-a-private-constructor-not-a-public-feature-flag-d-64): D-64's seams serve the crate's own tests reaching internal state, for which `#[cfg(test)]`/`pub(crate)` is strictly better; this seam serves a downstream consumer's tests, which `#[cfg(test)]` cannot reach at all, and it exposes the delivery channel and public boundary constructors rather than internal state (so the retired `unstable-internals` objection does not apply). Feature-gating keeps the crate's internal queue sender, and identity/name construction, out of the production API. See [Consumer test surface](#consumer-test-surface). | | D-83 | **The consumer test surface tests the consumer's reactions, not whether this crate would ever emit a given sequence.** Builders are valid-by-construction in the type-safety sense (memory-safe, lossless), not production-domain-validating: a `RelativeName` can still carry a unit sequence the kernel itself never reports (an interior NUL, say), and an impossible ordering or an impossible relationship between two otherwise valid values (a `VolumeChanged` with equal `previous`/`current` serials, each individually a legal `VolumeIdentity`) both remain the consumer's responsibility, as with any hand-fed test double. This fidelity limit is documented on the surface so a passing handler test is not mistaken for confirmation that the crate produces that traffic. See [Consumer test surface](#consumer-test-surface). | | D-84 | **The delivery contract was under-specified, and a second implementation of it -- not a test suite -- is what proved that.** PR #42's example harness promised contract-legal schedules only (its own D-5), which made its generator a second implementation of *this* crate's contract. Converging it took **19 automated review rounds**: eight fixed generated sequences this crate could never emit, five corrected the contract prose itself, and one found a real shipped reliability defect ([`has_room`](#the-has_room-finding-in-this-crate)) on [D-29](#d-29)'s backpressure path. All 278 of this crate's own tests passed throughout and were never going to fail -- they assert what the watcher *does*, and every gap was in what the contract *permits*. The gap categories are workspace-wide and recorded once, in [the workspace design notes](../../DESIGN-NOTES.md#specifying-a-delivery-contract); the decisions amended in response were [D-9](#d-9) (renames never joined), [D-12](#d-12)/[D-30](#d-30) (branch and terminal paths), [D-17](#d-17) (per-tier emission legality), [D-27](#d-27)/[D-28](#d-28) (a fault question is unconditional, and enters as `Arm`), [D-50](#d-50)/[D-78](#d-78) (volume identity: distinct serials, and continuity across reopens), and [D-83](#d-83) (fidelity is type-safety, not production-domain). See [What the second implementation exposed](#what-the-second-implementation-exposed). | -| D-85 | **A caller's path is passed to Win32 verbatim: this crate never adds a `\\?\` prefix, and whether a path longer than `MAX_PATH` opens is the consuming application's decision, not this crate's.** `\\?\` is not a longer-path switch, it is a *different parsing mode*, and adopting it on a caller's behalf silently changes what their path means -- measured, on short paths that open fine today: forward slashes fail with `ERROR_FILE_NOT_FOUND`, and a trailing `.` or an interior `..` fail with `ERROR_INVALID_NAME`. Relative paths would stop resolving entirely, which this crate supports on purpose (`open_file_target` normalises a bare leaf's empty parent to `.`). Long paths *without* the prefix are gated on the machine's `LongPathsEnabled` policy **and** the application's `longPathAware` manifest -- measured: the same source, on the same machine, opens a 300-character path only once that manifest is present. A library cannot set its consumer's manifest, so a caller who wants long-path behaviour either opts in at the application level or passes an explicitly `\\?\`-prefixed path, and both work here because the path is passed through untouched. See [Paths are the caller's, verbatim](#paths-are-the-callers-verbatim). | +| D-85 | **A caller's path is passed to Win32 verbatim: this crate never adds a `\\?\` prefix, and whether a path longer than `MAX_PATH` opens is the consuming application's decision, not this crate's.** `\\?\` is not a longer-path switch, it is a *different parsing mode*, and adopting it on a caller's behalf silently changes what their path means -- measured, on short paths that open fine today: forward slashes fail with `ERROR_FILE_NOT_FOUND`, and a trailing `.` or an interior `..` fail with `ERROR_INVALID_NAME`. Relative paths would stop resolving entirely, which this crate supports on purpose (`open_file_target` normalises a bare leaf's empty parent to `.`). Long paths *without* the prefix are gated on the machine's `LongPathsEnabled` policy **and** the application's `longPathAware` manifest -- measured: the same source, on the same machine, opens a 300-character path only once that manifest is present. A library cannot set its consumer's manifest, so a caller who wants long-path behaviour either opts in at the application level or passes an explicitly `\\?\`-prefixed path, and both work here because the path is passed through untouched. The complementary case -- a layer that *does* build paths, and so must demand a form it can build on -- is `windows-file-enumeration-sys`; the shared principle is recorded once in [the workspace design notes](../../DESIGN-NOTES.md#path-contracts-follow-path-construction). See [Paths are the caller's, verbatim](#paths-are-the-callers-verbatim). | ### Queue mediation From a4db985f98c4d0151394f6163f673972097709af Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 19:54:44 -0400 Subject: [PATCH 171/361] test(file-watcher): guard D-85's path pass-through against a helpful prefix D-85 says a caller's path reaches Win32 verbatim. Nothing enforced it, and the change that would break it -- prepending `\\?\` so longer paths work -- is one a future contributor could make in good faith, because it looks like a pure improvement. Five guards now sit in a labelled `directory::tests` section: forward slashes, a `.` component, a `..` component, and a caller's own `\\?\` path, plus `opens_the_current_directory_by_relative_path`, which already existed and turned out to be load-bearing for the same reason. Its comment now says so, so it is not simplified away by someone who does not know. Each asserts the resolved *identity* rather than `is_ok()`. A path that opens the wrong directory is the failure worth catching, and `..` is where that bites: under an ordinary parse it must land on the parent, and a test that only checked "something opened" would accept a handle on the child. The measurement that justifies writing them at all: with a blanket prefix injected into `wide_path`, exactly those five fail and the other 33 tests in the module pass. A helpful prefix would otherwise land looking entirely green. Two things that run settled rather than assumed. A trailing separator survives the prefix, so it is not a distinguishing case and was left out. And the guards are precise -- they fail on the prefix specifically, not on incidental path handling. Deliberately absent, and stated in the section header so the omission reads as a decision: a long-path test. A Rust test binary has no longPathAware manifest, so a MAX_PATH-exceeding open fails in-suite regardless of machine policy, and asserting that would pin the harness rather than the crate. M15.10 covers the part that can be tested. Completed item: M15.9: Guard D-85's pass-through with tests, so a future "helpful" `\\?\` prefix fails the suite instead of silently changing what callers' paths mean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 13 +- .../COMPLETED-CHECKLIST.md | 36 +++++- crates/windows-file-watcher/DESIGN-NOTES.md | 9 ++ .../src/directory/tests.rs | 113 ++++++++++++++++++ 4 files changed, 158 insertions(+), 13 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index eb4287d4..a483e73c 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -141,18 +141,7 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- - [x] **M15.3** -- Decided: a caller's path goes to Win32 verbatim, and long-path support is the consuming application's call, not this crate's (D-85). The proposed `\\?\` prefix was measured to break forward slashes, `.`, `..` and relative paths that work today. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m153) -- [ ] **M15.9** -- Guard D-85's pass-through with tests, so a future "helpful" `\\?\` prefix fails the - suite instead of silently changing what callers' paths mean. **Deliberately scoped out of M15.3, which - recorded the decision only.** - **What to pin, all measured against a short directory that opens fine today** -- each of these would - break under a blanket prefix, which is exactly why they are the guard: `C:/Users/.../dir` (forward - slashes) becomes `ERROR_FILE_NOT_FOUND`; `...\dir\.` and `...\dir\subdir\..` become - `ERROR_INVALID_NAME`; a bare relative leaf stops resolving at all. - **And the other direction:** a caller's own `\\?\`-prefixed path must arrive intact and open, since - that is one of the two routes D-85 leaves a caller who wants long-path behaviour. - **Not** a long-path test: a Rust test binary has no `longPathAware` manifest, so a >`MAX_PATH` open - fails in-suite regardless of machine policy. Asserting that failure would pin the harness, not the - crate -- see M15.10 for the part that can be tested. +- [x] **M15.9** -- Guarded D-85's pass-through with five identity-asserting tests. Measured worth: with a blanket prefix injected into `wide_path`, exactly those five fail and the other 33 in the module pass. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m159) - [ ] **M15.10** -- Test `canonical_path`'s 512-unit retry through the junction back door. **The back door is confirmed to work, so this is now a fixture to build rather than a question to answer.** diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index 0b7e4126..10d723e4 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -752,4 +752,38 @@ verbatim and only ever reopened, never matched against a canonical path. **Deferred deliberately, as work items rather than prose:** M15.9 (guard tests pinning the pass-through, so a future "helpful" prefix fails the suite) and M15.10 (the junction fixture for the 512-unit retry -- the back door M15.3 called hypothetical is confirmed to work, no elevation needed, a 53-character junction -resolving to a 578-character target). \ No newline at end of file +resolving to a 578-character target). +## Moved 2026-09-01 -- M15.9: guarding D-85's pass-through + +### M15.9 -- Guard D-85's pass-through with tests, so a future "helpful" `\\?\` prefix fails the suite instead of silently changing what callers' paths mean. *(completed 2026-09-01 20:05:00 -04:00)* + +**Five guards, in a labelled `directory::tests` section.** Four new -- forward slashes, a `.` component, +a `..` component, and a caller's own `\\?\` path -- plus `opens_the_current_directory_by_relative_path`, +which already existed and turned out to be load-bearing for the same reason; its comment now says so, so +it is not simplified away by someone who does not know. + +**Each asserts the resolved identity, not `is_ok()`.** A path that opens the *wrong* directory is the +failure worth catching, and the `..` case is the one where that matters concretely: under an ordinary +parse it must land on the parent, and a test that only checked "something opened" would accept a handle +on the child. + +**The measurement that justifies the item.** With a blanket prefix injected into `wide_path`, **exactly +those five fail and the other 33 tests in the module pass.** So a "helpful" prefix would otherwise land +looking entirely green -- the existing suite does not constrain this at all, which is precisely why the +guards had to be written rather than assumed. + +**Two facts settled by that run, rather than by reasoning.** A trailing separator *survives* the prefix +(`opens_a_directory_with_a_trailing_separator` passed), so it is not a distinguishing case and was left +out. And the guards are precise rather than broad: they fail on the prefix specifically, not on +incidental path handling. + +**Deliberately absent: a long-path test.** A Rust test binary has no `longPathAware` manifest, so a +`MAX_PATH`-exceeding open fails in-suite regardless of machine policy; asserting that would pin the +harness rather than the crate. That is stated in the section header so the omission reads as a decision +rather than an oversight. M15.10 covers the part that *can* be tested. + +**A self-inflicted detour worth recording.** The first injection was written through PowerShell and +over-escaped -- eight backslashes reached Rust where four were needed -- so every path became invalid and +23 tests failed instead of 5. The over-broad result is what exposed it; a subtler mis-escape would have +read as a real finding. Redone with a direct file edit and a Rust raw string, which removes the escaping +layer entirely. This is the same multi-layer escaping trap recorded earlier in this sweep. \ No newline at end of file diff --git a/crates/windows-file-watcher/DESIGN-NOTES.md b/crates/windows-file-watcher/DESIGN-NOTES.md index de9579d7..bfe8423f 100644 --- a/crates/windows-file-watcher/DESIGN-NOTES.md +++ b/crates/windows-file-watcher/DESIGN-NOTES.md @@ -899,6 +899,15 @@ Relative paths would stop working entirely, and this crate supports them on purpose -- `open_file_target` normalises a bare leaf's empty `parent()` to `.` precisely so `subscribe("target.txt", ...)` resolves. +Each of those is pinned by a test in `directory::tests` (the "D-85" section), +asserting the resolved *identity* rather than merely that something opened, plus +one in the other direction: a caller's own `\\?\` path must arrive intact and +reach the same directory, which is what makes "we never add the prefix" a +complete contract rather than a refusal. The measurement that says those guards +are worth having: with a blanket prefix injected into `wide_path`, exactly those +five tests fail and the other **33** in the module pass. A "helpful" prefix would +otherwise land looking entirely green. + **Long paths are the application's call, not the library's.** Opening past `MAX_PATH` without the prefix requires *both* the machine's `LongPathsEnabled` policy and the application's own `longPathAware` manifest. diff --git a/crates/windows-file-watcher/src/directory/tests.rs b/crates/windows-file-watcher/src/directory/tests.rs index 933306d3..cbbc966a 100644 --- a/crates/windows-file-watcher/src/directory/tests.rs +++ b/crates/windows-file-watcher/src/directory/tests.rs @@ -57,6 +57,9 @@ fn opens_the_system_temp_directory() { #[test] fn opens_the_current_directory_by_relative_path() { + // Also a D-85 guard: a relative path cannot survive a `\\?\` prefix, which + // accepts only fully qualified paths. See the section at the end of this + // file for the rest of the pass-through guards. assert!( DirectoryHandle::open(Path::new(".")).is_ok(), "a relative path must be accepted; Win32 resolves it" @@ -531,3 +534,113 @@ fn case_sensitivity_is_read_from_the_directory_rather_than_assumed() { sensitive -- this is the half a hard-coded `false` passes" ); } + +// --- D-85: the caller's path reaches Win32 verbatim --- +// +// These exist to fail if this crate ever "helpfully" prepends `\\?\`. That +// prefix is a different path *parsing mode*, not a longer-path switch, so +// adopting it on a caller's behalf silently changes what their path means -- +// and it does so on paths that have nothing to do with `MAX_PATH`, which is +// what makes the change easy to justify to oneself and hard to notice. +// +// Each asserts on the resolved *identity*, not merely that something opened: a +// path that resolves to the wrong directory is the failure mode worth catching, +// and `is_ok()` would not see it. +// +// Deliberately absent: a test that a path longer than `MAX_PATH` opens. That +// depends on the host executable's `longPathAware` manifest, which a Rust test +// binary does not have, so such a test would pin the harness rather than this +// crate. `opens_the_current_directory_by_relative_path` above is part of this +// set -- a relative path cannot survive the prefix either. + +/// The identity of a directory opened by its plain absolute path, to compare a +/// differently-spelled route to the same directory against. +fn identity_of(path: &Path) -> super::DirectoryId { + DirectoryHandle::open(path) + .expect("the plain absolute path must open") + .identity() +} + +#[test] +fn forward_slashes_resolve_to_the_same_directory() { + // Win32 translates `/` to `\` during ordinary parsing. Under `\\?\` it does + // not, and this path would fail with `ERROR_FILE_NOT_FOUND`. + let dir = TempDir::new("verbatim-slashes"); + let expected = identity_of(dir.path()); + + let with_slashes = dir.path().to_string_lossy().replace('\\', "/"); + let handle = DirectoryHandle::open(Path::new(&with_slashes)) + .expect("a forward-slash spelling must open, because Win32 translates it"); + assert_eq!( + handle.identity(), + expected, + "the forward-slash spelling must reach the same directory" + ); + + drop(handle); + dir.cleanup(); +} + +#[test] +fn a_dot_component_resolves_to_the_same_directory() { + // Win32 resolves `.` during ordinary parsing. Under `\\?\` it is a literal + // component and this fails with `ERROR_INVALID_NAME`. + let dir = TempDir::new("verbatim-dot"); + let expected = identity_of(dir.path()); + + let with_dot = dir.path().join("."); + let handle = DirectoryHandle::open(&with_dot) + .expect("a `.` component must open, because Win32 resolves it"); + assert_eq!( + handle.identity(), + expected, + "`.` must resolve to the directory itself" + ); + + drop(handle); + dir.cleanup(); +} + +#[test] +fn a_dot_dot_component_resolves_to_the_parent() { + // As above for `..`, and this one proves the component was *resolved* + // rather than merely tolerated: the handle must land on the parent. + let dir = TempDir::new("verbatim-dotdot"); + let child = dir.path().join("child"); + std::fs::create_dir(&child).expect("create the child directory"); + let expected = identity_of(dir.path()); + + let up_again = child.join(".."); + let handle = DirectoryHandle::open(&up_again) + .expect("a `..` component must open, because Win32 resolves it"); + assert_eq!( + handle.identity(), + expected, + "`..` must resolve back to the parent, not open the child" + ); + + drop(handle); + dir.cleanup(); +} + +#[test] +fn a_caller_supplied_verbatim_prefix_is_forwarded_and_honoured() { + // The other direction of D-85, and the route a caller takes when they want + // extended-length or verbatim semantics: their own `\\?\` path must arrive + // intact. This is what makes "we never add the prefix" a complete contract + // rather than a refusal. + let dir = TempDir::new("verbatim-prefixed"); + let expected = identity_of(dir.path()); + + let prefixed = format!(r"\\?\{}", dir.path().display()); + let handle = DirectoryHandle::open(Path::new(&prefixed)) + .expect("a caller's own `\\?\\` path must be forwarded unchanged and open"); + assert_eq!( + handle.identity(), + expected, + "the verbatim spelling must reach the same directory" + ); + + drop(handle); + dir.cleanup(); +} From e7b3c4fd292c8c6ea604cd402678ee82c567723b Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 20:03:07 -0400 Subject: [PATCH 172/361] test(file-watcher): cover canonical_path's regrow, and settle its two mutants The item's premise was wrong, and checking it first saved the whole fixture. M15.10 -- and M15.3 before it -- held that the 512-unit retry was unreachable through this crate's API, so a junction pointing at a deep target was the only way in. But D-85's pass-through means a caller's own \\?\ path opens past MAX_PATH without the host carrying a longPathAware manifest, which M15.3's own probe had already shown. So `open` on a long \\?\ path reaches the retry directly: no junction, no reparse-point plumbing, no spawned mklink, no question about elevation. Two tests. One drives a ~560-unit path through `open` and asserts the grown buffer carries the whole path rather than a truncated one. The second walks every length from 508 to 516 units, sizing each fixture exactly by padding its final component, so the boundary itself is pinned: 511 units is the last that fits one call and 512 the first that needs the regrow. The branch really was uncovered: making the regrow return an error fails exactly one test and passes 38. Two of M15.3's claims are corrected by measurement. Its `<` -> `>` mutant does not loop forever; it is caught and fails fast. And `<` -> `<=` is not a gap but an equivalent mutant -- Win32's two-call convention makes `written == buffer.len()` unreachable, since success returns the length excluding the NUL and needs room for it, while a too-small buffer returns the length including it. Confirmed rather than argued: an assert_ne! probe never fired across the whole suite, including the 508-516 walk that straddles the buffer size exactly. That reasoning is now a comment at the comparison so the next sweep does not re-litigate it. Completed item: M15.10: Test `canonical_path`'s 512-unit retry through the junction back door. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 11 +-- .../COMPLETED-CHECKLIST.md | 29 +++++- crates/windows-file-watcher/src/directory.rs | 8 ++ .../src/directory/tests.rs | 97 +++++++++++++++++++ 4 files changed, 134 insertions(+), 11 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index a483e73c..f919a8b4 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -143,16 +143,7 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- - [x] **M15.9** -- Guarded D-85's pass-through with five identity-asserting tests. Measured worth: with a blanket prefix injected into `wide_path`, exactly those five fail and the other 33 in the module pass. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m159) -- [ ] **M15.10** -- Test `canonical_path`'s 512-unit retry through the junction back door. **The back - door is confirmed to work, so this is now a fixture to build rather than a question to answer.** - **Measured:** `mklink /J` needs no elevation, and a **53-character** junction path resolving to a - **578-character** target is enough -- `GetFinalPathNameByHandleW` returns the resolved target, so - `open` only ever sees the short path while `canonical_path` must grow its buffer. Setup creates the - deep target through an explicitly `\\?\`-prefixed string, so the fixture does not depend on any - library prefixing on its behalf. - **Why it is worth doing:** the retry is the last untested branch in `canonical_path`, and its `<` -> - `>` mutant does not merely fail, it **loops forever** -- a defect that would surface as a hung suite - rather than a red test. `<` -> `<=` currently survives (verified by injection in M15.8). +- [x] **M15.10** -- Tested `canonical_path`'s 512-unit retry. No junction needed: a caller's own `\\?\` path opens past `MAX_PATH` (D-85), so the retry is reachable through the crate's own API. Added a boundary walk over 508-516 units; `<=` proved equivalent by measurement. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m1510) - [ ] **M15.4** -- Isolate the last two notification-filter categories, or record that they cannot be isolated from outside. Two mutants in `ALL_NOTIFY_FILTERS` survive: replacing the `|` before diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index 10d723e4..f654ec30 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -786,4 +786,31 @@ rather than an oversight. M15.10 covers the part that *can* be tested. over-escaped -- eight backslashes reached Rust where four were needed -- so every path became invalid and 23 tests failed instead of 5. The over-broad result is what exposed it; a subtler mis-escape would have read as a real finding. Redone with a direct file edit and a Rust raw string, which removes the escaping -layer entirely. This is the same multi-layer escaping trap recorded earlier in this sweep. \ No newline at end of file +layer entirely. This is the same multi-layer escaping trap recorded earlier in this sweep. +## Moved 2026-09-01 -- M15.10: covering canonical_path's regrow + +### M15.10 -- Test `canonical_path`'s 512-unit retry through the junction back door. *(completed 2026-09-01 20:25:00 -04:00)* + +**The item's own premise was wrong, and checking it first saved the whole fixture.** M15.10 (and M15.3 +before it) held that the retry was unreachable through the crate's API, so a junction pointing at a deep +target was the only way in. But D-85's pass-through means a caller's own `\\?\` path opens past +`MAX_PATH` *without* the host carrying a `longPathAware` manifest -- which M15.3's own probe had already +shown. So `DirectoryHandle::open` on a long `\\?\` path reaches the retry directly: no junction, no +reparse-point plumbing, no spawned `mklink`, and no question about elevation. + +**Two tests.** One drives a ~560-unit path through `open` and asserts the grown buffer carries the whole +path rather than a truncated one. The second walks **every length from 508 to 516 units**, sizing each +fixture exactly by padding its final component, so the boundary itself is pinned: 511 units is the last +that fits one call and 512 the first that needs the regrow, and an off-by-one on either side would leave +a path truncated or looping. + +**The branch really was uncovered.** Verified by making the regrow return an error: **exactly one test +failed and 38 passed.** Nothing else in the module reaches that code. + +**Two claims from M15.3 corrected by measurement.** Its `<` -> `>` mutant does *not* loop forever -- it +is caught and fails fast. And `<` -> `<=` is not a gap but a genuinely **equivalent** mutant: Win32's +two-call convention makes `written == buffer.len()` unreachable, because success returns the length +excluding the NUL (needing room for it) while a too-small buffer returns the length including it. That +was confirmed rather than argued -- an `assert_ne!` probe never fired across the whole suite, including +the 508-516 walk that straddles the buffer size exactly. The reasoning is now a comment at the +comparison, so the next sweep does not re-litigate it. \ No newline at end of file diff --git a/crates/windows-file-watcher/src/directory.rs b/crates/windows-file-watcher/src/directory.rs index 066ee571..7635d27c 100644 --- a/crates/windows-file-watcher/src/directory.rs +++ b/crates/windows-file-watcher/src/directory.rs @@ -640,6 +640,14 @@ fn canonical_path(handle: HANDLE) -> Result { return Err(OpenError::new(classify(&source), source)); } let written = written as usize; + // Win32's two-call convention makes `written == buffer.len()` unreachable, + // which is why this is `<` and why a `<=` here would be equivalent rather + // than wrong: on success the call returns the length *excluding* the NUL + // and needs room for it, so `written < buffer.len()`; when the buffer is + // too small it returns the length *including* the NUL, which by + // definition exceeds `buffer.len()`. Confirmed empirically -- an + // `assert_ne!` here never fired across the suite, including a walk of + // every path length from 508 to 516 units. if written < buffer.len() { buffer.truncate(written); return Ok(PathBuf::from(std::ffi::OsString::from_wide(&buffer))); diff --git a/crates/windows-file-watcher/src/directory/tests.rs b/crates/windows-file-watcher/src/directory/tests.rs index cbbc966a..e8ed596b 100644 --- a/crates/windows-file-watcher/src/directory/tests.rs +++ b/crates/windows-file-watcher/src/directory/tests.rs @@ -644,3 +644,100 @@ fn a_caller_supplied_verbatim_prefix_is_forwarded_and_honoured() { drop(handle); dir.cleanup(); } + +#[test] +fn canonical_path_grows_its_buffer_when_the_path_does_not_fit() { + // `canonical_path` sizes a 512-unit buffer and retries on the documented + // two-call convention. Reaching that retry needs a resolved path of 512+ + // units, which needs a directory deeper than `MAX_PATH` -- and D-85's + // pass-through is what makes that openable without the host executable + // carrying a `longPathAware` manifest: the caller's own `\\?\` path is + // forwarded unchanged, and Win32 honours it. + // + // This corrects the note that opened M15.10: the retry is reachable through + // this crate's own API, so it needs no junction/reparse-point fixture and no + // spawned `mklink`. + use std::os::windows::ffi::OsStrExt; + + let dir = TempDir::new("canonical-long"); + let mut deep = dir.path().to_path_buf(); + while format!(r"\\?\{}", deep.display()).len() < 560 { + deep.push("segment-0123456789abcdef"); + } + // Created through an explicitly prefixed string, so the fixture does not + // depend on any library prefixing on its behalf. + let prefixed = format!(r"\\?\{}", deep.display()); + std::fs::create_dir_all(&prefixed).expect("create the deep directory"); + + let handle = DirectoryHandle::open(Path::new(&prefixed)) + .expect("a caller's own `\\?\\` path opens past MAX_PATH (D-85)"); + let reported = handle.canonical_path().expect("canonical path"); + + let units = reported.as_os_str().encode_wide().count(); + assert!( + units > 512, + "the fixture must actually overflow the first buffer, got {units} units" + ); + assert_eq!( + reported, + std::fs::canonicalize(&prefixed).expect("std canonicalize"), + "the grown buffer must carry the whole path, not a truncated one" + ); + + drop(handle); + let _ = std::fs::remove_dir_all(&prefixed); + dir.cleanup(); +} +/// A directory whose `\\?\` spelling is exactly `target` UTF-16 units, built by +/// padding the final component. Components stay well under the 255-unit limit. +fn deep_dir_of_prefixed_len(base: &Path, target: usize) -> PathBuf { + let mut path = base.to_path_buf(); + loop { + let current = format!(r"\\?\{}", path.display()).len(); + assert!( + current + 2 <= target, + "the base path is already too long to hit {target}" + ); + let remaining = target - current - 1; + if remaining <= 200 { + path.push("x".repeat(remaining)); + return path; + } + path.push("x".repeat(200)); + } +} + +#[test] +fn canonical_path_is_exact_on_both_sides_of_its_first_buffer() { + // The first buffer is 512 units and the two-call convention's success test + // is `written < buffer.len()`, so 511 units is the last length that fits in + // one call and 512 is the first that needs the regrow. Walking both sides + // pins that boundary: an off-by-one in either the success test or the + // regrow would leave one of these lengths truncated or looping. + use std::os::windows::ffi::OsStrExt; + + let dir = TempDir::new("canon-bound"); + for target in 508..=516 { + let deep = deep_dir_of_prefixed_len(dir.path(), target); + let prefixed = format!(r"\\?\{}", deep.display()); + assert_eq!(prefixed.len(), target, "the fixture must be exactly sized"); + std::fs::create_dir_all(&prefixed).expect("create the sized directory"); + + let handle = DirectoryHandle::open(Path::new(&prefixed)).expect("open the sized directory"); + let reported = handle.canonical_path().expect("canonical path"); + assert_eq!( + reported.as_os_str().encode_wide().count(), + target, + "a {target}-unit path must be reported whole" + ); + assert_eq!( + reported, + std::fs::canonicalize(&prefixed).expect("std canonicalize"), + "a {target}-unit path must be reported correctly" + ); + + drop(handle); + let _ = std::fs::remove_dir_all(&prefixed); + } + dir.cleanup(); +} From 052efa3a606c325e7384fd99dec000372dcefe88 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 20:15:45 -0400 Subject: [PATCH 173/361] fix(placement-probe): repair the PR #56 suppressed review findings Completed items: SH-8.1, SH-8.2, SH-8.3 These three arrived as *suppressed* comments in two review bodies rather than as inline threads, so none of them was resolvable and none would have shown up in a "are all comments resolved?" check. Each was verified against the source, and each repair is sabotage-verified: reverting it turns the suite red. Completed item: SH-8.1: all five timed runs in the contention probe started the clock before thread::scope spawned anything, and every worker began pushing the moment it was created. At 50,000 pushes each, an early producer could finish a long uncontended prefix -- or finish outright -- while the last threads were still being spawned, so a row labelled 16 or 32 producers need never have had that many contenders, and the interval included thread-creation cost. Every participant now waits on a start barrier, the consumer included in the drained runs, and the clock starts as the barrier releases. This matters because the module's own header says these numbers decide whether two speculative queue shapes get written and whether the two shipped shapes merge. Completed item: SH-8.2: write_backup_to_new_file reserved the name with create_new and then wrote into it, so a write failing part-way left a truncated .json under the name a complete record would have had -- indistinguishable to a collector, and stepped around by the next run's collision suffix. The bytes are now published by rename: written to an exclusively-created temporary beside the reservation, flushed, and moved onto it only once the write succeeded. A reader sees the final name either absent or complete. A test seam makes the post-reservation write failure reachable, since no test can fill a disk. Completed item: SH-8.3: places_from_topology iterated class_of, which only core domains populate, so an online processor with no core domain was silently absent from the result -- and the documented core-id fallback beneath it, written to keep group 1's cpu5 distinct from group 0's, was unreachable as a direct consequence. It then defaulted absent NUMA membership to 0, which is right only when the topology names no memory domain at all; where the real nodes are 1 and 2 it fabricated node 0, the precise failure this crate's own seam rule forbids. It now iterates the online processors and refuses a topology that names memory domains but not this processor's, so the conversion is fallible rather than inventive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 38 ++++ .../src/bin/placement_probe/main.rs | 73 +++++++- .../src/bin/placement_probe/tests.rs | 70 +++++++ .../src/fingerprint.rs | 87 +++++++-- .../src/fingerprint/tests.rs | 173 ++++++++++++++++-- .../src/queue_contention.rs | 72 +++++++- 6 files changed, 476 insertions(+), 37 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 3a84978b..2ab67105 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -394,3 +394,41 @@ repair from a reviewer's guess that was taken on trust. finding that was checked and found not to hold: `GetSystemDirectoryW` returning exactly the buffer length is unreachable (success excludes the terminator, failure includes it and so exceeds the buffer), though the guard is widened anyway so the next reader need not redo the analysis. + +## M8: PR #56 third review round (suppressed findings) + +The reviewer generated no new inline comments in these rounds and instead listed **suppressed** findings in +the review body, so none of them arrived as a resolvable thread. They are recorded here because a finding +that produces no thread is otherwise invisible to the "are all comments resolved?" check that gates merge. + +- [x] **SH-8.1** -- **The contention probe times thread creation, and lets early producers run alone.** + All five timed runs in `windows-platform-probes`'s `queue_contention` start the clock *before* + `thread::scope` spawns anything, and every worker begins pushing the moment it is spawned. At 50,000 + pushes each, an early producer can finish a large uncontended prefix -- or finish outright -- while the + last threads are still being created, so a row labelled 16 or 32 producers may never have had 16 or 32 + contenders. The measured interval also includes spawn cost. This is not a cosmetic inaccuracy: the + module's own header says these numbers decide whether two speculative queue shapes get written at all + and whether the two shipped shapes merge. Hold every participant -- producers *and*, in the drained + runs, the consumer -- at a start barrier, and start the clock when it releases. + +- [x] **SH-8.2** -- **A failed backup write leaves a truncated file under the canonical name.** + `write_backup_to_new_file` reserves the name with `create_new` and then `write_all`s through `?`, so a + disk-full or quota failure returns an error while leaving a zero-length or partial `.json` behind. That + file is indistinguishable from a real record to whoever collects it, and the next run's collision + suffix steps politely around it. Publish by rename: write the bytes to an exclusively-created temporary + in the same directory, flush, and move it onto the reserved name only once the write has succeeded. + +- [x] **SH-8.3** -- **`places_from_topology` drops processors and invents NUMA membership.** + Two defects in one conversion, both reachable only through a hand-built or deserialized `Topology` -- + which is exactly the input this seam exists to accept (D-12). + It iterates `class_of`, which is populated only from `DomainKind::Core` domains, so an online processor + with no core domain is **silently absent from the result** -- and the documented core-id fallback + beneath it, written to keep group 1's cpu5 distinct from group 0's, is unreachable dead code as a + direct consequence. + It then defaults absent NUMA membership to `unwrap_or(0)`. That is the right answer only when the + topology names no memory domain at all; when it names nodes 1 and 2, it **fabricates node 0** and files + a processor under a node the machine does not have -- the precise failure this crate's own rule + ("a seam that only moves data is safe; a seam that lets fabricated labels reach real hardware is not") + exists to prevent. + Iterate the online processors so every one is placed, and refuse a topology that names memory domains + but not this processor's, rather than inventing one. diff --git a/crates/windows-placement-probe/src/bin/placement_probe/main.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs index 45021128..dd8c491b 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/main.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -237,7 +237,34 @@ fn write_backup(record: &SubmissionRecord) { /// Exclusive creation makes the collision visible instead of silent, and a /// suffix resolves it. The suffix is only reached on a real collision, so the /// ordinary name stays the predictable one. +/// +/// **The bytes are published by rename, and that is a second correction.** +/// Reserving the name and then writing into it means a failure part-way through +/// the write -- a full disk, a quota, a killed process -- leaves a truncated +/// `.json` sitting under the name a *complete* record would have. Nothing +/// downstream can tell the two apart: whoever collects the file sees a record, +/// and the next run's collision suffix steps politely around the wreckage. So +/// the reserved name is a placeholder, the content is written to a temporary +/// beside it, and the temporary is moved onto the reservation only once the +/// write has succeeded. A reader therefore sees the final name either absent or +/// complete, never half-written. fn write_backup_to_new_file(name: &str, json: &str) -> std::io::Result { + write_backup_with(name, json, |file, bytes| { + std::io::Write::write_all(file, bytes) + }) +} + +/// The body of [`write_backup_to_new_file`], with the write itself injectable. +/// +/// The failure this guards is a write that fails *after* the name is taken, and +/// no test can provoke that by filling the disk. The seam is the smallest thing +/// that makes it reachable: a test supplies a writer that fails, and asserts +/// that nothing is left behind under either name. +fn write_backup_with( + name: &str, + json: &str, + mut write: impl FnMut(&mut std::fs::File, &[u8]) -> std::io::Result<()>, +) -> std::io::Result { /// Enough to outlast any plausible burst of same-second runs; past this, /// failing is better than looping while a caller waits. const MAX_ATTEMPTS: u32 = 100; @@ -252,10 +279,21 @@ fn write_backup_to_new_file(name: &str, json: &str) -> std::io::Result { } }; + // Reserve the name first, so a concurrent run cannot pick it while this + // one is still writing. The file stays empty until the rename below. match std::fs::File::create_new(&candidate) { - Ok(mut file) => { - std::io::Write::write_all(&mut file, json.as_bytes())?; - return Ok(candidate); + Ok(reservation) => { + drop(reservation); + return match publish(&candidate, json, &mut write) { + Ok(()) => Ok(candidate), + Err(error) => { + // The reservation is this function's own litter once the + // write has failed, and leaving it would present an + // empty file under the name of a complete record. + let _ = std::fs::remove_file(&candidate); + Err(error) + } + }; } // Someone else has this name. Not a failure yet: try the next. Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} @@ -269,6 +307,35 @@ fn write_backup_to_new_file(name: &str, json: &str) -> std::io::Result { )) } +/// Write `json` beside `final_name` and move it there once it is complete. +/// +/// The temporary is created exclusively too, and in the same directory, so the +/// rename is within one volume and cannot silently become a copy. +fn publish( + final_name: &str, + json: &str, + write: &mut impl FnMut(&mut std::fs::File, &[u8]) -> std::io::Result<()>, +) -> std::io::Result<()> { + let temporary = format!("{final_name}.{}.partial", std::process::id()); + + let mut file = std::fs::File::create_new(&temporary)?; + let written = write(&mut file, json.as_bytes()).and_then(|()| file.sync_all()); + drop(file); + + if let Err(error) = written { + let _ = std::fs::remove_file(&temporary); + return Err(error); + } + + // Replaces the reservation, which is what makes the publication atomic from + // a reader's point of view. + if let Err(error) = std::fs::rename(&temporary, final_name) { + let _ = std::fs::remove_file(&temporary); + return Err(error); + } + Ok(()) +} + fn parse_arguments() -> Result { let mut options = Options { preview: false, diff --git a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs index 252ceb0e..d77dd163 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs @@ -130,3 +130,73 @@ fn a_write_that_cannot_be_placed_reports_rather_than_loops() { assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); } + +#[test] +fn a_failed_write_leaves_no_file_behind() { + // The defect: the name was reserved with `create_new` and written into, so + // a write that failed part-way -- a full disk, a quota, a killed process -- + // left a truncated `.json` under the name a COMPLETE record would have had. + // Nothing downstream can tell those apart: a collector sees a record, and + // the next run's collision suffix steps politely around the wreckage. + let dir = scratch("failed-write"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + let error = super::write_backup_with(name, "{}", |_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::StorageFull, + "no space left on device", + )) + }) + .expect_err("the injected write fails"); + assert_eq!(error.kind(), std::io::ErrorKind::StorageFull); + + assert!( + !std::path::Path::new(name).exists(), + "a failed write must not leave a file under the record's own name" + ); + let left: Vec<_> = std::fs::read_dir(&dir) + .expect("readable") + .map(|entry| entry.expect("entry").file_name()) + .collect(); + assert!( + left.is_empty(), + "the partial file must be cleaned up too, found {left:?}" + ); +} + +#[test] +fn a_failed_write_does_not_consume_the_name_for_the_next_run() { + // The consequence that makes the leftover worse than untidy. If the failed + // attempt kept the name, the retry would be pushed onto a `-1` suffix and + // the good record would sit beside a broken one that sorts first. + let dir = scratch("failed-then-retry"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + let _ = super::write_backup_with(name, "{}", |_, _| Err(std::io::Error::other("interrupted"))) + .expect_err("the injected write fails"); + + let written = write_backup_to_new_file(name, "GOOD").expect("the name must be free again"); + + assert_eq!(written, name, "the retry must get the canonical name"); + assert_eq!(std::fs::read_to_string(&written).expect("readable"), "GOOD"); +} + +#[test] +fn a_successful_write_leaves_only_the_record() { + // Publication is by rename through a temporary, so the temporary must not + // survive a successful run either. + let dir = scratch("no-litter"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + let written = write_backup_to_new_file(name, "{}").expect("must write"); + + let left: Vec<_> = std::fs::read_dir(&dir) + .expect("readable") + .map(|entry| entry.expect("entry").path()) + .collect(); + assert_eq!(left.len(), 1, "expected only the record, found {left:?}"); + assert_eq!(left[0].to_str().expect("utf-8 path"), written); +} diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index e120343c..998484be 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -457,11 +457,43 @@ impl fmt::Display for Fingerprint { /// /// # Errors /// -/// Returns whatever [`Topology::discover`] failed with. +/// Returns whatever [`Topology::discover`] failed with, or +/// [`ErrorKind::InvalidData`](std::io::ErrorKind::InvalidData) if the discovered +/// topology names memory domains but leaves an online processor out of all of +/// them. Discovery has never produced that, and it would mean the topology +/// crate's parse had regressed rather than that the machine is unusual -- which +/// is worth saying out loud rather than papering over with a fabricated node. pub fn discover_places() -> std::io::Result> { - Ok(places_from_topology(&Topology::discover()?)) + places_from_topology(&Topology::discover()?).map_err(|unplaceable| { + std::io::Error::new(std::io::ErrorKind::InvalidData, unplaceable.to_string()) + }) } +/// An online processor whose NUMA membership a topology did not state, in a +/// topology that stated other processors'. +/// +/// Carried as a value rather than reported as a bare message so a caller can see +/// which processor was at fault; the identity is the whole diagnostic. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct UnplacedProcessor { + /// The processor's group. + pub group: u16, + /// The processor's number within that group. + pub number: u8, +} + +impl fmt::Display for UnplacedProcessor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "the topology names memory domains but places no NUMA node for g{}/cpu{}", + self.group, self.number + ) + } +} + +impl std::error::Error for UnplacedProcessor {} + /// Work out where each logical processor sits, in any topology. /// /// # Why this seam exists when `measure` deliberately has none @@ -483,8 +515,23 @@ pub fn discover_places() -> std::io::Result> { /// only ever execute against whatever machine ran the suite. The NUMA mapping /// in particular was unverifiable on a single-node host, where a completely /// broken lookup and a correct one both yield node 0. -#[must_use] -pub fn places_from_topology(topology: &Topology) -> Vec { +/// +/// # Every online processor is placed, and none is placed on an invented node +/// +/// The result has one entry per **online processor**, not one per processor a +/// core domain happens to mention. Iterating the core domains instead dropped +/// any processor without one -- silently returning a shorter machine than the +/// topology described, and rendering the core-id fallback below unreachable. +/// +/// # Errors +/// +/// Returns [`UnplacedProcessor`] when the topology names memory domains but +/// none of them contains some online processor. Defaulting that to node 0 is +/// right only when the topology names *no* memory domain at all; where the real +/// nodes are, say, 1 and 2, it invents a node the machine does not have and +/// files a processor under it. A partial topology is a legitimate input to this +/// seam (D-12), so it is refused rather than guessed at. +pub fn places_from_topology(topology: &Topology) -> Result, UnplacedProcessor> { // Every map here is keyed by the full `(group, number)` pair. Keying on the // number alone is the defect this function is written against: on a machine // with more than 64 logical processors each group numbers from zero, so @@ -520,20 +567,31 @@ pub fn places_from_topology(topology: &Topology) -> Vec { } } - // Node 0 is the correct default rather than a fallback: a machine with no - // NUMA partitioning has exactly one node, and every processor is in it. let mut numa_of = std::collections::BTreeMap::new(); + let mut any_memory_domain = false; for domain in topology.memory_domains() { + any_memory_domain = true; for id in domain.processors.iter() { numa_of.insert(id, domain.id); } } - class_of - .into_iter() - .map(|(id, efficiency_class)| { + topology + .processors + .iter() + .filter(|processor| processor.online) + .map(|processor| { + let id = (processor.id.group, processor.id.number); let (group, number) = id; - ProcessorPlace { + let numa_node = match numa_of.get(&id).copied() { + Some(node) => node, + // Node 0 is the correct answer rather than a fallback *only* + // here: a topology naming no memory domain at all describes a + // machine with one node, and every processor is in it. + None if !any_memory_domain => 0, + None => return Err(UnplacedProcessor { group, number }), + }; + Ok(ProcessorPlace { group, number, // The fallback keeps distinct processors distinct across groups: @@ -543,10 +601,13 @@ pub fn places_from_topology(topology: &Topology) -> Vec { .get(&id) .copied() .unwrap_or_else(|| u32::from(group) << 8 | u32::from(number)), - efficiency_class, + // Zero is what the topology crate itself reports for a processor + // with no known owning core, so this agrees with `Processor`'s + // own `capacity` rather than inventing a separate convention. + efficiency_class: class_of.get(&id).copied().unwrap_or(0), cache_domain: cache_of.get(&id).copied(), - numa_node: numa_of.get(&id).copied().unwrap_or(0), - } + numa_node, + }) }) .collect() } diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index 4c72ad72..9c7e5d3e 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -439,7 +439,8 @@ mod from_topology { #[test] fn every_processor_is_placed() { - let places = places_from_topology(&two_node_host()); + let places = + places_from_topology(&two_node_host()).expect("the fixture places every processor"); assert_eq!(places.len(), 8); let mut numbers: Vec = places.iter().map(|p| p.number).collect(); @@ -452,7 +453,8 @@ mod from_topology { // The assertion that could not be made before this seam existed. On a // single-node host this passes whether the lookup works or returns the // fallback, so it was previously untested in the only way that matters. - let places = places_from_topology(&two_node_host()); + let places = + places_from_topology(&two_node_host()).expect("the fixture places every processor"); for place in &places { let expected = u32::from(place.number >= 4); @@ -470,7 +472,8 @@ mod from_topology { // everything, the test above would still fail, but a future refactor // that collapsed the map could otherwise leave a suite that only ever // sees one node. - let places = places_from_topology(&two_node_host()); + let places = + places_from_topology(&two_node_host()).expect("the fixture places every processor"); let mut nodes: Vec = places.iter().map(|p| p.numa_node).collect(); nodes.sort_unstable(); nodes.dedup(); @@ -480,7 +483,8 @@ mod from_topology { #[test] fn smt_siblings_share_a_core_id() { - let places = places_from_topology(&two_node_host()); + let places = + places_from_topology(&two_node_host()).expect("the fixture places every processor"); for pair in places.chunks(2) { assert_eq!( @@ -496,7 +500,8 @@ mod from_topology { fn the_partitioning_cache_level_is_the_outermost_one_that_divides() { // Four distinct L2 domains here, so every core sits behind its own and // the two siblings of a core share one. - let places = places_from_topology(&two_node_host()); + let places = + places_from_topology(&two_node_host()).expect("the fixture places every processor"); assert_eq!(places[0].cache_domain, places[1].cache_domain); assert_ne!(places[0].cache_domain, places[2].cache_domain); @@ -522,7 +527,7 @@ mod from_topology { }, ]); - let places = places_from_topology(&flat); + let places = places_from_topology(&flat).expect("the fixture places every processor"); assert!( places.iter().all(|p| p.cache_domain.is_none()), @@ -547,7 +552,7 @@ mod from_topology { }, ]); - let places = places_from_topology(&hybrid); + let places = places_from_topology(&hybrid).expect("the fixture places every processor"); assert_eq!(places[0].efficiency_class, 1); assert_eq!(places[1].efficiency_class, 1); @@ -561,7 +566,8 @@ mod from_topology { // author assumed it would produce. use crate::core_affinity::{Placement, node_pairs, representative_pairs}; - let places = places_from_topology(&two_node_host()); + let places = + places_from_topology(&two_node_host()).expect("the fixture places every processor"); let pairs = representative_pairs(&places); assert!(pairs.contains_key(&Placement::SameCoreSiblings)); @@ -661,7 +667,8 @@ mod multi_group_conversion { // The regression that matters. Keying the conversion's maps on the // processor number alone silently produced four places for an // eight-processor machine, and nothing in the output said so. - let places = places_from_topology(&two_group_topology()); + let places = places_from_topology(&two_group_topology()) + .expect("the fixture places every processor"); assert_eq!( places.len(), @@ -678,7 +685,8 @@ mod multi_group_conversion { #[test] fn a_cores_identity_does_not_collide_across_groups() { - let places = places_from_topology(&two_group_topology()); + let places = places_from_topology(&two_group_topology()) + .expect("the fixture places every processor"); let cores: std::collections::BTreeSet<(u16, u32)> = places.iter().map(|p| (p.group, p.core)).collect(); @@ -688,7 +696,8 @@ mod multi_group_conversion { #[test] fn per_group_cache_and_node_membership_is_read_correctly() { - let places = places_from_topology(&two_group_topology()); + let places = places_from_topology(&two_group_topology()) + .expect("the fixture places every processor"); for place in &places { assert_eq!( @@ -703,4 +712,146 @@ mod multi_group_conversion { ); } } + + // --- Partial topologies, which this seam exists to accept (D-12) --- + + /// A topology whose only domain is the group: online processors, no core, + /// no cache, and no memory domain at all. + fn bare_processors(count: u8) -> Topology { + let all: Vec = (0..count).collect(); + let mask = all.iter().fold(0_usize, |mask, n| mask | (1 << n)); + Topology { + processors: all + .iter() + .map(|&number| Processor { + id: ProcessorId { group: 0, number }, + online: true, + capacity: 0, + }) + .collect(), + domains: vec![Domain { + kind: DomainKind::Group, + id: 0, + processors: ProcessorSet::from_group_mask(0, mask), + }], + distances: None, + ..Default::default() + } + } + + #[test] + fn a_processor_with_no_core_domain_is_still_placed() { + // The conversion used to iterate the core domains, so a processor no + // core mentioned simply vanished -- the result described a smaller + // machine than the topology did, and said nothing about the omission. + let places = places_from_topology(&bare_processors(4)) + .expect("no memory domain at all means the single-node default applies"); + + assert_eq!(places.len(), 4, "every online processor must be placed"); + let mut numbers: Vec = places.iter().map(|p| p.number).collect(); + numbers.sort_unstable(); + assert_eq!(numbers, vec![0, 1, 2, 3]); + } + + #[test] + fn a_processor_with_no_core_domain_keeps_its_group_distinct() { + // The core fallback was unreachable while the iteration was over core + // domains: no core meant no entry to fall back *from*. It exists so + // group 1's cpu5 cannot collapse onto group 0's, so that is what is + // asserted rather than merely that some number was produced. + let mut topology = bare_processors(1); + topology.processors.push(Processor { + id: ProcessorId { + group: 1, + number: 0, + }, + online: true, + capacity: 0, + }); + topology.domains.push(Domain { + kind: DomainKind::Group, + id: 1, + processors: ProcessorSet::from_group_mask(1, 0b1), + }); + + let places = places_from_topology(&topology).expect("no memory domain, so node 0 applies"); + + assert_eq!(places.len(), 2); + assert_ne!( + places[0].core, places[1].core, + "g0/cpu0 and g1/cpu0 must not share a fabricated core id" + ); + } + + #[test] + fn an_offline_processor_is_not_placed() { + // Placement is about where work can run. An offline slot exists only to + // reserve group capacity for a processor that may be added later. + let mut topology = bare_processors(4); + topology.processors[2].online = false; + + let places = places_from_topology(&topology).expect("a partial topology with no nodes"); + + let numbers: Vec = places.iter().map(|p| p.number).collect(); + assert_eq!( + numbers, + vec![0, 1, 3], + "the offline slot must not be placed" + ); + } + + #[test] + fn node_zero_is_the_answer_only_when_no_memory_domain_exists() { + // The half of the default that is correct: a topology naming no memory + // domain describes one node, and every processor is in it. + let places = places_from_topology(&bare_processors(2)).expect("one implicit node"); + + assert!(places.iter().all(|p| p.numa_node == 0)); + } + + #[test] + fn a_processor_outside_every_named_memory_domain_is_refused() { + // The half that was not. This topology names node 1 and node 2, and + // says nothing about cpu2 -- so `unwrap_or(0)` filed it under a node + // this machine does not have, which is exactly the fabricated label the + // crate's own seam rule forbids. + let mut topology = bare_processors(3); + for (id, mask) in [(1_u32, 0b001_usize), (2, 0b010)] { + topology.domains.push(Domain { + kind: DomainKind::Memory { memory_bytes: None }, + id, + processors: ProcessorSet::from_group_mask(0, mask), + }); + } + + let refused = places_from_topology(&topology) + .expect_err("cpu2 belongs to no named node, so it cannot be placed"); + + assert_eq!(refused.group, 0); + assert_eq!(refused.number, 2); + assert!( + refused.to_string().contains("g0/cpu2"), + "the message must name the processor: {refused}" + ); + } + + #[test] + fn a_fully_covered_multi_node_topology_is_still_accepted() { + // The guard must refuse only what it is aimed at: with every processor + // covered, sparse node *numbers* are a valid machine, not an error. + let mut topology = bare_processors(2); + for (id, mask) in [(1_u32, 0b01_usize), (2, 0b10)] { + topology.domains.push(Domain { + kind: DomainKind::Memory { memory_bytes: None }, + id, + processors: ProcessorSet::from_group_mask(0, mask), + }); + } + + let places = places_from_topology(&topology).expect("every processor has a node"); + + let mut nodes: Vec = places.iter().map(|p| p.numa_node).collect(); + nodes.sort_unstable(); + assert_eq!(nodes, vec![1, 2], "the real node numbers must survive"); + } } diff --git a/crates/windows-platform-probes/src/queue_contention.rs b/crates/windows-platform-probes/src/queue_contention.rs index 39f5de00..f855ab6c 100644 --- a/crates/windows-platform-probes/src/queue_contention.rs +++ b/crates/windows-platform-probes/src/queue_contention.rs @@ -47,6 +47,7 @@ //! contention question, and the drained one for the cost of `head`. use std::sync::Arc; +use std::sync::Barrier; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::thread; use std::time::Instant; @@ -211,16 +212,24 @@ fn median_run( /// over one cache line. fn time_contended_atomic(producers: usize) -> Repetition { let counter = Arc::new(AtomicU64::new(0)); - let started = Instant::now(); - thread::scope(|scope| { + // One party per worker plus this thread. Every worker is created, then waits + // here; the clock starts as the barrier releases, so neither thread creation + // nor a solo head start by an early worker is inside the measurement. See + // `start_barrier`'s note for why that matters at these producer counts. + let gate = Arc::new(Barrier::new(producers + 1)); + let started = thread::scope(|scope| { for _ in 0..producers { let counter = Arc::clone(&counter); + let gate = Arc::clone(&gate); scope.spawn(move || { + gate.wait(); for _ in 0..PUSHES_PER_PRODUCER { counter.fetch_add(1, Ordering::Relaxed); } }); } + gate.wait(); + Instant::now() }); (started.elapsed().as_nanos() as f64, 0) } @@ -230,20 +239,41 @@ fn capacity_for(producers: usize) -> usize { (producers * PUSHES_PER_PRODUCER).next_power_of_two() } +/// A gate holding every participant until all of them exist. +/// +/// **Without this the row labelled N producers need not have measured N of +/// them.** Spawning is not instant, and each worker used to start pushing the +/// moment it was created, so at 50,000 pushes an early producer could complete +/// a long uncontended prefix -- or finish entirely -- before the last thread was +/// spawned. The reported interval also began before any worker existed, folding +/// thread-creation cost into a per-push number. The curve against N is the whole +/// output of this probe, and both effects bend it downward exactly where it is +/// steepest. +/// +/// The count includes this thread: the workers arrive and block, this thread +/// arrives last, and the clock starts as the barrier releases them together. +fn start_barrier(participants: usize) -> Arc { + Arc::new(Barrier::new(participants + 1)) +} + fn time_isolated_mpsc(producers: usize) -> Repetition { let (tx, rx) = slotwise_mpsc::bounded::(capacity_for(producers)).expect("a valid capacity"); - let started = Instant::now(); - thread::scope(|scope| { + let gate = start_barrier(producers); + let started = thread::scope(|scope| { for producer in 0..producers { let tx = tx.clone(); + let gate = Arc::clone(&gate); scope.spawn(move || { + gate.wait(); for index in 0..PUSHES_PER_PRODUCER { tx.push((producer * PUSHES_PER_PRODUCER + index) as u64) .expect("the run fits in the capacity"); } }); } + gate.wait(); + Instant::now() }); let elapsed = started.elapsed().as_nanos() as f64; let refusals = tx.refused(); @@ -256,17 +286,21 @@ fn time_isolated_mpsc(producers: usize) -> Repetition { fn time_isolated_reserving(producers: usize) -> Repetition { let (tx, rx) = reserving_mpsc::bounded::(capacity_for(producers)).expect("a valid capacity"); - let started = Instant::now(); - thread::scope(|scope| { + let gate = start_barrier(producers); + let started = thread::scope(|scope| { for producer in 0..producers { let tx = tx.clone(); + let gate = Arc::clone(&gate); scope.spawn(move || { + gate.wait(); for index in 0..PUSHES_PER_PRODUCER { tx.push((producer * PUSHES_PER_PRODUCER + index) as u64) .expect("the run fits in the capacity"); } }); } + gate.wait(); + Instant::now() }); let elapsed = started.elapsed().as_nanos() as f64; let refusals = tx.refused(); @@ -282,8 +316,15 @@ fn time_drained_mpsc(producers: usize) -> Repetition { let (tx, rx) = slotwise_mpsc::bounded::(DRAINED_CAPACITY).expect("a valid capacity"); let done = Arc::new(AtomicBool::new(false)); let consumer_done = Arc::clone(&done); + // The consumer is a participant too: it is spawned first, but spawning is + // not readiness, and a consumer still starting up while producers push turns + // the opening of the run into an undrained regime -- the one thing this + // measurement is defined against. + let gate = start_barrier(producers + 1); + let consumer_gate = Arc::clone(&gate); let consumer = thread::spawn(move || { + consumer_gate.wait(); // Spin rather than park: the doorbell's cost is `doorbell_cost`'s // question, and parking here would measure that instead of the claim. while !consumer_done.load(Ordering::Relaxed) { @@ -294,11 +335,12 @@ fn time_drained_mpsc(producers: usize) -> Repetition { rx.refused() }); - let started = Instant::now(); - thread::scope(|scope| { + let started = thread::scope(|scope| { for producer in 0..producers { let tx = tx.clone(); + let gate = Arc::clone(&gate); scope.spawn(move || { + gate.wait(); for index in 0..PUSHES_PER_PRODUCER { let mut item = (producer * PUSHES_PER_PRODUCER + index) as u64; // Retry on a full queue, which is what a real producer @@ -310,6 +352,8 @@ fn time_drained_mpsc(producers: usize) -> Repetition { } }); } + gate.wait(); + Instant::now() }); let elapsed = started.elapsed().as_nanos() as f64; @@ -336,8 +380,13 @@ fn time_drained_reserving(producers: usize) -> Repetition { let (tx, rx) = reserving_mpsc::bounded::(DRAINED_CAPACITY).expect("a valid capacity"); let done = Arc::new(AtomicBool::new(false)); let consumer_done = Arc::clone(&done); + // The consumer joins the gate here for the reason it does in the slotwise + // twin: a run whose opening is undrained is not the regime being measured. + let gate = start_barrier(producers + 1); + let consumer_gate = Arc::clone(&gate); let consumer = thread::spawn(move || { + consumer_gate.wait(); while !consumer_done.load(Ordering::Relaxed) { while rx.pop().is_some() {} std::hint::spin_loop(); @@ -346,11 +395,12 @@ fn time_drained_reserving(producers: usize) -> Repetition { rx.refused() }); - let started = Instant::now(); - thread::scope(|scope| { + let started = thread::scope(|scope| { for producer in 0..producers { let tx = tx.clone(); + let gate = Arc::clone(&gate); scope.spawn(move || { + gate.wait(); for index in 0..PUSHES_PER_PRODUCER { let mut item = (producer * PUSHES_PER_PRODUCER + index) as u64; while let Err(error) = tx.push(item) { @@ -360,6 +410,8 @@ fn time_drained_reserving(producers: usize) -> Repetition { } }); } + gate.wait(); + Instant::now() }); let elapsed = started.elapsed().as_nanos() as f64; From 2eea4a82c7cbef52c54239a9db680873fc160e06 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 20:19:10 -0400 Subject: [PATCH 174/361] test(file-watcher): isolate the last notification-filter categories M15.4 asked to isolate LAST_WRITE and SECURITY or record that they cannot be. They can be, and the item's reasons for thinking otherwise were both wrong. It named the right measurement -- arm a watch with a single filter bit at a time and see which fires -- and running it overturned its own premises: SetFileTime last-write only -> LAST_WRITE alone SetFileTime creation only -> CREATION alone SetFileTime last-access only -> LAST_ACCESS alone DACL edit (icacls) -> SECURITY alone same-length rewrite -> SIZE and LAST_WRITE, not ATTRIBUTES The archive bit was a red herring. ATTRIBUTES does not fire for a same-length rewrite at all; SIZE reports it, because std::fs::write truncates before writing, so the length does change on the way through even though the net length does not. And the DACL test already worked. A DACL edit fires SECURITY and nothing else, and injecting the SECURITY-dropping mutant turns that test red. It was recorded as not closing that mutant because the earlier check was not measuring what it claimed. Adds two SetFileTime tests, one touching only the last-write stamp and one only the creation stamp. The handle is opened for FILE_WRITE_ATTRIBUTES alone rather than for writing, because a write-access handle can set the archive bit on close and would quietly undo the isolation. The creation-time test is the interesting one: the mutants drop adjacent pairs -- LAST_WRITE+CREATION and CREATION+SECURITY -- so a creation-only change is the single operation that fails under both. All six flag-pair mutants in ALL_NOTIFY_FILTERS are now caught, each confirmed by injection, including the four previously assumed covered but never verified individually. The section comment and both older tests' comments are corrected where they stated the disproved claims. A harness lesson, since the first probe produced two confidently wrong tables: reusing one directory handle across probes makes every read complete instantly against records buffered from earlier operations, so the first run showed nothing firing and the second showed everything firing. A fresh handle per probe, decoding records and matching by file name, plus a built-in sanity check so a broken harness announces itself rather than producing a plausible table. Completed item: M15.4: Isolate the last two notification-filter categories, or record that they cannot be isolated from outside. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 18 +- .../COMPLETED-CHECKLIST.md | 46 ++++- .../windows-file-watcher/src/watcher/tests.rs | 184 ++++++++++++++---- 3 files changed, 195 insertions(+), 53 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index f919a8b4..f95611d9 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -145,23 +145,7 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- - [x] **M15.10** -- Tested `canonical_path`'s 512-unit retry. No junction needed: a caller's own `\\?\` path opens past `MAX_PATH` (D-85), so the retry is reachable through the crate's own API. Added a boundary walk over 508-516 units; `<=` proved equivalent by measurement. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m1510) -- [ ] **M15.4** -- Isolate the last two notification-filter categories, or record that they cannot be - isolated from outside. Two mutants in `ALL_NOTIFY_FILTERS` survive: replacing the `|` before - `FILE_NOTIFY_CHANGE_CREATION` (dropping LAST_WRITE and CREATION) and before - `FILE_NOTIFY_CHANGE_SECURITY` (dropping CREATION and SECURITY). `&` binds tighter than `|`, so each - such mutant zeroes the two flags on either side of it. - **Why the obvious tests do not catch them, measured rather than assumed.** ATTRIBUTES and SIZE remain - present in both mutants, and they mask the rest: a same-length rewrite still sets the file's archive - bit, so ATTRIBUTES reports it; and a DACL edit via `icacls` is likewise still reported with SECURITY - dropped, through some filter this exercise did not identify. Both tests were written expecting to - isolate a category, both failed to, and both are kept with their claims corrected rather than deleted. - **What would work.** For LAST_WRITE, a timestamp-only change -- `SetFileTime` on an already-open - handle, touching neither length nor attributes. For SECURITY, first establish *which* filter currently - reports a DACL edit (arm a watch with a single filter bit at a time and see which one fires), because - the assumption that it touches nothing else is exactly what the failed test disproved. - **A legitimate outcome is "cannot be isolated".** If every operation that changes one of these also - changes an attribute or a length, then no black-box test can distinguish the mutants, and they belong - with the equivalent ones rather than on this list. Establishing that is as good an answer as a test. +- [x] **M15.4** -- Isolated both remaining notification-filter categories. All six `ALL_NOTIFY_FILTERS` flag-pair mutants are now caught. Two of the item's own recorded claims were disproved by measurement: ATTRIBUTES does not mask a same-length rewrite, and a DACL edit *is* reported by SECURITY alone. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m154) - [ ] **M15.5** -- Assert the arming contract in `arm_detailed_read`, so a broken one fails a test instead of *sometimes* corrupting the heap. **The shipping code is not at fault here** -- that was diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index f654ec30..7ef0333a 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -813,4 +813,48 @@ two-call convention makes `written == buffer.len()` unreachable, because success excluding the NUL (needing room for it) while a too-small buffer returns the length including it. That was confirmed rather than argued -- an `assert_ne!` probe never fired across the whole suite, including the 508-516 walk that straddles the buffer size exactly. The reasoning is now a comment at the -comparison, so the next sweep does not re-litigate it. \ No newline at end of file +comparison, so the next sweep does not re-litigate it. +## Moved 2026-09-01 -- M15.4: isolating the last notification-filter categories + +### M15.4 -- Isolate the last two notification-filter categories, or record that they cannot be isolated from outside. *(completed 2026-09-01 20:35:00 -04:00)* + +**They can be isolated, and the item's reasons for thinking otherwise were both wrong.** The item did the +right thing in naming the measurement it needed -- arm a watch with a single filter bit at a time and see +which fires -- and doing that measurement overturned its own premises. + +| operation | which bit reported it | +|---|---| +| `SetFileTime` last-write only | **LAST_WRITE alone** | +| `SetFileTime` creation only | **CREATION alone** | +| `SetFileTime` last-access only | LAST_ACCESS alone | +| DACL edit (`icacls`) | **SECURITY alone** | +| same-length rewrite | SIZE and LAST_WRITE -- **not** ATTRIBUTES | + +**Correction 1: the archive bit was a red herring.** The item held that a same-length rewrite is masked +because it sets the archive bit and ATTRIBUTES reports it. ATTRIBUTES does not fire for that operation at +all. What actually reports it is SIZE, because `std::fs::write` truncates before writing, so the length +genuinely does change on the way through even though the net length is the same. + +**Correction 2: the DACL test already worked.** The item held that a DACL edit is reported through "some +filter this exercise did not identify" with SECURITY dropped. A DACL edit fires SECURITY and nothing else, +and injecting the SECURITY-dropping mutant turns `changing_a_files_permissions_is_reported_as_modified` +red. That mutant was already closed; the record said otherwise because the earlier check was not +measuring what it claimed. + +**What was added.** Two tests using `SetFileTime`, one touching only the last-write stamp and one only the +creation stamp. The handle is opened for `FILE_WRITE_ATTRIBUTES` **alone**, not for writing, because a +write-access handle can set the archive bit on close and would quietly undo the isolation. The +creation-time test is the interesting one: the surviving mutants drop *adjacent pairs* +(LAST_WRITE+CREATION and CREATION+SECURITY), so a creation-only change is the single operation that fails +under both. + +**Result: all six flag-pair mutants in `ALL_NOTIFY_FILTERS` are now caught**, each confirmed by injecting +it and watching the module go red -- including the four the item had assumed were already covered, which +had never been verified individually. + +**A harness lesson, since the first probe produced two confidently wrong tables.** Reusing one directory +handle across probes makes every read complete instantly against change records buffered from earlier +operations -- the first run reported nothing firing, the second reported everything firing. Both were +harness artifacts. The fix is a fresh handle per probe plus decoding the returned records and matching by +file name, and a built-in sanity check (a known operation under a known bit) so a broken harness announces +itself instead of producing a plausible table. \ No newline at end of file diff --git a/crates/windows-file-watcher/src/watcher/tests.rs b/crates/windows-file-watcher/src/watcher/tests.rs index f7f4dd45..53e07ad8 100644 --- a/crates/windows-file-watcher/src/watcher/tests.rs +++ b/crates/windows-file-watcher/src/watcher/tests.rs @@ -6,11 +6,13 @@ //! the kernel, not a model of it. use std::num::NonZeroUsize; +use std::os::windows::io::AsRawHandle; use std::path::Path; use std::sync::Arc; use std::time::Duration; -use windows_sys::Win32::Foundation::ERROR_NOT_SUPPORTED; +use windows_sys::Win32::Foundation::{ERROR_NOT_SUPPORTED, FILETIME, HANDLE}; +use windows_sys::Win32::Storage::FileSystem::{FILE_WRITE_ATTRIBUTES, SetFileTime}; use super::{ArmGate, DirectoryWatcher, ReadBuffer, lock}; use crate::directory::{ @@ -1669,25 +1671,31 @@ fn stopping_a_volume_change_removes_only_that_route() { // zeroes the two flags on either side of it -- `A | B & C | D` parses as // `A | (B & C) | D`, and disjoint flags AND to nothing -- so each such mutant // silently drops a whole category of change from what the kernel is asked to -// report. +// report. That is a real gap rather than a curiosity: a watcher that reports +// creation and deletion but silently never reports a write is a plausible +// defect, and for a while nothing here would have noticed. // -// Three of those survived: the ones dropping ATTRIBUTES+SIZE, -// LAST_WRITE+CREATION and CREATION+SECURITY. The mutants dropping FILE_NAME and -// DIR_NAME were caught, which says exactly what the suite covered -- files -// appearing, disappearing and being renamed -- and what it did not: anything -// that happens to a file that already exists and keeps its name. +// **All six are now caught**, each confirmed by injecting it and watching this +// module go red. The last two to fall were the pairs LAST_WRITE+CREATION and +// CREATION+SECURITY, closed by the two `SetFileTime` tests below. // -// That is a real gap rather than a curiosity. A watcher that reports creation -// and deletion but silently never reports a write is a plausible defect, and -// until now nothing here would have noticed. +// What made them closable was measuring which filter bit actually reports which +// operation -- arming a watch with a single bit at a time -- rather than +// reasoning about it. The result, and it corrects two claims this comment used +// to make: // -// The tests below close the ATTRIBUTES+SIZE one. **They do not close the other -// two**, and that is recorded rather than papered over: ATTRIBUTES and SIZE stay -// present in both of those mutants, and ordinary file operations set the archive -// bit or change the length, so those two filters mask the dropped ones. Catching -// LAST_WRITE needs a timestamp-only change; catching SECURITY needs an operation -// that provably touches nothing else, which a DACL edit turns out not to be. See -// M15.4. +// SetFileTime last-write only -> LAST_WRITE alone +// SetFileTime creation only -> CREATION alone +// SetFileTime last-access only -> LAST_ACCESS alone +// DACL edit (icacls) -> SECURITY alone +// same-length rewrite -> SIZE and LAST_WRITE (not ATTRIBUTES) +// +// The corrections: a same-length rewrite is *not* masked by the archive bit -- +// ATTRIBUTES does not fire for it at all, and what actually reports it is SIZE, +// because `std::fs::write` truncates before writing, so the length does change +// on the way through. And a DACL edit *is* reported by SECURITY alone, so the +// permissions test below does isolate that category; the earlier note saying it +// did not was simply wrong. #[test] fn writing_to_an_existing_file_is_reported_as_modified() { @@ -1768,16 +1776,20 @@ fn the_default_buffer_is_the_documented_size() { #[test] fn rewriting_a_file_without_changing_its_length_is_reported_as_modified() { - // A rewrite that leaves the length alone, so this cannot be reported - // through FILE_NOTIFY_CHANGE_SIZE. + // A rewrite whose *net* length is unchanged. + // + // It was written to isolate FILE_NOTIFY_CHANGE_LAST_WRITE and it does not, + // but not for the reason first recorded here. Measured, one filter bit at a + // time: this operation fires SIZE and LAST_WRITE, and does *not* fire + // ATTRIBUTES -- the archive bit was a red herring. What defeats the + // isolation is that `std::fs::write` truncates before writing, so the length + // really does change on the way through and SIZE reports it. Isolating + // last-write needs a change that touches only the timestamp, which + // `changing_only_a_files_last_write_time_is_reported_as_modified` below now + // does. // - // It was written to isolate FILE_NOTIFY_CHANGE_LAST_WRITE and it does not: - // dropping that flag still leaves this green, because writing to a file - // also sets its archive bit and FILE_NOTIFY_CHANGE_ATTRIBUTES reports the - // change instead. Isolating last-write needs a change that touches only the - // timestamp -- a `SetFileTime` call rather than a write. Kept anyway, - // because a same-length rewrite being reported at all is worth asserting - // and nothing else here does it. + // Kept anyway, because a same-length rewrite being reported at all is worth + // asserting and nothing else here does it. let dir = TempDir::new("modified-same-size"); let path = dir.path().join("fixed-size.txt"); std::fs::write(&path, b"aaaa").expect("seed the file"); @@ -1796,19 +1808,121 @@ fn rewriting_a_file_without_changing_its_length_is_reported_as_modified() { dir.cleanup(); } +#[test] +fn changing_only_a_files_last_write_time_is_reported_as_modified() { + // Isolates FILE_NOTIFY_CHANGE_LAST_WRITE, which nothing else here does: a + // `SetFileTime` touching only the last-write stamp changes no name, no + // length, and no attribute, so no other filter in `ALL_NOTIFY_FILTERS` can + // account for the notification. Measured before it was written -- armed one + // filter bit at a time, this operation fires LAST_WRITE and nothing else. + // + // The handle is opened for `FILE_WRITE_ATTRIBUTES` alone rather than for + // writing, deliberately: a write-access handle can set the archive bit when + // it closes, which would let ATTRIBUTES report this instead and quietly undo + // the isolation. + let dir = TempDir::new("modified-timestamp"); + let path = dir.path().join("stamped.txt"); + std::fs::write(&path, b"unchanged").expect("seed the file"); + + let (watcher, collected) = watch(dir.path(), false); + + set_last_write_time(&path); + + collected.wait_until("a Modified for stamped.txt", |d| { + d.changes() + .iter() + .any(|(kind, name)| *kind == ChangeKind::Modified && name == "stamped.txt") + }); + + drop(watcher); + dir.cleanup(); +} + +#[test] +fn changing_only_a_files_creation_time_is_reported_as_modified() { + // Isolates FILE_NOTIFY_CHANGE_CREATION on the same principle. Worth having + // separately from the last-write case because the two surviving mutants drop + // *pairs* of adjacent flags -- LAST_WRITE+CREATION and CREATION+SECURITY -- + // so a creation-only change is the one operation that fails under both. + let dir = TempDir::new("modified-creation"); + let path = dir.path().join("born.txt"); + std::fs::write(&path, b"unchanged").expect("seed the file"); + + let (watcher, collected) = watch(dir.path(), false); + + set_creation_time(&path); + + collected.wait_until("a Modified for born.txt", |d| { + d.changes() + .iter() + .any(|(kind, name)| *kind == ChangeKind::Modified && name == "born.txt") + }); + + drop(watcher); + dir.cleanup(); +} + +/// An arbitrary fixed instant, far enough in the past that it cannot equal the +/// stamp already on a file this test just created. +fn a_distinct_filetime() -> FILETIME { + // 100ns ticks since 1601, i.e. some time in 2013. + const TICKS: u64 = 130_000_000_000_000_000; + FILETIME { + dwLowDateTime: (TICKS & 0xFFFF_FFFF) as u32, + dwHighDateTime: (TICKS >> 32) as u32, + } +} + +/// Open for `FILE_WRITE_ATTRIBUTES` only -- see the tests above for why the +/// narrower access matters. +fn open_for_timestamps(path: &Path) -> std::fs::File { + use std::os::windows::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .access_mode(FILE_WRITE_ATTRIBUTES) + .open(path) + .expect("open the file for its timestamps") +} + +fn set_last_write_time(path: &Path) { + let file = open_for_timestamps(path); + let stamp = a_distinct_filetime(); + // SAFETY: `file` is live for the call, and the two null pointers are the + // documented way to leave creation and last-access untouched. + let ok = unsafe { + SetFileTime( + file.as_raw_handle() as HANDLE, + std::ptr::null(), + std::ptr::null(), + &stamp, + ) + }; + assert!(ok != 0, "SetFileTime(last write) failed"); +} + +fn set_creation_time(path: &Path) { + let file = open_for_timestamps(path); + let stamp = a_distinct_filetime(); + // SAFETY: as above, leaving last-access and last-write untouched. + let ok = unsafe { + SetFileTime( + file.as_raw_handle() as HANDLE, + &stamp, + std::ptr::null(), + std::ptr::null(), + ) + }; + assert!(ok != 0, "SetFileTime(creation) failed"); +} + #[test] fn changing_a_files_permissions_is_reported_as_modified() { // A DACL edit, which is a change no other test here makes. // - // It was written to isolate FILE_NOTIFY_CHANGE_SECURITY and it does not: - // dropping that flag leaves this green, so the notification is arriving - // through one of the filters that remain. Which one is not established -- - // the assumption that a DACL edit touches nothing else is what this - // disproves, and guessing a replacement would repeat the error. - // - // Kept anyway: "a permission change is reported to a watcher" is a - // user-visible behaviour worth asserting on its own terms, whatever filter - // delivers it. + // This **does** isolate FILE_NOTIFY_CHANGE_SECURITY, and an earlier note + // here claiming otherwise was wrong. Measured one filter bit at a time, a + // DACL edit fires SECURITY and nothing else; injecting the mutant that drops + // SECURITY turns this test red. Whatever the earlier check did, it was not + // measuring this. // // `icacls` rather than a Win32 call, because the point is to make a real // security-descriptor change and observe that the watch reports it, not to From 8c5553846876055a7ae55f036e0672aa46ce0c35 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 20:27:38 -0400 Subject: [PATCH 175/361] test(file-watcher): assert the arming contract instead of crashing into it The submission closure classified ReadDirectoryChangesW's result inline, so nothing could observe that classification except by running a real watcher and seeing what the pool did. Getting it wrong tells the pool to reclaim an I/O the kernel will still complete, and the completion lands in a freed buffer -- so detection depended on allocator behaviour rather than on an assertion. The same mutation was recorded MISSED in one sweep and a crash in another, and two crashes were scored as caught purely because the process exited non-zero. `classify_submission` makes that classification a value: it takes the raw result and a lazily-taken GetLastError and returns Result. Four tests assert the whole contract -- a non-zero return is still pending (a packet is coming even though the I/O finished), ERROR_IO_PENDING is pending, a genuine failure carries the OS code through, and an error with no OS code falls to the failure arm rather than matching the pending comparison. The success test passes a last_error closure that panics, pinning the rule that GetLastError must not be read after a success, where it holds a stale value from an unrelated call. One detail worth recording, because verifying the fix is what found it. The extraction first took a `bool`, leaving `ok != 0` at the call site -- and injecting *that* comparison was still "caught" only by the process dying with STATUS_HEAP_CORRUPTION, which is the exact failure mode this item exists to remove, relocated by one operand. The function now takes the raw BOOL and does the `!= 0` itself, so the convention sits inside the tested surface and the call site has no comparison left to get wrong. Every mutant in the classification -- `!= 0` to `== 0`, to true, to false; the pending equality to `!=`; the failure arm to Ok(Pending) -- now fails with exit 101 in about a second each. None crashes, so this file's mutation score stops depending on heap layout. Completed item: M15.5: Assert the arming contract in `arm_detailed_read`, so a broken one fails a test instead of sometimes corrupting the heap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 24 +----- .../COMPLETED-CHECKLIST.md | 39 ++++++++- crates/windows-file-watcher/src/watcher.rs | 52 +++++++++--- .../windows-file-watcher/src/watcher/tests.rs | 82 ++++++++++++++++++- 4 files changed, 161 insertions(+), 36 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index f95611d9..8b869d0e 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -147,29 +147,7 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- - [x] **M15.4** -- Isolated both remaining notification-filter categories. All six `ALL_NOTIFY_FILTERS` flag-pair mutants are now caught. Two of the item's own recorded claims were disproved by measurement: ATTRIBUTES does not mask a same-length rewrite, and a DACL edit *is* reported by SECURITY alone. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m154) -- [ ] **M15.5** -- Assert the arming contract in `arm_detailed_read`, so a broken one fails a test - instead of *sometimes* corrupting the heap. **The shipping code is not at fault here** -- that was - checked before anything else, and the check is recorded below so nobody has to repeat it. - **What is wrong.** Inverting the `ERROR_IO_PENDING` test at `watcher.rs:482` makes a genuinely-pending - read look failed, so the thread pool cancels its accounting for an I/O the kernel is still going to - complete, and the completion lands in a freed buffer. Nothing asserts otherwise, so the only thing - standing between that mutation and a green suite is whether the allocator happens to notice. - **It is not reliable, and that is the point.** The same mutant was recorded `MISSED` in one sweep and - crashed the process in another. Sixteen crashes were logged across the runs, and cargo-mutants counted - two of them as `CaughtMutant` purely because the process exited non-zero -- one with - `STATUS_HEAP_CORRUPTION` (`0xC0000374`), one with `STATUS_STACK_BUFFER_OVERRUN` (`0xC0000409`). **A - crash is not a test.** Detection by memory corruption depends on allocator behaviour and heap layout, - so the mutation score for this file is non-deterministic run to run, and a "caught" here is a weaker - claim than it looks. - **Wanted:** a test that observes the arming *contract* rather than its wreckage -- that a read - reporting `ERROR_IO_PENDING` is treated as armed and its completion delivered exactly once, and that a - genuinely failed submission is not left accounted-for. That makes both the mutant and any future - regression a deterministic red test. - **Ruled out: a defect in the unmutated code.** 55 runs of the unmutated suite (25 default-feature, 30 - `--all-features`, including the exact binary named in every crash report) produced zero failures. All - sixteen crash reports carry distinct PE timestamps, none equal to the clean build's -- each was its own - mutant build. The test binary's filename is derived from the target and features rather than its - contents, which is why every report names the same `.exe` and why that name alone proves nothing. +- [x] **M15.5** -- Made the arming contract observable: extracted `classify_submission` (taking the raw `BOOL`, so the `!= 0` convention is inside the tested surface too) and asserted all four cases. Every mutant now fails as a deterministic red test rather than as a heap corruption. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m155) - [x] **M15.6** -- Converted `queue/tests.rs` to bounded waiting, so a broken wake fails instead of hanging. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m156) diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index 7ef0333a..e2c459cc 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -857,4 +857,41 @@ handle across probes makes every read complete instantly against change records operations -- the first run reported nothing firing, the second reported everything firing. Both were harness artifacts. The fix is a fresh handle per probe plus decoding the returned records and matching by file name, and a built-in sanity check (a known operation under a known bit) so a broken harness announces -itself instead of producing a plausible table. \ No newline at end of file +itself instead of producing a plausible table. +## Moved 2026-09-01 -- M15.5: the arming contract, asserted instead of crashed into + +### M15.5 -- Assert the arming contract in `arm_detailed_read`, so a broken one fails a test instead of *sometimes* corrupting the heap. *(completed 2026-09-01 20:45:00 -04:00)* + +**The shape of the problem.** The submission closure classified `ReadDirectoryChangesW`'s result inline, +so nothing could observe that classification except by running a real watcher and seeing what the pool +did with the result. Getting it wrong tells the pool to reclaim an I/O the kernel will still complete, and +the completion lands in a freed buffer -- detection then depends on allocator behaviour rather than on any +assertion. The same mutation was recorded `MISSED` in one sweep and a crash in another, and two crashes +were scored `CaughtMutant` purely because the process exited non-zero. + +**The fix: make the classification a value, not a side effect.** `classify_submission` is now a free +function taking the raw result and a lazily-taken `GetLastError`, returning `Result`. +Four tests assert the whole contract -- a non-zero return is still pending (a packet is coming even though +the I/O finished), `ERROR_IO_PENDING` is pending, a genuine failure is a failure carrying the OS code, and +an error with no OS code falls through to failure rather than matching the pending comparison. The success +test also passes a `last_error` closure that panics, pinning the rule that `GetLastError` must not be read +after a success, where it holds a stale value from some unrelated call. + +**The detail that made the first attempt insufficient, and why it is worth recording.** The extraction +initially took a `bool`, leaving `ok != 0` at the call site. Injecting *that* comparison was still "caught" +only by the process dying with `STATUS_HEAP_CORRUPTION` (exit `0xC0000374`) -- the exact failure mode this +item exists to remove, simply relocated by one operand. The function now takes the raw `BOOL` and does the +`!= 0` itself, so the whole convention sits inside the tested surface and the call site has no comparison +left to get wrong. Verifying the fix rather than assuming it is what surfaced this. + +**Result.** Every mutant in the arming classification -- `!= 0` to `== 0`, to `true`, to `false`; the +pending equality to `!=`; and the failure arm to `Ok(Pending)` -- now fails with exit 101, a clean test +failure, in about a second each. None crashes, so the file's mutation score stops depending on heap +layout. + +**Ruled out beforehand, and left recorded so nobody repeats it: the shipping code was never at fault.** +55 runs of the unmutated suite (25 default-feature, 30 `--all-features`, including the exact binary named +in every crash report) produced zero failures, and all sixteen crash reports carry distinct PE timestamps, +none equal to the clean build's -- each was its own mutant build. The test binary's filename derives from +target and features rather than contents, which is why every report names the same `.exe` and why that +name alone proves nothing. \ No newline at end of file diff --git a/crates/windows-file-watcher/src/watcher.rs b/crates/windows-file-watcher/src/watcher.rs index fa605a5f..7c1e1596 100644 --- a/crates/windows-file-watcher/src/watcher.rs +++ b/crates/windows-file-watcher/src/watcher.rs @@ -463,16 +463,7 @@ impl WatcherInner { overlapped, None, ); - if ok != 0 { - // A synchronous success still delivers a completion, because - // the handle is not in skip-on-success mode. - return Ok(Issued::Pending); - } - let error = io::Error::last_os_error(); - if error.raw_os_error() == Some(ERROR_IO_PENDING as i32) { - return Ok(Issued::Pending); - } - Err(error) + classify_submission(ok, io::Error::last_os_error) }) }; @@ -1588,6 +1579,47 @@ impl Drop for DirectoryWatcher { } } +/// What `ReadDirectoryChangesW` reported, as the submission seam's answer to +/// "will a completion packet arrive?". +/// +/// Takes the raw `BOOL` rather than a `bool` on purpose: the `!= 0` convention is +/// part of the contract being tested, and leaving it at the call site would put +/// the most dangerous comparison in this crate back outside the reach of a test. +/// +/// A free function rather than inline in the submission closure, so this crate's +/// most dangerous misreading is observable by a test rather than only by its +/// wreckage. Getting either half wrong tells the thread pool to reclaim an I/O +/// the kernel is still going to complete, and the completion then lands in a +/// freed buffer -- a heap corruption whose visibility depends on allocator +/// behaviour, not a test result. That is not hypothetical: with the comparison +/// still at the call site, inverting it was "caught" only by the process dying +/// with `STATUS_HEAP_CORRUPTION`, and the same class of mutation was recorded +/// `MISSED` in one sweep and a crash in another. A crash is not a test (M15.5). +/// +/// Both of the native call's success shapes mean the same thing here, for the +/// reason [`Issued::Pending`] spells out: a packet is coming either way. A +/// non-zero return says the I/O is already done, not that the packet is not +/// coming, and `ERROR_IO_PENDING` says it has not finished yet. Only a genuine +/// failure means no packet will arrive, and only then may the caller reclaim the +/// buffer. +/// +/// `last_error` is taken lazily and must not be consulted after a success: +/// `GetLastError` is not meaningful when the call succeeded, so reading it there +/// would classify on a stale value left by some earlier, unrelated call. +fn classify_submission(returned: i32, last_error: F) -> Result +where + F: FnOnce() -> io::Error, +{ + if returned != 0 { + return Ok(Issued::Pending); + } + let error = last_error(); + if error.raw_os_error() == Some(ERROR_IO_PENDING as i32) { + return Ok(Issued::Pending); + } + Err(error) +} + /// Lock, recovering the guard if a previous holder panicked. fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(|poison| poison.into_inner()) diff --git a/crates/windows-file-watcher/src/watcher/tests.rs b/crates/windows-file-watcher/src/watcher/tests.rs index 53e07ad8..f7bf6a61 100644 --- a/crates/windows-file-watcher/src/watcher/tests.rs +++ b/crates/windows-file-watcher/src/watcher/tests.rs @@ -11,10 +11,13 @@ use std::path::Path; use std::sync::Arc; use std::time::Duration; -use windows_sys::Win32::Foundation::{ERROR_NOT_SUPPORTED, FILETIME, HANDLE}; +use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, ERROR_IO_PENDING, ERROR_NOT_SUPPORTED, FILETIME, + HANDLE, +}; use windows_sys::Win32::Storage::FileSystem::{FILE_WRITE_ATTRIBUTES, SetFileTime}; -use super::{ArmGate, DirectoryWatcher, ReadBuffer, lock}; +use super::{ArmGate, DirectoryWatcher, Issued, ReadBuffer, lock}; use crate::directory::{ DirectoryHandle, FailureCode, FaultDetail, OpenFailure, VolumeIdentity, classify_detail, }; @@ -1956,3 +1959,78 @@ fn changing_a_files_permissions_is_reported_as_modified() { drop(watcher); dir.cleanup(); } + +// --- the arming contract: which submission results mean "a packet is coming" --- +// +// This is the crate's most dangerous misreading, and before these tests the only +// thing standing between getting it wrong and a green suite was whether the +// allocator happened to notice. Telling the pool that a genuinely-pending read +// failed makes it cancel its accounting for an I/O the kernel is still going to +// complete, and the completion lands in a freed buffer -- so detection depended +// on heap layout. The same mutation was recorded `MISSED` in one sweep and +// crashed the process in another; two crashes were even scored as "caught" +// purely because the process exited non-zero. A crash is not a test (M15.5). +// +// `classify_submission` exists so the contract can be asserted directly instead. + +#[test] +fn a_synchronous_success_still_means_a_completion_is_coming() { + // The counter-intuitive half: `TRUE` says the *I/O* finished, not that the + // *packet* is not coming. Treating it as "done, nothing to wait for" would + // free a buffer the kernel has already queued a completion for. + let issued = super::classify_submission(1, || { + panic!("GetLastError must not be consulted after a success") + }); + assert!( + matches!(issued, Ok(Issued::Pending)), + "a native success must still be reported as pending" + ); +} + +#[test] +fn a_pending_submission_is_armed_rather_than_failed() { + // The half that corrupts the heap when it is wrong. + let issued = super::classify_submission(0, || { + std::io::Error::from_raw_os_error(ERROR_IO_PENDING as i32) + }); + assert!( + matches!(issued, Ok(Issued::Pending)), + "ERROR_IO_PENDING is how a submission reports success, not failure" + ); +} + +#[test] +fn a_genuinely_failed_submission_is_reported_as_failed() { + // The other direction, and the one that hangs a watcher rather than + // corrupting it: claiming a failed submission is pending leaves the pool + // waiting for a completion that will never arrive. `ERROR_INVALID_PARAMETER` + // is the real case -- it is what a directory handle opened by file id + // answers (D-80). + for code in [ + ERROR_INVALID_PARAMETER as i32, + ERROR_ACCESS_DENIED as i32, + ERROR_NOT_SUPPORTED as i32, + ] { + let issued = super::classify_submission(0, || std::io::Error::from_raw_os_error(code)); + match issued { + Err(error) => assert_eq!( + error.raw_os_error(), + Some(code), + "the failure must be reported with the code the OS gave" + ), + Ok(_) => panic!("error {code} must not be reported as armed"), + } + } +} + +#[test] +fn a_submission_failure_with_no_os_code_is_still_a_failure() { + // Nothing guarantees the seam only ever sees OS-coded errors, and the + // pending test is an equality against a code -- an error carrying none must + // fall through to the failure arm rather than matching anything. + let issued = super::classify_submission(0, || std::io::Error::other("no raw code")); + assert!( + issued.is_err(), + "an error with no OS code cannot be a pending submission" + ); +} From d9665c585fed01f8da8d770ad426e8434a64d5f2 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 20:34:46 -0400 Subject: [PATCH 176/361] fix(waitable-queues): clamp the length gauges, and honour reservations in remaining Completed items: SH-9.1, SH-9.2, SH-9.3 Completed item: SH-9.1: both bounded shapes could report a length larger than their own capacity. `len` reads the producer-side position and then `head`, which are two instants rather than one, so a consumer draining past the sampled position makes the wrapping subtraction yield a number near the integer maximum -- a four-slot queue reporting four billion items through a public metric. The comment beside it defended the overestimate as "safe in the direction that matters for a backpressure gauge", which is true of a bounded overestimate and not of usize::MAX. Both are clamped to the capacity now, so the skew still resolves towards full while the impossible value is gone. Completed item: SH-9.2: reserving_mpsc inherited Bounded::remaining as `capacity - len`, and its `len` excludes reservations by design -- so an empty queue of four holding one reservation answered four while only three items fit, promising room for a push that is guaranteed to be refused. Overridden on both handles and both trait impls, reading the packed claim word ONCE so the position and the reservation count cannot be sampled at different instants (which is why they are packed together in the first place). `is_full` is now defined in terms of `remaining` rather than restating the rule beside it. Completed item: SH-9.3: the pull request description framed the change as CI and provenance work and mentioned windows-waitable-queues only under release tracking, while the majority of the diff is that crate's public API and its three lock-free queue implementations. Rewritten to lead with the shipped surface, its scope, and its compatibility position. The skewed load pair is written directly in the tests rather than raced for. An earlier version of these tests drove `push` through the CLAIM race hook and then called `len()` afterwards -- by which point the two values agree again, so it asserted nothing. The sabotage sweep caught that, which is the whole reason for running one; both tests now restore consistent state before the handles drop, because teardown walks head..tail and an inverted pair sets it a usize::MAX-long loop that hangs rather than fails. Sabotage-verified: 4 of 4 caught. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 22 ++++ .../src/reserving_mpsc.rs | 72 +++++++++++- .../src/reserving_mpsc/tests.rs | 108 ++++++++++++++++++ .../src/slotwise_mpsc.rs | 10 +- .../src/slotwise_mpsc/tests.rs | 49 ++++++++ 5 files changed, 258 insertions(+), 3 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 2ab67105..2744ca44 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -432,3 +432,25 @@ that produces no thread is otherwise invisible to the "are all comments resolved exists to prevent. Iterate the online processors so every one is placed, and refuse a topology that names memory domains but not this processor's, rather than inventing one. + +## M9: PR #56 fourth review round + +- [x] **SH-9.1** -- **Both bounded shapes could report a length larger than their capacity.** + `len` reads the producer-side position and then `head`, which are two instants; a consumer draining + past the sampled position makes the wrapping subtraction yield a number near the integer maximum. The + comment beside it claimed the overestimate was "safe in the direction that matters for a backpressure + gauge", which is true of a *bounded* overestimate and not of `usize::MAX`. Both are now clamped to the + capacity, so the skew still resolves towards full -- the safe direction -- while the impossible value + is gone. + +- [x] **SH-9.2** -- **`reserving_mpsc` inherited a `remaining()` that counted reserved slots as room.** + `Bounded::remaining` defaults to `capacity - len`, and this shape's `len` excludes reservations by + design, so an empty queue of four holding one reservation answered four while only three items fit -- + promising room for a push guaranteed to be refused. Overridden on both handles and both trait impls, + reading the packed claim word **once** so the position and the reservation count cannot be sampled at + different instants; `is_full` is now defined in terms of it rather than restating the rule. + +- [x] **SH-9.3** -- **The pull request description described the release plumbing, not the product.** + The body framed the change as CI and provenance work and mentioned `windows-waitable-queues` only + under release tracking, while the majority of the diff is that crate's public API and its three + lock-free queue implementations. Rewritten to lead with the shipped surface. diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 39476671..59f68139 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -420,10 +420,40 @@ impl Shared { /// Counts slots a producer has claimed but not yet finished writing, for the /// reason `slotwise_mpsc`'s does: counting only published items would need a walk of /// the ring, and this number is a metric rather than a control-flow input. + /// + /// **Clamped to the capacity**, for the reason given on `slotwise_mpsc`'s + /// twin: the claim word and `head` are two loads at two instants, so a + /// consumer draining past the sampled position makes the wrapping + /// subtraction produce a number near `u32::MAX`. A bounded queue must never + /// report holding more than it can. fn len(&self) -> usize { let position = position_of(self.claim.0.load(Ordering::Acquire)); let head = self.head.0.load(Ordering::Acquire); - position.wrapping_sub(head) as usize + (position.wrapping_sub(head) as usize).min(self.capacity) + } + + /// How many further items a best-effort push could still place, as a + /// snapshot. + /// + /// **Not `capacity - len()`, which is what the [`Bounded`](crate::Bounded) + /// default computes and is wrong for this shape.** `len` deliberately + /// excludes outstanding reservations, so on an empty queue of four with one + /// slot reserved the default answers four while only three items fit -- and + /// a caller sizing a batch from it would be told there is room the + /// reservation is holding. + /// + /// The claim word is read **once**: the position and the reservation count + /// share it precisely so the two cannot be sampled at different instants, + /// and reading it twice would reintroduce the skew this shape packs them + /// together to avoid. `head` is still a second load, so the result is + /// clamped for the reason `len` is. + fn remaining(&self) -> usize { + let word = self.claim.0.load(Ordering::Acquire); + let head = self.head.0.load(Ordering::Acquire); + let capacity = self.capacity_u32(); + let occupied = position_of(word).wrapping_sub(head).min(capacity); + let spoken_for = occupied.saturating_add(reserved_of(word)); + capacity.saturating_sub(spoken_for) as usize } /// Whether the consumer would find an item right now. @@ -722,7 +752,19 @@ impl Producer { /// another producer may take the last slot between this call and the push. #[must_use] pub fn is_full(&self) -> bool { - self.len() + self.outstanding_reservations() >= self.shared.capacity + self.remaining() == 0 + } + + /// How many further items a best-effort push could still place, as a + /// snapshot. + /// + /// **Reservations are subtracted**, unlike `capacity() - len()`: a reserved + /// slot is spoken for, so counting it as room would promise a push that + /// [`push`](Self::push) is guaranteed to refuse. Advisory only, like every + /// other gauge here. + #[must_use] + pub fn remaining(&self) -> usize { + self.shared.remaining() } /// Whether the consumer has been dropped. @@ -978,6 +1020,18 @@ impl Consumer { reserved_of(self.shared.claim.0.load(Ordering::Acquire)) as usize } + /// How many further items a best-effort push could still place, as a + /// snapshot. + /// + /// The same number [`Producer::remaining`] reports, and offered here for + /// the same reason `outstanding_reservations` is: a consumer deciding + /// whether to keep draining wants the producers' view of the room left, and + /// that view subtracts reservations rather than treating them as free. + #[must_use] + pub fn remaining(&self) -> usize { + self.shared.remaining() + } + /// Whether every producer and every outstanding reservation is gone. /// /// **Check this only after [`Self::pop`] has returned `None`.** A producer @@ -1181,6 +1235,13 @@ impl crate::Bounded for Producer { fn is_empty(&self) -> bool { Self::is_empty(self) } + + // Overridden, because the default `capacity - len` counts a reserved slot + // as room: `len` excludes reservations by design, so an empty queue of four + // holding one reservation would answer four while only three items fit. + fn remaining(&self) -> usize { + self.shared.remaining() + } } impl crate::Bounded for Consumer { @@ -1195,6 +1256,13 @@ impl crate::Bounded for Consumer { fn is_empty(&self) -> bool { Self::is_empty(self) } + + // The consumer's view has to agree with the producer's: both describe the + // same queue, and a caller generic over `Bounded` should not get a different + // answer depending on which handle it holds. + fn remaining(&self) -> usize { + Self::remaining(self) + } } impl Shared { diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index f26eef4c..92feaf40 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -1222,3 +1222,111 @@ fn the_debug_renderings_name_the_type_and_its_state() { "got {rendered}" ); } + +// --------------------------------------------------------------------------- +// The gauges: `len` under a skewed pair of loads, and `remaining` against the +// reservations `len` deliberately excludes. +// --------------------------------------------------------------------------- + +#[test] +fn remaining_subtracts_outstanding_reservations() { + // The defect. `Bounded`'s default is `capacity - len`, and `len` excludes + // reservations by design, so an empty queue of four holding one reservation + // answered four -- promising room for a fourth item that `push` is + // guaranteed to refuse, because the reservation is holding the slot. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("an empty queue has room"); + + assert_eq!(tx.len(), 0, "a reservation is not an item"); + assert_eq!( + tx.remaining(), + 3, + "one of the four slots is spoken for by the reservation" + ); + assert_eq!( + rx.remaining(), + 3, + "both handles describe the same queue and must agree" + ); + + // And the number is honest: exactly three further pushes fit. + for i in 0..3 { + tx.push(i).expect("remaining() said there was room"); + } + assert_eq!(tx.remaining(), 0); + assert!(tx.is_full(), "no unreserved slot is left"); + assert!(matches!(tx.push(99), Err(PushError::Full(99)))); + + slot.send(7).expect("the consumer is still here"); + assert_eq!(rx.len(), 4, "the redeemed reservation is now an item"); +} + +#[test] +fn remaining_agrees_through_the_bounded_trait() { + // The override is on the trait impls, not only the inherent methods: a + // caller generic over `Bounded` is exactly who would be misled by the + // default, since it cannot reach `outstanding_reservations` to correct it. + fn room_through_trait(handle: &B) -> usize { + handle.remaining() + } + + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let _slot = tx.reserve().expect("an empty queue has room"); + + assert_eq!(room_through_trait(&tx), 3); + assert_eq!(room_through_trait(&rx), 3); +} + +#[test] +fn the_gauges_are_clamped_when_head_has_passed_the_sampled_position() { + // `len` and `remaining` each read the claim word and then `head`, which are + // two instants rather than one. If the consumer drains past the position + // the claim held, `head` overtakes it and `wrapping_sub` yields a number + // near `u32::MAX` -- a four-slot queue reporting four billion items, and + // four billion slots of room, straight out of a public metric. + // + // The skewed pair is written directly rather than raced for. The CLAIM hook + // opens the window inside `push`, but by the time `push` returns the two + // values agree again, so a test that called `len()` afterwards would assert + // nothing -- which is exactly what an earlier version of this test did, and + // a sabotage run caught it doing. + let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); + + tx.shared.claim.0.store(claim_word(0, 1), Ordering::Release); + tx.shared.head.0.store(2, Ordering::Release); + + assert_eq!( + tx.len(), + tx.capacity(), + "a bounded queue must never report holding more than it can" + ); + assert_eq!( + tx.remaining(), + 0, + "the clamp must resolve towards full, which is the safe direction" + ); + assert!(tx.is_full()); + + // **Restored before the handles drop, and this is not tidiness.** Teardown + // walks from `head` to the claim position to dispose whatever is still + // held, so leaving `head` ahead sets that walk a `u32::MAX`-length loop and + // the test hangs rather than fails. Measured the hard way. + tx.shared.head.0.store(0, Ordering::Release); + tx.shared.claim.0.store(claim_word(0, 0), Ordering::Release); +} + +#[test] +fn the_gauges_are_exact_when_the_two_loads_agree() { + // The guard must not have been bought by clamping everything: an ordinary + // reading still reports the true count and the true room. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + + assert_eq!(tx.len(), 2); + assert_eq!(tx.remaining(), 2); + assert!(!tx.is_full()); + assert_eq!(rx.pop(), Some(1)); + assert_eq!(tx.len(), 1); + assert_eq!(tx.remaining(), 3); +} diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index 63b018e1..ac66963e 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -306,10 +306,18 @@ impl Shared { /// matters for a backpressure gauge, and it is never used to decide whether /// to wait: [`Consumer::arm`] asks [`Shared::has_ready_item`] instead, /// which is the exact question `pop` answers. + /// + /// **Clamped to the capacity, because the two loads are not one instant.** + /// `tail` is read first; if the consumer then drains past the value it + /// held, `head` overtakes it and the wrapping subtraction yields a number + /// near `usize::MAX` -- a bounded queue claiming to hold more items than it + /// has slots. Over-reporting is the safe direction for this gauge and + /// under-reporting is not, so the skew is resolved towards "full" rather + /// than towards zero; what the clamp removes is only the impossible value. fn len(&self) -> usize { let tail = self.tail.0.load(Ordering::Acquire); let head = self.head.0.load(Ordering::Acquire); - tail.wrapping_sub(head) + tail.wrapping_sub(head).min(self.capacity) } /// Whether the consumer would find an item right now. diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs b/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs index bebd01e1..82701ab2 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc/tests.rs @@ -1295,3 +1295,52 @@ fn the_debug_renderings_name_the_type_and_its_state() { "got {consumer}" ); } + +#[test] +fn len_is_clamped_when_head_has_passed_the_sampled_tail() { + // `len` reads `tail` and then `head`, which are two instants rather than + // one. If the consumer drains past the value `tail` held, `head` overtakes + // it and `tail.wrapping_sub(head)` yields a number near `usize::MAX` -- a + // four-slot queue reporting four billion items through a public metric. + // + // The skewed pair is written directly rather than raced for: it is a + // transient a reader observes, not a state the queue rests in, so a + // scheduler could only be asked to produce it by chance. Writing it makes + // the arithmetic the assertion is actually about deterministic. + let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); + + tx.shared.tail.0.store(1, Ordering::Release); + tx.shared.head.0.store(2, Ordering::Release); + + assert_eq!( + tx.len(), + tx.capacity(), + "a bounded queue must never report holding more than it can" + ); + assert_eq!( + crate::Bounded::remaining(&tx), + 0, + "the clamp must resolve towards full, which is the safe direction" + ); + + // **Restored before the handles drop, and this is not tidiness.** Teardown + // walks `head..tail` to dispose whatever the queue still holds, so leaving + // `head` ahead of `tail` sets that walk a `usize::MAX`-length loop and the + // test hangs instead of failing. Measured the hard way. + tx.shared.head.0.store(0, Ordering::Release); + tx.shared.tail.0.store(0, Ordering::Release); +} + +#[test] +fn len_is_exact_when_the_two_loads_agree() { + // The guard must not have been bought by clamping everything: an ordinary + // reading still reports the true count rather than the capacity. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + + assert_eq!(tx.len(), 2); + assert_eq!(crate::Bounded::remaining(&tx), 2); + assert_eq!(rx.pop(), Some(1)); + assert_eq!(tx.len(), 1); +} From f9b57db522aba3ea75e4fadcc9c05c05a0819530 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 20:44:39 -0400 Subject: [PATCH 177/361] fix(placement-probe): publish atomically, discover once, and build on 32-bit Completed items: SH-10.1, SH-10.2, SH-10.3 Completed item: SH-10.1: reserving_mpsc's BOUNDS_MAX was a flat `1 << 31`, derived from the packed position's width alone. On a 32-bit target the crate-wide WRAPPING_MAX_CAPACITY is `usize::MAX / 2` -- that is `2^31 - 1`, and therefore NARROWER than the packing -- so the const assertion that no shape may exceed it fails the build outright, for every capacity including small valid ones. It is now the narrower of the two limits, kept a power of two so the value stays one a caller could actually pass, with a new assertion pinning that. Verified both ways against a real i686-pc-windows-msvc check: the old constant fails with E0080, the new one compiles. Completed item: SH-10.2: the previous round's fix reserved the destination with create_new and renamed onto it. That closed the truncated-file case and opened a worse one -- an empty file under the record's own name for the whole duration of the write, and left there permanently if the process was killed in that window, which contradicts the absent-or-complete guarantee its own doc comment claimed. The content is now written to a temporary in full and published with a single atomic no-replace MoveFileExW. std::fs::rename cannot express this: on Windows it always passes MOVEFILE_REPLACE_EXISTING, so it would silently clobber a record a concurrent run had already placed, destroying what the collision suffix exists to protect. Completed item: SH-10.3: the tool discovered the topology three times -- the plan from one reading, the fingerprint from another, and core_affinity::measure from a third -- so a processor going offline mid-run could leave the announced plan, the recorded host, and the measured rows describing different machines with nothing saying which. The plan and the fingerprint now derive from one discovery. `measure` still discovers its own, deliberately: its documentation refuses a measure_with(places) seam because a supplied list's processor numbers stay valid on the real host while its node labels need not, so every pin would succeed and real timings would be filed under fabricated labels. Its rows carry their own places, so each row states what it measured. Sabotage-verified: 2 of 2 caught on the publication guarantees (replacing an existing record, and creating the destination before the content exists). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 27 +++ crates/windows-placement-probe/Cargo.toml | 6 + .../src/bin/placement_probe/main.rs | 157 +++++++++++------- .../src/bin/placement_probe/tests.rs | 55 ++++++ .../src/reserving_mpsc.rs | 25 ++- 5 files changed, 212 insertions(+), 58 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 2744ca44..d6d9dd42 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -454,3 +454,30 @@ that produces no thread is otherwise invisible to the "are all comments resolved The body framed the change as CI and provenance work and mentioned `windows-waitable-queues` only under release tracking, while the majority of the diff is that crate's public API and its three lock-free queue implementations. Rewritten to lead with the shipped surface. + +## M10: PR #56 fifth review round + +- [x] **SH-10.1** -- **`BOUNDS_MAX` does not compile on a 32-bit target.** `reserving_mpsc`'s maximum + was a flat `1 << 31`, derived from the packed position's width alone. On a 32-bit target the + crate-wide `WRAPPING_MAX_CAPACITY` is `usize::MAX / 2`, which is `2^31 - 1` -- *narrower* than the + packing -- so the const assertion that no shape may exceed it fails the build outright, for every + capacity including the small valid ones. Now the narrower of the two limits, kept a power of two so + the value stays one a caller could actually pass. Verified in both directions against a real + `i686-pc-windows-msvc` check: the old constant fails with `E0080`, the new one compiles. + +- [x] **SH-10.2** -- **The backup's final name was visible empty for the whole write.** The previous + round reserved the destination with `create_new` and renamed onto it, which fixed the truncated-file + case and left a worse one: an empty file under the record's own name for the duration of the write, + and permanently if the process was killed in that window -- contradicting the absent-or-complete + guarantee its own doc comment claimed. Publication is now a single atomic no-replace `MoveFileExW` + from a fully-written temporary. `std::fs::rename` cannot express this: on Windows it always passes + `MOVEFILE_REPLACE_EXISTING`, so it would clobber a record a concurrent run had placed. + +- [x] **SH-10.3** -- **The tool discovered the topology three times.** The plan used one reading, the + fingerprint another, and `core_affinity::measure` a third, so a processor going offline mid-run could + have the announced plan, the recorded host, and the measured rows describing different machines with + nothing saying which. The plan and the fingerprint now derive from one `Topology::discover`. + `measure` still discovers its own, and deliberately so: its documentation refuses a + `measure_with(places)` seam because a supplied list's processor *numbers* stay valid on the real host + while its node labels need not, so every pin would succeed and real timings would be filed under + fabricated labels. Its rows carry their own places, so each row states what it measured. diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index 353cb881..762d4b36 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -87,4 +87,10 @@ features = [ # Asking is not the same as achieving, and the record reports the second. "Win32_System_Memory", "Win32_System_ProcessStatus", + # `MoveFileExW` *without* MOVEFILE_REPLACE_EXISTING, which is the only way to + # publish the backup record atomically without ever making its final name + # visible half-written. `std::fs::rename` cannot express it: on Windows it + # always passes MOVEFILE_REPLACE_EXISTING, so it would clobber a record a + # concurrent run had already placed. + "Win32_Storage_FileSystem", ] diff --git a/crates/windows-placement-probe/src/bin/placement_probe/main.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs index dd8c491b..6a9b335a 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/main.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -12,10 +12,11 @@ mod tests; use windows_placement_probe::build_identity::BuildIdentity; use windows_placement_probe::core_affinity::{self, RunPlan}; -use windows_placement_probe::fingerprint::{Fingerprint, discover_places}; +use windows_placement_probe::fingerprint::{Fingerprint, places_from_topology}; use windows_placement_probe::machine::MachineDescription; use windows_placement_probe::record::SubmissionRecord; use windows_placement_probe::submission::{self, DISCUSSION_URL}; +use windows_topology_sys::Topology; /// What the run was asked to do. struct Options { @@ -62,26 +63,39 @@ fn main() -> ExitCode { let machine = MachineDescription::read(options.suppress_model); - let places = match discover_places() { - Ok(places) => places, + // **One discovery, two derivations.** The announced plan and the recorded + // fingerprint used to come from separate `Topology::discover()` calls, so a + // processor going offline between them would have the notice describing one + // machine and the record another, with nothing in the output saying which + // was which. Both now come from this reading. + let topology = match Topology::discover() { + Ok(topology) => topology, Err(error) => { eprintln!("could not read this machine's topology: {error}"); return ExitCode::FAILURE; } }; - let plan = RunPlan::for_processors(&places); - // Read before the notice, not after the measurement, because the notice is - // what a runner decides on and it cannot show a value it does not have. - // One reading serves both the notice and the record, so the two can never - // describe different machines. - let host = match Fingerprint::discover() { - Ok(host) => host, + let places = match places_from_topology(&topology) { + Ok(places) => places, Err(error) => { - eprintln!("could not read this machine's shape: {error}"); + eprintln!("could not read this machine's topology: {error}"); return ExitCode::FAILURE; } }; + let plan = RunPlan::for_processors(&places); + + // Derived from the same topology as the plan, and read before the notice + // rather than after the measurement, because the notice is what a runner + // decides on and it cannot show a value it does not have. + // + // `core_affinity::measure` deliberately discovers again rather than being + // handed these places, and that is not an oversight -- see its + // documentation. A `measure_with(places)` seam would accept a processor list + // whose *numbers* are valid on this host while its node labels are not, so + // every pin would succeed and real timings would be filed under fabricated + // labels. Its rows carry their own places, so each row says what it measured. + let host = Fingerprint::from_topology(&topology); print_collection_notice(&machine, &host, options.suppress_model); print_plan(&plan); @@ -238,16 +252,21 @@ fn write_backup(record: &SubmissionRecord) { /// suffix resolves it. The suffix is only reached on a real collision, so the /// ordinary name stays the predictable one. /// -/// **The bytes are published by rename, and that is a second correction.** -/// Reserving the name and then writing into it means a failure part-way through -/// the write -- a full disk, a quota, a killed process -- leaves a truncated -/// `.json` sitting under the name a *complete* record would have. Nothing -/// downstream can tell the two apart: whoever collects the file sees a record, -/// and the next run's collision suffix steps politely around the wreckage. So -/// the reserved name is a placeholder, the content is written to a temporary -/// beside it, and the temporary is moved onto the reservation only once the -/// write has succeeded. A reader therefore sees the final name either absent or -/// complete, never half-written. +/// **The record's name never exists half-written, and this is a second +/// correction -- twice over.** Writing straight into the final name leaves a +/// truncated `.json` behind when the write fails, indistinguishable to a +/// collector from a complete record. Reserving the final name with an empty file +/// and renaming onto it afterwards fixes only half of that: the empty +/// reservation is *itself* visible under the record's name for the whole +/// duration of the write, and a process killed in that window leaves it there +/// permanently. +/// +/// So the content is written to a temporary and **published with a single +/// atomic no-replace move**. The final name comes into existence already +/// complete, or not at all. `std::fs::rename` cannot be used for this: on +/// Windows it always passes `MOVEFILE_REPLACE_EXISTING`, so it would silently +/// clobber a record another run had placed while this one was writing -- +/// destroying the very collision guarantee the suffix exists to provide. fn write_backup_to_new_file(name: &str, json: &str) -> std::io::Result { write_backup_with(name, json, |file, bytes| { std::io::Write::write_all(file, bytes) @@ -256,10 +275,10 @@ fn write_backup_to_new_file(name: &str, json: &str) -> std::io::Result { /// The body of [`write_backup_to_new_file`], with the write itself injectable. /// -/// The failure this guards is a write that fails *after* the name is taken, and +/// The failure this guards is a write that fails after the temporary exists, and /// no test can provoke that by filling the disk. The seam is the smallest thing /// that makes it reachable: a test supplies a writer that fails, and asserts -/// that nothing is left behind under either name. +/// that nothing is left behind under any name. fn write_backup_with( name: &str, json: &str, @@ -269,6 +288,15 @@ fn write_backup_with( /// failing is better than looping while a caller waits. const MAX_ATTEMPTS: u32 = 100; + // Written first and in full, so every candidate below is offered a file that + // is already complete. Publication is then a single move per candidate, + // rather than a window in which a name exists but its content does not. + let (temporary, outcome) = write_temporary(name, json, &mut write)?; + if let Err(error) = outcome { + let _ = std::fs::remove_file(&temporary); + return Err(error); + } + for attempt in 0..MAX_ATTEMPTS { let candidate = if attempt == 0 { name.to_owned() @@ -279,59 +307,74 @@ fn write_backup_with( } }; - // Reserve the name first, so a concurrent run cannot pick it while this - // one is still writing. The file stays empty until the rename below. - match std::fs::File::create_new(&candidate) { - Ok(reservation) => { - drop(reservation); - return match publish(&candidate, json, &mut write) { - Ok(()) => Ok(candidate), - Err(error) => { - // The reservation is this function's own litter once the - // write has failed, and leaving it would present an - // empty file under the name of a complete record. - let _ = std::fs::remove_file(&candidate); - Err(error) - } - }; - } + match publish(&temporary, &candidate) { + Ok(()) => return Ok(candidate), // Someone else has this name. Not a failure yet: try the next. Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(error) => return Err(error), + Err(error) => { + let _ = std::fs::remove_file(&temporary); + return Err(error); + } } } + let _ = std::fs::remove_file(&temporary); Err(std::io::Error::new( std::io::ErrorKind::AlreadyExists, format!("{MAX_ATTEMPTS} names starting from {name} were all taken"), )) } -/// Write `json` beside `final_name` and move it there once it is complete. +/// Create a temporary beside `name` and fill it, returning its path and the +/// write's outcome. /// -/// The temporary is created exclusively too, and in the same directory, so the -/// rename is within one volume and cannot silently become a copy. -fn publish( - final_name: &str, +/// The path is returned even when the write failed, so the caller can remove it: +/// a temporary left behind is litter under a name a collector might not +/// recognise, which is only marginally better than litter under one it would. +fn write_temporary( + name: &str, json: &str, write: &mut impl FnMut(&mut std::fs::File, &[u8]) -> std::io::Result<()>, -) -> std::io::Result<()> { - let temporary = format!("{final_name}.{}.partial", std::process::id()); - +) -> std::io::Result<(String, std::io::Result<()>)> { + // The process id keeps two concurrent runs from colliding here, and the + // `.partial` suffix keeps the file out of any `*.json` collection. + let temporary = format!("{name}.{}.partial", std::process::id()); let mut file = std::fs::File::create_new(&temporary)?; - let written = write(&mut file, json.as_bytes()).and_then(|()| file.sync_all()); + let outcome = write(&mut file, json.as_bytes()).and_then(|()| file.sync_all()); drop(file); + Ok((temporary, outcome)) +} - if let Err(error) = written { - let _ = std::fs::remove_file(&temporary); - return Err(error); +/// Move `temporary` onto `final_name`, failing rather than replacing. +/// +/// # Why not `std::fs::rename` +/// +/// On Windows it passes `MOVEFILE_REPLACE_EXISTING`, so it would overwrite a +/// record another run had already written -- silently undoing the collision +/// handling the caller's suffix loop exists to provide. Without that flag +/// `MoveFileExW` fails with `ERROR_ALREADY_EXISTS` instead, which is exactly the +/// signal the loop wants, and the move is atomic: the destination appears +/// complete or does not appear. +fn publish(temporary: &str, final_name: &str) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt as _; + + fn wide(path: &str) -> Vec { + std::ffi::OsStr::new(path) + .encode_wide() + .chain(std::iter::once(0)) + .collect() } - // Replaces the reservation, which is what makes the publication atomic from - // a reader's point of view. - if let Err(error) = std::fs::rename(&temporary, final_name) { - let _ = std::fs::remove_file(&temporary); - return Err(error); + let from = wide(temporary); + let to = wide(final_name); + // SAFETY: both pointers address NUL-terminated wide strings that outlive the + // call, and the flags word is zero, which is the documented "fail if the + // destination exists" behaviour rather than a sentinel. + let moved = unsafe { + windows_sys::Win32::Storage::FileSystem::MoveFileExW(from.as_ptr(), to.as_ptr(), 0) + }; + if moved == 0 { + return Err(std::io::Error::last_os_error()); } Ok(()) } diff --git a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs index d77dd163..b1e5b647 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs @@ -200,3 +200,58 @@ fn a_successful_write_leaves_only_the_record() { assert_eq!(left.len(), 1, "expected only the record, found {left:?}"); assert_eq!(left[0].to_str().expect("utf-8 path"), written); } + +#[test] +fn publication_never_replaces_a_record_another_run_already_placed() { + // The no-replace half of the publication guarantee. `std::fs::rename` on + // Windows always passes MOVEFILE_REPLACE_EXISTING, so publishing with it + // would silently overwrite a complete record another run had written -- + // destroying exactly what the collision suffix exists to protect, and doing + // it in the window where this run had not yet finished writing. + let dir = scratch("no-replace"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + // Stand in for a record another process completed a moment ago. + std::fs::write(name, "PLACED BY SOMEONE ELSE").expect("writable"); + + let written = write_backup_to_new_file(name, "MINE").expect("must find a free name"); + + assert_ne!(written, name, "the existing record's name was taken"); + assert_eq!( + std::fs::read_to_string(name).expect("readable"), + "PLACED BY SOMEONE ELSE", + "the existing record must survive byte-for-byte" + ); + assert_eq!(std::fs::read_to_string(&written).expect("readable"), "MINE"); +} + +#[test] +fn a_failed_write_never_creates_the_records_name_at_all() { + // The absent-or-complete half. An earlier version reserved the final name + // with an empty file and renamed onto it afterwards, which left that empty + // file visible under the record's own name for the whole duration of the + // write -- and permanently, if the process was killed in that window. The + // name must now come into existence already complete, or not at all. + let dir = scratch("never-created"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + let _ = super::write_backup_with(name, "{}", |_, _| { + Err(std::io::Error::new( + std::io::ErrorKind::StorageFull, + "no space left on device", + )) + }) + .expect_err("the injected write fails"); + + assert!( + !std::path::Path::new(name).exists(), + "the record's name must never have been created" + ); + let left: Vec<_> = std::fs::read_dir(&dir) + .expect("readable") + .map(|entry| entry.expect("entry").file_name()) + .collect(); + assert!(left.is_empty(), "nothing at all should remain: {left:?}"); +} diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 59f68139..b6e627bb 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -124,7 +124,24 @@ const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; /// half of the packed claim word rather than a whole [`usize`]. A wrapping /// 32-bit difference is unambiguous only up to 2^31, and that is exactly the /// most items this shape can hold. -pub const BOUNDS_MAX: usize = 1 << (POSITION_BITS - 1); +/// +/// **Bounded by the `usize` width as well as by the packing, because on a +/// 32-bit target the packing is the *wider* of the two.** There, +/// [`WRAPPING_MAX_CAPACITY`] is `2^31 - 1`, so a flat `1 << 31` exceeds the +/// crate-wide ceiling and the assertion below rejects it -- failing the build +/// for every capacity, including the small valid ones. Taking the narrower of +/// the two limits keeps this a power of two on every target, which matters +/// because the value is offered to a caller as a capacity it could actually +/// use. +pub const BOUNDS_MAX: usize = { + let packed = 1_usize << (POSITION_BITS - 1); + let widest_usize_power_of_two = 1_usize << (usize::BITS - 2); + if packed <= widest_usize_power_of_two { + packed + } else { + widest_usize_power_of_two + } +}; /// The capacities this shape accepts. See [`BOUNDS_MAX`]. const BOUNDS: Bounds = Bounds { @@ -168,6 +185,12 @@ const _: () = { BOUNDS.max <= WRAPPING_MAX_CAPACITY, "a shape may be narrower than the crate-wide bound but never wider" ); + assert!( + BOUNDS.max.is_power_of_two(), + "the maximum is offered to a caller as a capacity it could use, so it must itself be one \ + this shape would accept -- and on a 32-bit target the crate-wide ceiling is not a power \ + of two, so clamping to it directly would have produced a suggestion that is rejected" + ); assert!( BOUNDS.min <= BOUNDS.max, "a shape that accepts nothing would reject every capacity with a suggestion it would also \ From 068548bf04153ef9e6613f544c5fde065f0d83d6 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 20:52:53 -0400 Subject: [PATCH 178/361] fix(waitable-queues): correct spsc's remaining, which the last round missed Completed item: SH-10.4: spsc implements Reserving as well, and the previous round corrected only reserving_mpsc -- so reserving every slot left this shape reporting the full capacity as available while both push and reserve refused. The finding is the same one, on the shape that was not looked at. Its Bounded impls now override `remaining` on the producer and the consumer alike, `len` is clamped to the capacity as the other two shapes' gauges are (the two position loads are not one instant, so a consumer draining past the sampled tail made the wrapping subtraction produce a number near usize::MAX), and `is_full` is defined in terms of `remaining` rather than restating the rule beside it. The trait's default now says outright that a Reserving shape must override it, and why: a reservation withdraws capacity without becoming an item, so it never appears in `len` and `capacity - len` therefore promises room that `push` is guaranteed to refuse. That is the part that stops the next shape which reserves from inheriting the same wrong answer silently, which is exactly how this one survived the previous round. Sabotage-verified: 3 of 3 caught -- dropping the reservation term, removing the trait override so the default is inherited again, and un-clamping `len`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 8 ++ crates/windows-waitable-queues/src/spsc.rs | 49 +++++++- .../windows-waitable-queues/src/spsc/tests.rs | 110 ++++++++++++++++++ crates/windows-waitable-queues/src/traits.rs | 8 ++ 4 files changed, 173 insertions(+), 2 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index d6d9dd42..1f6f183d 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -481,3 +481,11 @@ that produces no thread is otherwise invisible to the "are all comments resolved `measure_with(places)` seam because a supplied list's processor *numbers* stay valid on the real host while its node labels need not, so every pin would succeed and real timings would be filed under fabricated labels. Its rows carry their own places, so each row states what it measured. + +- [x] **SH-10.4** -- **`spsc` had the same `remaining()` defect, and it was missed.** The previous round + corrected `reserving_mpsc` and stopped there, but `spsc` implements `Reserving` too -- so reserving + every slot left it reporting the full capacity as available while both `push` and `reserve` refused. + Its `Bounded` impls now override `remaining` on the producer *and* the consumer, its `len` is clamped + to the capacity like the other two shapes', and `is_full` is defined in terms of `remaining` rather + than restating the rule. The trait's default now documents that a `Reserving` shape must override it, + so the next shape to reserve does not inherit the same wrong answer silently. diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index ce79daf4..48c312d4 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -260,10 +260,28 @@ impl Shared { /// consistent with the items it can actually observe. It is a snapshot the /// moment it is returned: the peer may push or pop immediately afterwards, /// which is why nothing here invites a check-then-act. + /// + /// **Clamped to the capacity**, for the reason the other shapes' gauges are: + /// `tail` is read before `head`, so a consumer draining past the sampled + /// value makes the wrapping subtraction produce a number near `usize::MAX`. + /// A bounded queue must never report holding more than it can. fn len(&self) -> usize { let tail = self.tail.0.load(Ordering::Acquire); let head = self.head.0.load(Ordering::Acquire); - tail.wrapping_sub(head) + tail.wrapping_sub(head).min(self.capacity) + } + + /// How many further items a best-effort push could still place. + /// + /// **Not `capacity - len()`, which is what the [`Bounded`](crate::Bounded) + /// default computes and is wrong for this shape too.** A reservation + /// withdraws a slot without becoming an item, so after reserving every slot + /// the default still answers the full capacity while both `push` and + /// `reserve` refuse. + fn remaining(&self) -> usize { + let held = self.len(); + let reserved = self.reserved.load(Ordering::Relaxed); + self.capacity.saturating_sub(held.saturating_add(reserved)) } /// Write an item into the slot at `tail` and publish it. @@ -427,7 +445,19 @@ impl Producer { /// offered for metrics rather than for control flow. #[must_use] pub fn is_full(&self) -> bool { - self.len() + self.outstanding_reservations() >= self.shared.capacity + self.remaining() == 0 + } + + /// How many further items a best-effort push could still place, as a + /// snapshot. + /// + /// **Reservations are subtracted**, unlike `capacity() - len()`: a reserved + /// slot is spoken for, so counting it as room would promise a push that + /// [`push`](Self::push) is guaranteed to refuse. Advisory only, like every + /// other gauge here. + #[must_use] + pub fn remaining(&self) -> usize { + self.shared.remaining() } /// Slots currently claimed by a [`Reservation`] and not yet redeemed. @@ -935,6 +965,14 @@ impl crate::Bounded for Producer { fn is_empty(&self) -> bool { Self::is_empty(self) } + + // Overridden, because the default `capacity - len` counts a reserved slot as + // room: a reservation withdraws capacity without becoming an item, so after + // reserving every slot the default would answer the full capacity while both + // `push` and `reserve` refuse. + fn remaining(&self) -> usize { + Self::remaining(self) + } } impl crate::Bounded for Consumer { @@ -949,6 +987,13 @@ impl crate::Bounded for Consumer { fn is_empty(&self) -> bool { Self::is_empty(self) } + + // The consumer's view has to agree with the producer's: both describe the + // same queue, and a caller generic over `Bounded` should not get a different + // answer depending on which handle it holds. + fn remaining(&self) -> usize { + self.shared.remaining() + } } impl Shared { diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index 6be1d2a7..ba9db09b 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -1685,3 +1685,113 @@ fn the_debug_renderings_name_the_type_and_its_state() { let rendered = format!("{reservation:?}"); assert!(rendered.contains("spsc::Reservation"), "got {rendered}"); } + +// --------------------------------------------------------------------------- +// The gauges: reservations withdraw capacity without becoming items, and the +// two position loads are not one instant. +// --------------------------------------------------------------------------- + +#[test] +fn remaining_subtracts_outstanding_reservations() { + // The defect. `Bounded`'s default is `capacity - len`, and a reservation + // withdraws a slot without becoming an item -- so with every slot reserved + // the default answered the full capacity while both `push` and `reserve` + // refuse. This shape reserves too, which is exactly what made it easy to + // miss when the sibling was fixed. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("an empty queue has room"); + + assert_eq!(tx.len(), 0, "a reservation is not an item"); + assert_eq!(tx.remaining(), 3, "one of the four slots is spoken for"); + assert_eq!( + crate::Bounded::remaining(&rx), + 3, + "both handles describe the same queue and must agree" + ); + + for i in 0..3 { + tx.push(i).expect("remaining() said there was room"); + } + assert_eq!(tx.remaining(), 0); + assert!(tx.is_full()); + assert!(matches!(tx.push(99), Err(PushError::Full(99)))); + + slot.send(7).expect("the consumer is still here"); + assert_eq!(rx.len(), 4, "the redeemed reservation is now an item"); +} + +#[test] +fn remaining_is_zero_when_every_slot_is_reserved() { + // The case the finding named directly: reserve everything, and the queue is + // empty of items yet has no room at all. + let (tx, _rx) = bounded::(2).expect("2 is a valid capacity"); + let _first = tx.reserve().expect("room"); + let _second = tx.reserve().expect("room"); + + assert_eq!(tx.len(), 0, "no item has been sent"); + assert_eq!( + tx.remaining(), + 0, + "every slot is spoken for, so nothing further fits" + ); + assert!(tx.is_full()); + assert!(tx.reserve().is_none(), "and no further slot can be claimed"); +} + +#[test] +fn remaining_agrees_through_the_bounded_trait() { + // The override is on the trait impls, not only the inherent methods: a + // caller generic over `Bounded` is exactly who would be misled by the + // default, since it cannot reach `outstanding_reservations` to correct it. + fn room_through_trait(handle: &B) -> usize { + handle.remaining() + } + + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let _slot = tx.reserve().expect("an empty queue has room"); + + assert_eq!(room_through_trait(&tx), 3); + assert_eq!(room_through_trait(&rx), 3); +} + +#[test] +fn len_is_clamped_when_head_has_passed_the_sampled_tail() { + // `len` reads `tail` and then `head`, which are two instants rather than + // one. If the consumer drains past the value `tail` held, `head` overtakes + // it and the wrapping subtraction yields a number near `usize::MAX`. + // + // The skewed pair is written directly rather than raced for: it is a + // transient a reader observes, not a state the queue rests in. + let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); + + tx.shared.tail.0.store(1, Ordering::Release); + tx.shared.head.0.store(2, Ordering::Release); + + assert_eq!( + tx.len(), + tx.capacity(), + "a bounded queue must never report holding more than it can" + ); + assert_eq!(tx.remaining(), 0, "the clamp resolves towards full"); + + // Restored before the handles drop: teardown walks `head..tail`, and an + // inverted pair sets it a `usize::MAX`-length loop that hangs rather than + // fails. + tx.shared.head.0.store(0, Ordering::Release); + tx.shared.tail.0.store(0, Ordering::Release); +} + +#[test] +fn the_gauges_are_exact_when_nothing_is_reserved_or_skewed() { + // The guard must not have been bought by clamping or subtracting always. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + + assert_eq!(tx.len(), 2); + assert_eq!(tx.remaining(), 2); + assert!(!tx.is_full()); + assert_eq!(rx.pop(), Some(1)); + assert_eq!(tx.len(), 1); + assert_eq!(tx.remaining(), 3); +} diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs index 61d0c619..53e9c677 100644 --- a/crates/windows-waitable-queues/src/traits.rs +++ b/crates/windows-waitable-queues/src/traits.rs @@ -144,6 +144,14 @@ pub trait Bounded { /// Saturating rather than wrapping, because a shape may count a slot that a /// producer has claimed but not yet finished writing, and a momentary /// overshoot should read as "no room" rather than as a very large number. + /// + /// **A [`Reserving`] shape must override this.** A reservation withdraws + /// capacity *without* becoming an item, so it does not appear in + /// [`len`](Self::len) -- and this default therefore reports room that + /// `push` is guaranteed to refuse. Both shipped reserving shapes override + /// it, on the producer and the consumer alike, and a new one that forgets to + /// will report a queue with every slot reserved as entirely empty of + /// commitments. fn remaining(&self) -> usize { self.capacity().saturating_sub(self.len()) } From 06f4ca089cf1faf3352badd8c8313ecf23765b93 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 21:00:38 -0400 Subject: [PATCH 179/361] test(file-watcher): lower NOTIFY_TIMEOUT to 5s, measured rather than guessed M15.7 framed the test-side wait budget as a trade against flake resistance that only the engineer could make. It is -- but it was being made with no measurement of what the budget absorbed. Instrumenting every wait in the suite: idle median 0ms p95 2.5ms max 506ms + a full release build median 25ms p95 94ms max 509ms 3x concurrent + a build median 0ms p95 2.5ms max 508ms 45 of 46 waits finish in 2.5ms or less, and the whole tail is one test gated on the retry backoff timer at ~515ms -- structural, not notification latency. None of it moves under 4x oversubscription, so the 30s budget was absorbing no contention it needed to. What it cost: when a mutation breaks delivery, dozens of these waits each burn the full budget on the way to failing, and the suite overruns cargo-mutants' kill deadline, so a detected mutant is filed `timeout` rather than `caught`. Measured on one of them, changing only NOTIFY_TIMEOUT: 93.6s at 30s (killed), 42.1s at 10s, 31.8s at 5s. Confirmed by a real queue.rs sweep, against the run that motivated the item: caught 78 -> 89, timeout 14 -> 8, wall clock 20min -> 14min. (missed 8 -> 0 is M15.1 and M15.8's doing, not this.) Six consecutive full-suite runs under sustained concurrent release builds were green. The residual risk is stated on the constant: this was one 12-core machine, 5s is ~10x the structural outlier, and a slower runner is unmeasured -- if it flakes, raise the number rather than doubting the tests. Also refreshes tools/run-mutants.ps1, whose parameter docs still said timeouts dominate the wall clock and pointed at the multiplier. They no longer do, and the general lesson replaces it: when mutants pile up in the timeout column, suspect the suite's own wait budget first -- a timeout is usually a detection that was not allowed to finish. Spawns M15.11. All 8 remaining timeouts are pinned by one test, no_wakeup_is_lost_under_a_concurrent_burst, whose wait is bounded while the loop around it is not: each mutant makes its exit condition permanently false while the doorbell stays signalled, so it spins. That is M15.6's defect one level up, and no further budget reduction can reach it. Completed item: M15.7: Decide the test-side wait budget, so a mutation sweep is not dominated by tests that correctly fail slowly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 39 +++++------ .../COMPLETED-CHECKLIST.md | 67 ++++++++++++++++++- .../windows-file-watcher/src/watcher/tests.rs | 21 +++++- .../tests/fault_detail.rs | 5 +- .../tests/watched_paths.rs | 12 ++-- tools/run-mutants.ps1 | 35 ++++++---- 6 files changed, 139 insertions(+), 40 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 8b869d0e..1a9bf36e 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -151,25 +151,26 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- - [x] **M15.6** -- Converted `queue/tests.rs` to bounded waiting, so a broken wake fails instead of hanging. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m156) -- [ ] **M15.7** -- Decide the test-side wait budget, so a mutation sweep is not dominated by tests that - correctly fail slowly. **This is a throughput decision, not a test gap -- do not close it by writing tests.** - **The measurement.** After M15.6, a full `queue.rs` sweep is 124 mutants in 20 minutes, and **14 x 67s = - 15.6 minutes of that is mutants scored `timeout`**. Every one of those 14 was already detected: between 4 - and 132 tests had `FAILED` before cargo-mutants killed the run. The kill happens because the suite exceeds - 3x the baseline, and it exceeds it because dozens of bounded waits each burn their full budget on the way - to failing. - **Where the budget lives.** `NOTIFY_TIMEOUT` in [src/watcher/tests.rs](src/watcher/tests.rs) is - `Duration::from_secs(30)`, plus several 5s and one 20s bound. Those numbers are generous on purpose -- - they are what keeps the suite from flaking on a loaded machine -- so lowering them trades sweep throughput - against exactly that robustness. That trade is the decision, and it is the engineer's. - **The options, none free.** (a) Lower `NOTIFY_TIMEOUT` and accept more flake risk under load. (b) Raise - `--timeout-multiplier` in [tools/run-mutants.ps1](../../tools/run-mutants.ps1) so a suite full of slow - failures still fits, which makes a genuine wedge cost proportionally more. (c) Leave it, and read - `timeout` as "detected" rather than "unknown" -- correct today, but only because it was checked by hand, - and nothing keeps it true. - **Read `missed` as the gap column.** After M15.6, `timeout` no longer distinguishes a wedge from a slow - detection, so a sweep's `timeout` list has to be adjudicated by counting `FAILED` lines in each log before - it means anything. +- [x] **M15.7** -- Decided and implemented: `NOTIFY_TIMEOUT` lowered 30s -> 5s across all three copies, after measuring that 45 of 46 waits finish in <=2.5ms and the whole tail is one structural ~515ms backoff, unchanged under 4x oversubscription. One previously-timing-out mutant: 93.6s -> 31.8s. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m157) + +- [ ] **M15.11** -- Bound the *loop* in `no_wakeup_is_lost_under_a_concurrent_burst`, not just the wait + inside it. **Found by M15.7's confirming sweep, and it is M15.6's defect one level up.** + **The measurement.** After M15.7, a `queue.rs` sweep is 121 mutants in 14 minutes with **8 timeouts, and + every one of the 8 is pinned by this single test** -- `Receiver::try_recv -> None`, `recv`'s `==` to + `!=`, `is_disconnected -> false`, `len -> 1`, `is_empty -> false` (x2), `latched -> 1`, and + `take -> None`. All 8 still had between 4 and 76 tests failed before the kill, so they are detections + rather than gaps; they just cost 67s each instead of failing. + **Why the budget fix did not reach it.** The test waits on the doorbell with a bounded `await_signal`, + then drains, then re-checks an exit condition of + `is_disconnected() && is_empty() && latched() == 0`. Each of those mutants makes that condition + permanently false while leaving the doorbell signalled, so the **outer loop** spins at full speed -- + bounded wait, unbounded loop. Lowering `NOTIFY_TIMEOUT` cannot touch it, and no further budget reduction + will. + **What is wanted.** A deadline on the loop itself (and ideally a no-progress bound: `seen` not advancing + across N iterations is the real symptom), so a broken predicate fails with what it saw rather than + spinning. That is the same transform M15.6 applied to `recv()`, applied one level out. + **Worth checking while there:** whether any other loop in the suite has this shape -- a bounded wait + inside an unbounded `loop`. M15.6 swept for unbounded `recv()`, which would not have found this one. ## M-inf -- Horizon (ungated, post-v1) diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index e2c459cc..d0bb8905 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -894,4 +894,69 @@ layout. in every crash report) produced zero failures, and all sixteen crash reports carry distinct PE timestamps, none equal to the clean build's -- each was its own mutant build. The test binary's filename derives from target and features rather than contents, which is why every report names the same `.exe` and why that -name alone proves nothing. \ No newline at end of file +name alone proves nothing. +## Moved 2026-09-01 -- M15.7: the test-side wait budget + +### M15.7 -- Decide the test-side wait budget, so a mutation sweep is not dominated by tests that correctly fail slowly. *(completed 2026-09-01 21:05:00 -04:00)* + +**Decision: `NOTIFY_TIMEOUT` lowered from 30s to 5s**, in all three copies (`src/watcher/tests.rs`, +`tests/fault_detail.rs`, `tests/watched_paths.rs`). The item framed this as a trade against flake +resistance that only the engineer could make, and it is -- but it was being made without any measurement +of what the budget was actually absorbing. Getting that first turned a taste call into an arithmetic one. + +**What the waits cost.** Every `wait_until` was instrumented and the suite run three ways: + +| condition | median | p95 | max | +|---|---|---|---| +| idle | 0 ms | 2.5 ms | 506 ms | +| + a full release build | 25 ms | 94 ms | 509 ms | +| 3x concurrent suites + a build | 0 ms | 2.5 ms | 508 ms | + +**45 of 46 waits complete in 2.5ms or less**, and the entire tail is a single test gated on the retry +*backoff timer* at ~515ms -- structural, not notification latency. The numbers barely move under 4x +oversubscription on a 12-core machine, which is the finding that mattered: the 30s budget was absorbing +no contention it needed to. + +**What lowering it buys, measured on one of the mutants that had been scored `timeout`** (changing only +`NOTIFY_TIMEOUT`; a first attempt also raised the unrelated 5s bounds and had to be redone): + +| budget | suite time | cargo-mutants verdict (67s kill) | +|---|---|---| +| 30s (as shipped) | 93.6s | TIMEOUT -- killed | +| 10s | 42.1s | caught, a clean red test | +| 5s | 31.8s | caught, a clean red test | + +That is the whole of the item's 15.6-minutes-of-20 problem: 14 mutants x 67s, every one already detected +(between 4 and 132 tests had failed before the kill) but filed as `timeout` rather than `caught`. + +**Stability check, since margin was deliberately traded away.** Six consecutive full-suite runs under +sustained concurrent release builds: 6/6 green. + +**The residual risk, recorded rather than smoothed over.** All of this came from one 12-core developer +machine. 5s is ~10x the structural outlier and ~2000x the p95, but a slower runner -- a 2-core CI box, or +one with antivirus scanning temp directories -- is unmeasured. The constant's doc comment says so, and +says the answer to a flake there is to raise the number, not to doubt the tests. + +**An option the item did not list, and why it was not taken.** An environment-variable override (default +30s, sweeps opting into 5s) would have avoided transferring any risk to CI. It was offered and declined in +favour of the simpler global change -- worth recording, because it is the fallback if the residual risk +above ever materialises. +**Confirmed end to end by a real sweep of `queue.rs`**, against the run that motivated the item: + +| | before (30s budget) | after (5s budget) | +|---|---|---| +| caught | 78 | **89** | +| missed | 8 | **0** | +| timeout | 14 | **8** | +| wall clock | 20 min | **14 min** | + +(`missed` reaching 0 is M15.1 and M15.8's doing, not this item's.) The six mutants that stopped timing out +moved into `caught`, exactly as the arithmetic predicted. + +**What the remaining 8 turned out to be -- a different defect, now M15.11.** All 8 still had between 4 and +76 tests failed before the kill, so they remain detections rather than gaps. But none is held by a +notification budget: every one is pinned by a single test, +`queue::tests::no_wakeup_is_lost_under_a_concurrent_burst`, whose *wait* is bounded while the **loop around +it is not**. Each of those mutants breaks that loop's exit condition, so it spins at full speed rather than +failing. That is M15.6's shape one level up, and M15.6 missed it because the bound sits on the wait rather +than on the loop -- lowering the budget further would not have touched it. \ No newline at end of file diff --git a/crates/windows-file-watcher/src/watcher/tests.rs b/crates/windows-file-watcher/src/watcher/tests.rs index f7bf6a61..789044d7 100644 --- a/crates/windows-file-watcher/src/watcher/tests.rs +++ b/crates/windows-file-watcher/src/watcher/tests.rs @@ -29,7 +29,26 @@ use crate::testing::TempDir; use crate::watch::{RetryMode, VolumeChangeDecision, VolumeChangePolicy}; /// Upper bound for waiting on a notification the kernel really should deliver. -const NOTIFY_TIMEOUT: Duration = Duration::from_secs(30); +/// +/// 5s, lowered from 30s once the cost was measured (M15.7). Instrumenting every +/// wait in this suite: 45 of 46 complete in **2.5ms or less**, and the entire +/// tail is one test gated on the retry *backoff timer* at ~515ms -- structural, +/// not notification latency. None of it moved under 4x oversubscription (three +/// concurrent suites plus a full release build), so the budget was absorbing no +/// contention it needed to. +/// +/// Why lower it rather than leave it generous: when a mutation breaks delivery, +/// dozens of these waits each burn the full budget on the way to failing, and +/// the suite then overruns cargo-mutants' kill deadline -- so the mutant is +/// filed as `timeout` rather than `caught`, detected but recorded as though it +/// were not. Measured on one such mutant: **93.6s at 30s (killed) against 31.8s +/// at 5s (a clean red test)**. +/// +/// The residual risk, stated because it is real: that was one 12-core developer +/// machine. 5s is ~10x the structural outlier and ~2000x the p95, but a much +/// slower runner is unmeasured. If this ever flakes, that is the reason, and the +/// answer is to raise it -- not to conclude the tests are wrong. +const NOTIFY_TIMEOUT: Duration = Duration::from_secs(5); /// The subscription every test in this module watches under. fn test_watch() -> WatchId { diff --git a/crates/windows-file-watcher/tests/fault_detail.rs b/crates/windows-file-watcher/tests/fault_detail.rs index be2348cd..67bc53d6 100644 --- a/crates/windows-file-watcher/tests/fault_detail.rs +++ b/crates/windows-file-watcher/tests/fault_detail.rs @@ -12,7 +12,10 @@ use windows_file_watcher::{ }; /// Upper bound for waiting on something the monitor really should deliver. -const NOTIFY_TIMEOUT: Duration = Duration::from_secs(30); +/// +/// 5s, matching `NOTIFY_TIMEOUT` in `src/watcher/tests.rs`, which carries the +/// measurement that justifies the number (M15.7). +const NOTIFY_TIMEOUT: Duration = Duration::from_secs(5); /// A uniquely named temp path, removed when the test passes. struct TempPath { diff --git a/crates/windows-file-watcher/tests/watched_paths.rs b/crates/windows-file-watcher/tests/watched_paths.rs index 21c131cb..ee10b222 100644 --- a/crates/windows-file-watcher/tests/watched_paths.rs +++ b/crates/windows-file-watcher/tests/watched_paths.rs @@ -17,10 +17,14 @@ use windows_file_watcher::{ WatchOptions, }; -/// Upper bound for waiting on something the kernel really should deliver. Long -/// enough that a loaded CI runner does not fail spuriously; short enough that a -/// genuine wedge fails the run rather than stalling it. -const NOTIFY_TIMEOUT: Duration = Duration::from_secs(30); +/// Upper bound for waiting on something the kernel really should deliver. Short +/// enough that a genuine wedge fails the run rather than stalling it, and -- +/// since M15.7 measured what these waits actually cost -- short enough that a +/// mutation-breaking-delivery run stays inside cargo-mutants' kill deadline +/// instead of being filed as a timeout. See `NOTIFY_TIMEOUT` in +/// `src/watcher/tests.rs` for the measurements, including the residual risk that +/// they came from a single 12-core machine. +const NOTIFY_TIMEOUT: Duration = Duration::from_secs(5); /// What teardown is allowed to take. Cancellation retires an outstanding read at /// once, so this only fires if teardown waited for a change instead. diff --git a/tools/run-mutants.ps1 b/tools/run-mutants.ps1 index eabf715f..778c72f1 100644 --- a/tools/run-mutants.ps1 +++ b/tools/run-mutants.ps1 @@ -75,23 +75,30 @@ Fixed per-mutant test timeout, in seconds. Leave at 0 to derive it from the measured baseline instead, which is the default and the better option. - **Timeouts, not crashes, dominate the wall clock here.** Measured on - `queue.rs`: 14 of 101 mutants timed out, and at a fixed 120s that is 28 - minutes of budget in a 28-minute run -- roughly half the elapsed time at - `-j 2`. They are all in blocking paths (`Drop` for `Sender`, `recv`, - `is_empty`, `latch`), which is exactly what a queue's mutants do: break the - disconnect accounting and a receiver waits forever rather than failing. - - A fixed number is the wrong shape for that. `--timeout-multiplier` scales - the deadline from the baseline test time cargo-mutants already measures, so - it adapts to the machine instead of encoding one. The baseline here is about - 30s for the full `--all-features` suite, so the default multiplier of 3 - gives ~90s: comfortably above any legitimate run, and it shrinks - automatically on a faster host. + **Timeouts used to dominate the wall clock here, and no longer do.** Two + changes removed that, both driven by measurement rather than by tuning this + knob. M15.6 converted the queue tests to bounded waiting, so a broken wake + fails instead of hanging. M15.7 then found the budget those bounded waits + spend was 30s while 45 of 46 waits complete in 2.5ms or less -- so a mutation + that broke delivery made dozens of tests each burn 30s, overrunning the + deadline. Lowering `NOTIFY_TIMEOUT` to 5s took one such mutant from 93.6s + (killed, filed `timeout`) to 31.8s (a clean red test). + + The lesson generalises: when mutants pile up in the `timeout` column, suspect + the *suite's* wait budget before reaching for this parameter. A timeout is + usually a detection that was not allowed to finish. + + A fixed number is still the wrong shape. `--timeout-multiplier` scales the + deadline from the baseline test time cargo-mutants already measures, so it + adapts to the machine instead of encoding one. The baseline here is about 30s + for the full `--all-features` suite, so the default multiplier of 3 gives + ~90s: comfortably above any legitimate run, and it shrinks automatically on a + faster host. Lower it only with the false-timeout risk in mind. A mutant that is recorded `timeout` because the deadline was too tight is misattributed twice over -- - it is not a hang, and it is not necessarily caught either. + it is not a hang, and it is not necessarily caught either. Adjudicate any + `timeout` by counting `FAILED` lines in its log before treating it as a gap. .PARAMETER TimeoutMultiplier Test timeout as a multiple of the measured baseline. Ignored when From 8c566479e44343a17d8e07875fd1faec06ca2706 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 21:25:32 -0400 Subject: [PATCH 180/361] fix(waitable-queues): bound the recorded depth, and state what high_water means Completed item: SH-10.5: reserving_mpsc's publish sampled the queue depth from its own position and a relaxed load of head -- ungated and unclamped -- so the recorded peak could exceed the queue's own capacity. slotwise_mpsc's twin is bounded by construction, and the difference is worth naming: there the producer's acquire load of the slot's sequence synchronizes-with the consumer freeing that slot, so the head read afterwards cannot be older than `position - capacity + 1`. This shape has a second entry point with no such edge. `Reservation::send` redeems without a room check, so the only head its thread is ordered against is the one `reserve` read, which may be arbitrarily old by the time the reservation is redeemed -- and a stale read over-reports by however much was drained in between. The sample is now gated on tracking (parity with the twin, and it keeps the shared head line untouched when nobody asked for the metric), taken before publication, and clamped to the capacity. Observable::high_water's contract is corrected to say what all three shapes actually deliver: an UPPER BOUND on the true peak, never below it and never above the capacity. The error is one-directional because a stale read can only be older, and that is the useful direction for the question the metric answers -- whether a capacity was ever close to exhausted. Counting exactly, as an explicit current-depth counter would, puts a read-modify-write on a line shared by every producer and the consumer into every push and every pop: the line this crate pads its positions apart to keep out of the hot path. The test writes the stale-head state directly rather than racing for it. A first attempt looped the reservation path sixty-four times and asserted the bound, which passes with or without the clamp on a coherent machine -- the sabotage sweep reported both mutants as SURVIVED, which is what a sweep is for. Sabotage-verified: 3 of 3 caught -- removing the clamp, moving the sample after publication, and flattening the peak so the clamp could not be bought cheaply. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 15 +++++ crates/windows-waitable-queues/src/metrics.rs | 6 ++ .../src/reserving_mpsc.rs | 38 +++++++++--- .../src/reserving_mpsc/tests.rs | 61 +++++++++++++++++++ crates/windows-waitable-queues/src/traits.rs | 20 ++++++ 5 files changed, 132 insertions(+), 8 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 1f6f183d..09265625 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -489,3 +489,18 @@ that produces no thread is otherwise invisible to the "are all comments resolved to the capacity like the other two shapes', and `is_full` is defined in terms of `remaining` rather than restating the rule. The trait's default now documents that a `Reserving` shape must override it, so the next shape to reserve does not inherit the same wrong answer silently. + +- [x] **SH-10.5** -- **The high-water depth could record a peak the queue never reached.** + `reserving_mpsc`'s `publish` sampled the depth from its own position and a relaxed load of `head`, + ungated and unclamped. `slotwise_mpsc`'s twin is bounded by construction -- its producer's acquire + load of the slot's sequence synchronizes-with the consumer freeing that slot, so `head` cannot be + older than `position - capacity + 1` -- but this shape has a second entry point with no such edge: + `Reservation::send` redeems without a room check, so the only `head` its thread is ordered against is + the one *`reserve`* read, which may be arbitrarily old by the time the reservation is redeemed. The + sample is now gated on tracking (parity with the twin), read before publication, and clamped to the + capacity. + `Observable::high_water`'s contract is corrected to match what all three shapes actually deliver: an + **upper bound** on the true peak, never below it and never above the capacity, with the reason the + cheap sample is preferred to an exact count. Counting exactly would put a read-modify-write on a line + shared by every producer and the consumer into every push and every pop -- the line this crate pads + its positions apart to keep out of the hot path. diff --git a/crates/windows-waitable-queues/src/metrics.rs b/crates/windows-waitable-queues/src/metrics.rs index 44c9c50b..5da17877 100644 --- a/crates/windows-waitable-queues/src/metrics.rs +++ b/crates/windows-waitable-queues/src/metrics.rs @@ -52,6 +52,12 @@ pub(crate) struct Metrics { refused: AtomicU64, /// The deepest the queue has been observed to get, if it is being tracked. /// + /// An **upper bound** on the true peak rather than the peak exactly, and + /// never above the queue's capacity. The reasoning, and why the cheap + /// sample is preferred to an exact count, is on + /// [`Observable::high_water`](crate::Observable::high_water); it is stated + /// there because that is where a caller reads it. + /// /// `Option` rather than a sentinel because "not tracked" and "never got /// past empty" are different answers, and a caller acting on a `0` that /// meant the former would be reading a number nobody recorded. diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index b6e627bb..20406776 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -526,14 +526,36 @@ impl Shared { /// must not have published it already. A position is claimed by exactly one /// producer, so this is the only writer of the slot. unsafe fn publish(&self, position: u32, item: T) { - // Near-free on this shape, unlike `slotwise_mpsc`: the producer has already - // read `head` to decide there was room beyond the reservations, so the - // depth is a subtraction of two numbers it is holding. Only the - // counter's line is shared, and it is written rarely -- see - // `Metrics::record_depth` for why the load comes before the modify. - let head = self.head.0.load(Ordering::Relaxed); - self.metrics - .record_depth(position.wrapping_sub(head).wrapping_add(1) as usize); + // Gated, matching `slotwise_mpsc`: untracked, this costs one predictable + // branch on a field written once at construction, and the shared `head` + // line is not touched at all. + // + // **Before the publication below, and that placement is load-bearing.** + // The subtraction is only non-negative while the consumer cannot have + // passed `position`, and what holds it back is precisely that `position` + // is not published yet. Taken afterwards, the consumer is free to drain + // past it, the subtraction wraps, and `fetch_max` keeps a vast number + // forever -- the defect `slotwise_mpsc`'s twin comment records measuring. + // + // **Clamped, which its twin does not need to be.** There, the producer's + // acquire load of the slot's sequence synchronizes-with the consumer + // freeing that slot, so the `head` read here cannot be older than + // `position - capacity + 1` and the depth is bounded by construction. + // This shape has a second entry point with no such edge: + // [`Reservation::send`] redeems without a room check, so the only `head` + // its thread is ordered against is the one *`reserve`* read -- which may + // be arbitrarily old by the time the reservation is redeemed. A stale + // read can only over-report, never under-report, so clamping to the + // capacity keeps the value an upper bound on a depth the queue really + // reached rather than an unbounded one. See [`Observable::high_water`] + // for what that bound is contracted to mean. + // + // [`Observable::high_water`]: crate::Observable::high_water + if self.metrics.tracks_high_water() { + let head = self.head.0.load(Ordering::Acquire); + let depth = position.wrapping_sub(head).wrapping_add(1) as usize; + self.metrics.record_depth(depth.min(self.capacity)); + } let slot = &self.slots[position as usize & self.mask]; // SAFETY: the caller's claim makes this thread the only writer, and the diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index 92feaf40..dc7a0f05 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -1330,3 +1330,64 @@ fn the_gauges_are_exact_when_the_two_loads_agree() { assert_eq!(tx.len(), 1); assert_eq!(tx.remaining(), 3); } + +#[test] +fn the_high_water_mark_never_exceeds_the_capacity() { + // The defect, driven directly. The depth is sampled from this producer's + // position and a load of the consumer's, which are two readings rather than + // one instant -- and `Reservation::send` is the path with no room check, so + // the only `head` its thread is ordered against is the one `reserve` read, + // which may be arbitrarily old by the time the reservation is redeemed. + // + // A stale read cannot be raced for on a coherent machine, so the state one + // would observe is written instead: `head` behind the claim position by more + // than the capacity. Without the clamp this records a peak of 12 on a + // four-slot queue. + let (tx, rx) = bounded_with::(4, Options::new().tracking_high_water()) + .expect("4 is a valid capacity"); + let slot = tx.reserve().expect("an empty queue has room"); + + let stale_head = u32::MAX - 10; + tx.shared.head.0.store(stale_head, Ordering::Release); + + slot.send(7).expect("the consumer is still here"); + + // Restored before anything walks the ring: teardown and `pop` both step from + // `head` to the claim position, and a head this far behind sets them a + // four-billion-step loop that hangs rather than fails. + tx.shared.head.0.store(0, Ordering::Release); + + let peak = tx.high_water().expect("tracking was asked for"); + assert!( + peak <= tx.capacity(), + "a four-slot queue reported a peak of {peak}" + ); + assert_eq!(rx.pop(), Some(7), "the item itself must be unaffected"); +} + +#[test] +fn the_high_water_mark_still_reaches_a_genuine_peak() { + // The clamp must not have been bought by flattening the answer: filling the + // queue must still be reported as having filled it. + let (tx, rx) = bounded_with::(4, Options::new().tracking_high_water()) + .expect("4 is a valid capacity"); + + for i in 0..4 { + tx.push(i).expect("room"); + } + + assert_eq!( + tx.high_water(), + Some(4), + "the queue was filled, so the peak is its capacity" + ); + assert_eq!(rx.pop(), Some(0)); +} + +#[test] +fn the_high_water_mark_is_untracked_by_default_on_this_shape() { + // `None` and `Some(0)` are different answers, and the default is the former. + let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); + tx.push(1).expect("room"); + assert_eq!(tx.high_water(), None); +} diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs index 53e9c677..af968346 100644 --- a/crates/windows-waitable-queues/src/traits.rs +++ b/crates/windows-waitable-queues/src/traits.rs @@ -312,6 +312,26 @@ pub trait Observable { /// [`Options::tracking_high_water`](crate::Options::tracking_high_water) /// asked for it, because a peak has to observe every change and that is the /// one metric here which cannot be made free. + /// + /// # It is an upper bound on the peak, not the peak exactly + /// + /// The depth is sampled by a producer at publication, from its own position + /// and a load of the consumer's, and those are two readings rather than one + /// instant. The consumer may have drained between them, so the sample can + /// exceed the depth that held when the item landed. + /// + /// The error is **one-directional and bounded**: a stale read of the + /// consumer's position can only be *older*, which over-reports by the number + /// of items drained since, and the result is clamped to the capacity. So + /// this never reads below the true peak, and never above the queue's own + /// size. + /// + /// That is the useful direction for the question this answers -- whether a + /// capacity was ever close to exhausted -- and it is why the cheap sample is + /// preferred to an exact one. Counting exactly would mean a read-modify-write + /// on a line shared by every producer *and* the consumer, at every push and + /// every pop; this crate pads its positions apart specifically to keep that + /// line out of the hot path. fn high_water(&self) -> Option; } From 10c0e12a4b834db431f70a0f81b75edb4eba9b96 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 21:40:44 -0400 Subject: [PATCH 181/361] test(file-watcher): bound the loop, not just the wait inside it M15.7's confirming sweep left 8 timeouts, and all 8 were one test: no_wakeup_is_lost_under_a_concurrent_burst. Its wait was bounded, but the loop around it was not -- and every one of its exit conditions is an answer a defect can pin, which leaves the doorbell signalled, so the bounded wait returns immediately and the loop spins at full speed. A bounded wait inside an unbounded loop is still an unbounded loop, which is exactly why M15.6 missed it: that sweep looked for unbounded recv(), and here the recv was already bounded. The loop now owns a deadline whose message names `seen`, TOTAL and all three predicate values, so a future instance says which answer got pinned rather than just that time ran out. `await_signal` also still waited 30s, the budget M15.7 had lowered everywhere else; it is now 5s. The suite-wide sweep the item asked for found no second loop of that shape. Ten candidates were flagged mechanically and all but this one are sound -- three bound themselves with a `!remaining.is_zero()` assertion, one terminates because the helper it calls panics, two are path-construction loops, and the rest are drain loops or stop-flag threads. Worth noting that the first heuristic returned a false negative on this very test, because `await_signal` in the body looked like a bound: the check that works asks whether the loop asserts against a deadline *it owns*, not whether it calls something bounded. A near-miss that only injection caught. The first fix for the remaining Receiver::recv timeout replaced three bare `assert!(receiver.recv().is_none())` sites with recv_timeout. The suite stayed green, the hang went away -- and the mutant went from timeout to SURVIVED, because those assertions are the only thing exercising recv's own disconnect check. A detection had been traded for a gap while every surface signal said otherwise. The correct bound keeps the blocking recv and puts the deadline outside it, on a thread with an mpsc handshake -- the idiom a_blocked_receiver_is_woken_when_the_last_sender_drops already used. The mutant is now caught in ~11s. The rule: when a test asserts that a blocking call returns, bounding it by making the call non-blocking tests something weaker. Bound the wait around it. queue.rs across the three items: caught 78 -> 89 -> 95, missed 8 -> 0 -> 0, timeout 14 -> 8 -> 2, wall clock 20 -> 14 -> 11 minutes. The last two had 44 and 70 tests already failed before the kill and are the inherent floor: a mutant that breaks the core makes most of the suite fail, and a suite of failures takes longer than the passing baseline the deadline is derived from. Completed item: M15.11: Bound the loop in `no_wakeup_is_lost_under_a_concurrent_burst`, not just the wait inside it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 19 +---- .../COMPLETED-CHECKLIST.md | 70 ++++++++++++++++++- .../windows-file-watcher/src/queue/tests.rs | 27 ++++++- .../windows-file-watcher/src/session/tests.rs | 16 ++++- .../windows-file-watcher/src/watcher/tests.rs | 15 ++-- .../tests/consumer_test_surface.rs | 15 +++- 6 files changed, 135 insertions(+), 27 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 1a9bf36e..1e0b719d 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -153,24 +153,7 @@ Archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md#moved-2026-08-27---- - [x] **M15.7** -- Decided and implemented: `NOTIFY_TIMEOUT` lowered 30s -> 5s across all three copies, after measuring that 45 of 46 waits finish in <=2.5ms and the whole tail is one structural ~515ms backoff, unchanged under 4x oversubscription. One previously-timing-out mutant: 93.6s -> 31.8s. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m157) -- [ ] **M15.11** -- Bound the *loop* in `no_wakeup_is_lost_under_a_concurrent_burst`, not just the wait - inside it. **Found by M15.7's confirming sweep, and it is M15.6's defect one level up.** - **The measurement.** After M15.7, a `queue.rs` sweep is 121 mutants in 14 minutes with **8 timeouts, and - every one of the 8 is pinned by this single test** -- `Receiver::try_recv -> None`, `recv`'s `==` to - `!=`, `is_disconnected -> false`, `len -> 1`, `is_empty -> false` (x2), `latched -> 1`, and - `take -> None`. All 8 still had between 4 and 76 tests failed before the kill, so they are detections - rather than gaps; they just cost 67s each instead of failing. - **Why the budget fix did not reach it.** The test waits on the doorbell with a bounded `await_signal`, - then drains, then re-checks an exit condition of - `is_disconnected() && is_empty() && latched() == 0`. Each of those mutants makes that condition - permanently false while leaving the doorbell signalled, so the **outer loop** spins at full speed -- - bounded wait, unbounded loop. Lowering `NOTIFY_TIMEOUT` cannot touch it, and no further budget reduction - will. - **What is wanted.** A deadline on the loop itself (and ideally a no-progress bound: `seen` not advancing - across N iterations is the real symptom), so a broken predicate fails with what it saw rather than - spinning. That is the same transform M15.6 applied to `recv()`, applied one level out. - **Worth checking while there:** whether any other loop in the suite has this shape -- a bounded wait - inside an unbounded `loop`. M15.6 swept for unbounded `recv()`, which would not have found this one. +- [x] **M15.11** -- Bounded the loop in `no_wakeup_is_lost_under_a_concurrent_burst` (a bounded wait inside an unbounded loop is still an unbounded loop) and aligned `await_signal`'s budget with M15.7's. The suite-wide sweep for the same shape found no other instance. -> [completed 2026-09-01](COMPLETED-CHECKLIST.md#m1511) ## M-inf -- Horizon (ungated, post-v1) diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index d0bb8905..4beaac7b 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -959,4 +959,72 @@ notification budget: every one is pinned by a single test, `queue::tests::no_wakeup_is_lost_under_a_concurrent_burst`, whose *wait* is bounded while the **loop around it is not**. Each of those mutants breaks that loop's exit condition, so it spins at full speed rather than failing. That is M15.6's shape one level up, and M15.6 missed it because the bound sits on the wait rather -than on the loop -- lowering the budget further would not have touched it. \ No newline at end of file +than on the loop -- lowering the budget further would not have touched it. +## Moved 2026-09-01 -- M15.11: a bounded wait inside an unbounded loop + +### M15.11 -- Bound the *loop* in `no_wakeup_is_lost_under_a_concurrent_burst`, not just the wait inside it. *(completed 2026-09-01 21:25:00 -04:00)* + +**The shape, which is the transferable part.** The test waited on the doorbell with a bounded +`await_signal`, drained, then re-checked an exit condition of +`is_disconnected() && is_empty() && latched() == 0`. Every one of those calls is an answer a defect can +pin -- and a pinned answer leaves the doorbell *signalled*, so the bounded wait returns immediately and +the loop spins at full speed. **A bounded wait inside an unbounded loop is still an unbounded loop**, and +that is exactly why M15.6 did not find this: it swept for unbounded `recv()`, and here the `recv` was +already bounded. + +**What it cost.** All 8 timeouts in M15.7's confirming sweep were this one test, held open by eight +different mutants (`try_recv -> None`, `recv`'s `==` to `!=`, `is_disconnected -> false`, `len -> 1`, +`is_empty -> false` twice, `latched -> 1`, `take -> None`). Each was a detection -- between 4 and 76 tests +had already failed -- filed as a timeout because the run was killed before it could finish. + +**The fix.** A deadline on the loop itself, whose failure message names `seen`, `TOTAL`, and all three +predicate values, so a future instance says which answer got pinned rather than just that time ran out. +Measured on four of the eight mutants: each now fails in seconds where it previously cost the full 67s +kill. + +**Also aligned:** `await_signal` still waited 30s, the budget M15.7 had just lowered everywhere else. It +is now 5s, with the reasoning cross-referenced rather than restated. + +**The suite-wide sweep the item asked for found no second instance.** Ten candidate loops were flagged +mechanically and all but this one are sound: three in `watch/tests.rs` bound themselves with a +`!remaining.is_zero()` assertion (an idiom the first heuristic did not recognise), one terminates because +the bounded helper it calls *panics* rather than returning, two are path-construction loops that terminate +by growth, and the rest are drain loops or stop-flag threads the test controls. + +**Worth recording about the search itself:** the first heuristic -- "a loop with no deadline in its body" +-- returned a false negative on *this very test*, because `await_signal` appears in the body and looked +like a bound. The check that works is narrower: does the loop assert against a **deadline it owns**, as +opposed to merely calling something that is itself bounded. +**A near-miss worth recording, because only injection caught it.** The first fix for the remaining +`Receiver::recv` timeout was to replace the three bare `assert!(receiver.recv().is_none())` sites with +`recv_timeout`. The suite stayed green and the hang went away -- and the mutant went from `timeout` to +**SURVIVED**. Those assertions are the only thing that exercises `recv`'s *own* disconnect check, so +bounding them by swapping the call stopped testing the very path they existed for: a detection had been +traded for a gap, invisibly, while every surface signal said the change was an improvement. + +The correct bound keeps the blocking `recv()` and puts the *deadline outside it* -- run it on its own +thread and collect the answer through an `mpsc::recv_timeout` -- which is the idiom +`a_blocked_receiver_is_woken_when_the_last_sender_drops` already used a few hundred lines away. Applied to +all three sites (`session/tests.rs`, `watcher/tests.rs`, `tests/consumer_test_surface.rs`), the mutant is +**caught in ~11s** instead of costing a 67s kill. + +The general rule: **when a test asserts that a blocking call returns, bounding it by making the call +non-blocking tests something weaker.** Bound the wait *around* it instead. + +(Also caught by this: a first injection run reported SURVIVED because the test filter was the package name +rather than a test-name substring, so no tests ran at all. A filter that matches nothing looks exactly +like a mutant that nothing catches.) +**The final sweep, and where the floor is.** `queue.rs` across the three items: + +| | after M15.6 | after M15.7 | after M15.11 | +|---|---|---|---| +| caught | 78 | 89 | **95** | +| missed | 8 | 0 | **0** | +| timeout | 14 | 8 | **2** | +| wall clock | 20 min | 14 min | **11 min** | + +The two that remain (`Receiver::recv_timeout -> None` and `take -> None`) had **44 and 70 tests already +failed** before the kill, and neither is held by any single slow test. They are the inherent floor rather +than a defect: a mutant that breaks the queue's core makes most of the suite fail, and a suite of failures +takes longer than a suite of passes -- so it overruns a deadline set at 3x the *passing* baseline. No +test-side bound can reach that, and nothing is being missed. \ No newline at end of file diff --git a/crates/windows-file-watcher/src/queue/tests.rs b/crates/windows-file-watcher/src/queue/tests.rs index 05106ebf..2329437b 100644 --- a/crates/windows-file-watcher/src/queue/tests.rs +++ b/crates/windows-file-watcher/src/queue/tests.rs @@ -1115,9 +1115,15 @@ fn is_signalled(handle: BorrowedHandle<'_>) -> bool { } /// Wait for a handle to become signalled, failing rather than hanging. +/// +/// 5s, matching `NOTIFY_TIMEOUT` in `src/watcher/tests.rs` and lowered from 30s +/// for the reason recorded there (M15.7): a budget only spent when something is +/// already broken still has to be paid before the failure is reported, and at +/// 30s a suite full of them overruns cargo-mutants' deadline and is filed as a +/// timeout instead of a red test. fn await_signal(handle: BorrowedHandle<'_>) -> bool { // SAFETY: as above, with a bounded timeout. - unsafe { WaitForSingleObject(handle.as_raw_handle(), 30_000) == WAIT_OBJECT_0 } + unsafe { WaitForSingleObject(handle.as_raw_handle(), 5_000) == WAIT_OBJECT_0 } } #[test] @@ -1288,7 +1294,26 @@ fn no_wakeup_is_lost_under_a_concurrent_burst() { }); let mut seen = 0_usize; + // The wait below is bounded, but that is not enough on its own: every one of + // this loop's exit conditions is a call whose answer a defect can pin, and a + // pinned answer leaves the doorbell signalled, so `await_signal` returns + // immediately and the loop spins at full speed rather than blocking. Eight + // separate mutants did exactly that -- `try_recv` returning `None`, + // `is_disconnected`/`is_empty` returning `false`, `len`/`latched` returning + // `1`, `take` returning `None`, and `recv`'s own comparison inverted -- and + // each was detected only by cargo-mutants killing the run, i.e. filed as a + // timeout rather than a failure (M15.11). A bounded wait inside an unbounded + // loop is still an unbounded loop. + let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { + assert!( + std::time::Instant::now() < deadline, + "the stream never ended: saw {seen} of {TOTAL}, disconnected={}, \ + empty={}, latched={}", + receiver.is_disconnected(), + receiver.is_empty(), + receiver.latched() + ); assert!( await_signal(doorbell.as_handle()), "the doorbell stopped ringing after {seen} notifications" diff --git a/crates/windows-file-watcher/src/session/tests.rs b/crates/windows-file-watcher/src/session/tests.rs index 147a3567..d4690774 100644 --- a/crates/windows-file-watcher/src/session/tests.rs +++ b/crates/windows-file-watcher/src/session/tests.rs @@ -168,7 +168,21 @@ fn dropping_every_session_disconnects_the_receiver() { receiver.is_disconnected(), "the last session away ends the stream, so a `recv` loop terminates" ); - assert!(receiver.recv().is_none()); + // Deliberately a *blocking* `recv()`, not `recv_timeout`: this is the only + // site that exercises `recv`'s own disconnect check, and swapping it for the + // bounded call was measured to turn a detected mutant into a survivor. It is + // bounded instead by running it on its own thread and collecting the answer + // with a deadline, so a broken disconnect fails here rather than hanging the + // run (M15.11). A thread left blocked on the failure path is fine: libtest + // exits the process without joining it. + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(receiver.recv().is_none()); + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(ended) => assert!(ended, "a blocking recv on an ended stream yields None"), + Err(_) => panic!("a blocking recv did not return once every session was gone"), + } } #[test] diff --git a/crates/windows-file-watcher/src/watcher/tests.rs b/crates/windows-file-watcher/src/watcher/tests.rs index 789044d7..0ca3a10d 100644 --- a/crates/windows-file-watcher/src/watcher/tests.rs +++ b/crates/windows-file-watcher/src/watcher/tests.rs @@ -639,10 +639,17 @@ fn teardown_releases_the_sender_so_the_receiver_disconnects() { receiver.is_disconnected(), "dropping the watcher must release the queue sender" ); - assert!( - receiver.recv().is_none(), - "a drain loop must terminate rather than block" - ); + // A blocking `recv()`, bounded by a thread and a deadline rather than by + // swapping in `recv_timeout` -- the claim being made is about a *drain loop* + // terminating, and a bounded read does not test that (M15.11). + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(receiver.recv().is_none()); + }); + match rx.recv_timeout(NOTIFY_TIMEOUT) { + Ok(ended) => assert!(ended, "a drain loop must terminate rather than block"), + Err(_) => panic!("a blocking recv did not return once the watcher was gone"), + } dir.cleanup(); } diff --git a/crates/windows-file-watcher/tests/consumer_test_surface.rs b/crates/windows-file-watcher/tests/consumer_test_surface.rs index 591b8ba0..a2b18a4c 100644 --- a/crates/windows-file-watcher/tests/consumer_test_surface.rs +++ b/crates/windows-file-watcher/tests/consumer_test_surface.rs @@ -105,6 +105,17 @@ fn a_drained_receiver_reports_empty_then_disconnected() { drop(sender); // With every sender gone and the queue empty, a blocking recv returns None - // rather than hanging -- how a consumer's drain loop ends on teardown. - assert!(receiver.recv().is_none()); + // rather than hanging -- how a consumer's drain loop ends on teardown. Kept + // blocking on purpose (a bounded read would not demonstrate that), and + // bounded instead by a thread plus a deadline so a broken disconnect fails + // this test rather than wedging the run (M15.11). + assert!(receiver.is_disconnected(), "every sender is gone"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(receiver.recv().is_none()); + }); + match rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(ended) => assert!(ended, "a blocking recv on an ended stream yields None"), + Err(_) => panic!("a consumer's drain loop would not have terminated"), + } } From 69289a8ac0808e4dd76ff8e300d21e09da709743 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 21:53:20 -0400 Subject: [PATCH 182/361] fix: repair the two CI failures the merge surfaced Both were red on the merge commit; one is mine and one was already failing before it, and the difference is worth stating plainly. **rustdoc, mine.** The 32-bit `BOUNDS_MAX` fix documented its reasoning with an intra-doc link to `WRAPPING_MAX_CAPACITY`, which is `pub(crate)` -- so a public item linked to a private one and the `-D warnings` doc job refused it. The reasoning is kept and the link is not: the ceiling is named in prose with its value, which is what a reader of the public constant actually needs. **windows-file-watcher, pre-existing.** `canonical_path_is_exact_on_both_sides_of_its_first_buffer` has failed on every CI run since e7b3c4f introduced it, and passes on a developer machine -- so the merge did not cause it and no local run would have caught it. The fixture built a directory whose literal `\\?\` spelling was exactly N units and then asserted the reported canonical path was N units. Those are only the same string when the base path is already canonical. A GitHub-hosted runner's temp directory is an 8.3 short name, `C:\Users\RUNNER~1\...`, and canonicalizing expands it to `runneradmin` -- three units wider, which is exactly the reported `left: 511, right: 508`. The fixture now measures from the base's canonical spelling, so the expansion is a no-op. Verified rather than argued, because this host generates no 8.3 names and so cannot reproduce the runner directly. A junction reproduces the same shape -- a base whose spelling differs from its canonical form -- and pointing TEMP at one gives `left: 538, right: 508` without the fix (delta 30, the junction's) and a pass with it, mirroring CI's delta of 3. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-file-watcher/src/directory/tests.rs | 17 ++++++++++++++++- .../src/reserving_mpsc.rs | 15 ++++++++------- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/windows-file-watcher/src/directory/tests.rs b/crates/windows-file-watcher/src/directory/tests.rs index e8ed596b..6a79e202 100644 --- a/crates/windows-file-watcher/src/directory/tests.rs +++ b/crates/windows-file-watcher/src/directory/tests.rs @@ -690,8 +690,23 @@ fn canonical_path_grows_its_buffer_when_the_path_does_not_fit() { } /// A directory whose `\\?\` spelling is exactly `target` UTF-16 units, built by /// padding the final component. Components stay well under the 255-unit limit. +/// +/// **Built from the base's *canonical* spelling, not the one handed in.** A +/// machine whose temp directory contains an 8.3 short name -- `C:\Users\RUNNER~1\...` +/// on a GitHub-hosted runner -- canonicalizes it to the long form, so a fixture +/// measured against the short spelling is reported *longer* than it was built to +/// be. Measured: `RUNNER~1` expands to `runneradmin`, three units wider, and the +/// length assertions failed on CI with `left: 511, right: 508` while passing on +/// a developer machine whose temp path is already canonical. fn deep_dir_of_prefixed_len(base: &Path, target: usize) -> PathBuf { - let mut path = base.to_path_buf(); + let canonical = std::fs::canonicalize(base).expect("the base directory must exist"); + // `canonicalize` already returns the `\\?\` form on Windows, and the caller + // re-adds that prefix when it measures, so strip it here rather than + // counting it twice. + let mut path = match canonical.to_str().and_then(|s| s.strip_prefix(r"\\?\")) { + Some(stripped) => PathBuf::from(stripped), + None => canonical.clone(), + }; loop { let current = format!(r"\\?\{}", path.display()).len(); assert!( diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 20406776..503e702d 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -126,13 +126,14 @@ const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; /// most items this shape can hold. /// /// **Bounded by the `usize` width as well as by the packing, because on a -/// 32-bit target the packing is the *wider* of the two.** There, -/// [`WRAPPING_MAX_CAPACITY`] is `2^31 - 1`, so a flat `1 << 31` exceeds the -/// crate-wide ceiling and the assertion below rejects it -- failing the build -/// for every capacity, including the small valid ones. Taking the narrower of -/// the two limits keeps this a power of two on every target, which matters -/// because the value is offered to a caller as a capacity it could actually -/// use. +/// 32-bit target the packing is the *wider* of the two.** The crate-wide +/// ceiling below which a wrapping position difference stays unambiguous is +/// `usize::MAX / 2`, which on a 32-bit target is `2^31 - 1` -- narrower than +/// the packing. A flat `1 << 31` therefore exceeds it, and the const assertion +/// below rejects it, failing the build for every capacity including the small +/// valid ones. Taking the narrower of the two limits keeps this a power of two +/// on every target, which matters because the value is offered to a caller as a +/// capacity it could actually use. pub const BOUNDS_MAX: usize = { let packed = 1_usize << (POSITION_BITS - 1); let widest_usize_power_of_two = 1_usize << (usize::BITS - 2); From 03b9ae0ea580af066585a4cc51abe069a04accf3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 22:12:19 -0400 Subject: [PATCH 183/361] fix(placement-probe): refuse to invent placement for uncovered processors places_from_topology accepted a topology whose domains do not cover every processor and filled each gap with a value indistinguishable from a measured one. Three fallbacks, one cause: an absence was read as a value. A processor absent from every core domain was given a synthetic core id derived from its group and number, which can equal a real core domain's id -- classify then reports two processors as SMT siblings when one's core is merely unknown. Its efficiency class became 0, which is also a genuine Windows class, so within_class_pair reports a same-class pair against a real class-0 core. Its cache domain became None, which the type already means "no cache level partitions this machine" -- so two processors omitted from an incomplete partition compare equal and serialize a confident same-cache measurement. The rule now distinguishes uniform absence from a gap. A machine that reports no core domains at all, or no partitioning cache level, has told us something true about itself and still converts. A machine that places every other processor but not this one has told us nothing about this one, and the conversion refuses: places_from_topology returns Err(UnplacedProcessor) naming the processor and, in a new MissingPlacement field, which of core / cache domain / NUMA node was missing. MissingPlacement is non_exhaustive. Core and efficiency class are two spellings of one rule -- Topology::cores() filters to DomainKind::Core, so a processor's class is known exactly when its core is -- and an EfficiencyClass variant written for the second was removed on discovering it is unreachable. Sabotage confirms the pair behaves that way: removing either refusal alone leaves the suite green, because the other still fires; removing both fails two tests. Also fixes two defects in the mutation wrapper. Its output directory stamp has one-second resolution, so two runs launched in the same second -- a script starting several scopes at once, which is exactly the case that wants separate output -- selected the same directory and interleaved their results; a short random suffix now follows the stamp. And its cleanup matched WerFault / WerFaultSecure / vsjitdebugger by name across the whole session, killing a crash report the user was reading or a debugger attached to something else entirely; it now records the ones already running at startup and skips them. The reported mutants.out path doubling does not hold, and a comment records the evidence: cargo-mutants treats --output as the parent and creates mutants.out inside it, verified on disk as \mutants.out\caught.txt with 22 lines against the 22 caught the wrapper reported. Completed items: SH-11.1, SH-11.2, SH-11.3, SH-11.4 Completed item: SH-11.1: Three fallbacks each invented an answer that reads as a real one Completed item: SH-11.2: The mutation wrapper's output directory could collide Completed item: SH-11.3: The wrapper terminated fault handlers it did not start Completed item: SH-11.4: The mutants.out nesting finding does not hold; documented in place Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 47 ++++++ .../src/fingerprint.rs | 146 ++++++++++++++---- .../src/fingerprint/tests.rs | 141 ++++++++++++++++- tools/run-mutants.ps1 | 36 ++++- 4 files changed, 339 insertions(+), 31 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 09265625..1b543d6a 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -504,3 +504,50 @@ that produces no thread is otherwise invisible to the "are all comments resolved cheap sample is preferred to an exact count. Counting exactly would put a read-modify-write on a line shared by every producer and the consumer into every push and every pop -- the line this crate pads its positions apart to keep out of the hot path. + +## M11: PR #56 sixth review round + +Three findings against `places_from_topology`, all of the same shape, plus three against the +mutation wrapper. The conversion's three silent fallbacks are replaced by one rule. + +- [x] **SH-11.1** -- **Three fallbacks each invented an answer that reads as a real one.** + `places_from_topology` accepted a topology whose domains do not cover every processor, and filled + each gap with a value indistinguishable from a measured one. A processor absent from every core + domain was given a synthetic core id derived from its group and number, which can equal a real + core domain's id -- `classify` then reports two processors as SMT siblings when one's core is + merely unknown. Its efficiency class became `0`, which is also a genuine Windows class, so + `within_class_pair` reports a same-class pair against a real class-0 core. Its cache domain became + `None`, which the type already means "no cache level partitions this machine" -- so two processors + omitted from an incomplete partition compare equal and serialize a confident same-cache + measurement. + The three share one cause: an absence was read as a value. The rule now distinguishes *uniform* + absence from a *gap*. A machine that reports no core domains at all, or no partitioning cache + level, has told us something true about itself and still converts. A machine that places every + other processor but not this one has told us nothing about this one, and the conversion refuses: + `places_from_topology` returns `Err(UnplacedProcessor)` naming the processor and, in a new + `MissingPlacement` field, which of core / cache domain / NUMA node was missing. + `MissingPlacement` is `#[non_exhaustive]`. + Core and efficiency class are two spellings of one rule -- `Topology::cores()` filters to + `DomainKind::Core`, so a processor's class is known exactly when its core is -- and an + `EfficiencyClass` variant written for the second was removed on discovering it is unreachable. + Sabotage confirms the pair behaves that way: removing either refusal alone leaves the suite green, + because the other still fires; removing both fails two tests. + +- [x] **SH-11.2** -- **The mutation wrapper's output directory could collide.** The stamp has + one-second resolution, so two runs launched in the same second -- a script starting several scopes + at once, which is exactly the case that wants separate output -- selected the same directory and + interleaved their results. A short random suffix now follows the stamp, which still sorts + chronologically. + +- [x] **SH-11.3** -- **The wrapper terminated fault handlers it did not start.** Cleanup matched + `WerFault` / `WerFaultSecure` / `vsjitdebugger` by name across the whole session, so a crash report + the user was reading or a debugger attached to an unrelated process was killed by a mutation sweep. + The wrapper now records the ones already running at startup and skips them. + +- [x] **SH-11.4** -- **The `mutants.out` nesting finding does not hold; documented in place.** + The report was that `Join-Path $OutputDirectory 'mutants.out'` doubles a path cargo-mutants already + appends. It does not: cargo-mutants treats `--output` as the parent and creates `mutants.out` + inside it. Verified on disk -- a run with `--output .scratch\mutants-encoding-` produced + `\mutants.out\caught.txt` with 22 lines, matching the 22 caught the wrapper reported. A + comment now records the evidence, since the path reads like a duplication and has been challenged + once already. diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index 998484be..0517b837 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -469,25 +469,58 @@ pub fn discover_places() -> std::io::Result> { }) } -/// An online processor whose NUMA membership a topology did not state, in a -/// topology that stated other processors'. +/// An online processor a topology could not place, and which attribute was +/// missing. /// /// Carried as a value rather than reported as a bare message so a caller can see -/// which processor was at fault; the identity is the whole diagnostic. +/// which processor was at fault and why; the identity is the whole diagnostic. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct UnplacedProcessor { /// The processor's group. pub group: u16, /// The processor's number within that group. pub number: u8, + /// Which part of its position the topology did not state. + pub missing: MissingPlacement, +} + +/// Which attribute of a processor's position a topology left unstated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MissingPlacement { + /// No core domain covers it, though the topology names core domains. + /// + /// Covers the efficiency class too, and deliberately has no separate + /// variant for it: [`Topology::cores`] yields only `DomainKind::Core` + /// domains, and every one of those carries a class, so a processor's core + /// and its class are known or unknown together. A variant no input could + /// produce would be dead public API. + Core, + /// No cache domain covers it at the level that partitions the machine. + CacheDomain, + /// No memory domain covers it, though the topology names memory domains. + NumaNode, +} + +impl MissingPlacement { + /// What the topology failed to state, for a message. + fn what(self) -> &'static str { + match self { + Self::Core => "core domains but places no core for", + Self::CacheDomain => "a partitioning cache level that omits", + Self::NumaNode => "memory domains but places no NUMA node for", + } + } } impl fmt::Display for UnplacedProcessor { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "the topology names memory domains but places no NUMA node for g{}/cpu{}", - self.group, self.number + "the topology names {} g{}/cpu{}", + self.missing.what(), + self.group, + self.number ) } } @@ -525,12 +558,26 @@ impl std::error::Error for UnplacedProcessor {} /// /// # Errors /// -/// Returns [`UnplacedProcessor`] when the topology names memory domains but -/// none of them contains some online processor. Defaulting that to node 0 is -/// right only when the topology names *no* memory domain at all; where the real -/// nodes are, say, 1 and 2, it invents a node the machine does not have and -/// files a processor under it. A partial topology is a legitimate input to this -/// seam (D-12), so it is refused rather than guessed at. +/// Returns [`UnplacedProcessor`] when the topology states an attribute of a +/// processor's position for *other* processors but not for this one -- +/// [`MissingPlacement`] says which. The rule is one sentence: an absence that is +/// **uniform** across the machine is a real answer, and an absence that singles +/// one processor out is a gap. +/// +/// A topology naming no memory domain describes a single-node machine, so node +/// zero is correct for everyone; a topology naming nodes 1 and 2 and omitting a +/// processor has not said where it is, and answering zero invents a node the +/// machine does not have. The same holds for cores, efficiency classes, and the +/// cache level that partitions the machine. +/// +/// **The invented value is worse than a lost one**, which is why this refuses +/// rather than substituting a sentinel: a synthetic core id can equal a real +/// core domain's id, class zero is a genuine Windows class, and a `None` cache +/// domain already means "no level partitions this machine". Each would compare +/// *equal* to a real value, and +/// [`classify`](crate::core_affinity::classify) would then report a shared core, +/// class or cache that is not there. A partial topology is a legitimate input to +/// this seam (D-12), so it is refused rather than guessed at. pub fn places_from_topology(topology: &Topology) -> Result, UnplacedProcessor> { // Every map here is keyed by the full `(group, number)` pair. Keying on the // number alone is the defect this function is written against: on a machine @@ -539,7 +586,9 @@ pub fn places_from_topology(topology: &Topology) -> Result, // silently shrink to one group's worth of processors. let mut class_of = std::collections::BTreeMap::new(); let mut core_of = std::collections::BTreeMap::new(); + let mut any_core_domain = false; for core in topology.cores() { + any_core_domain = true; for id in core.processors.iter() { core_of.insert(id, core.id); } @@ -559,7 +608,9 @@ pub fn places_from_topology(topology: &Topology) -> Result, // the rule, so the two cannot disagree about which level partitions the // host or about how many partitions it has. let mut cache_of = std::collections::BTreeMap::new(); + let mut any_cache_partition = false; if let Some((_, partitions)) = topology.outermost_partitioning_cache() { + any_cache_partition = true; for domain in partitions { for id in domain.processors.iter() { cache_of.insert(id, domain.id); @@ -583,29 +634,70 @@ pub fn places_from_topology(topology: &Topology) -> Result, .map(|processor| { let id = (processor.id.group, processor.id.number); let (group, number) = id; + let refuse = |missing| { + Err(UnplacedProcessor { + group, + number, + missing, + }) + }; + + // Each attribute below follows one rule: an absence that is + // **uniform** across the machine is a real answer, and an absence + // that singles this processor out is a gap that must not be filled + // in. Inventing a value in the second case does not merely lose + // information -- it produces a value that compares *equal* to a + // real one, and `classify` then reports a shared core, class or + // cache that the machine does not have. + let core = match core_of.get(&id).copied() { + Some(core) => core, + // Synthetic, and collision-free precisely because no core + // domain exists to collide with: `group << 8 | number` is + // distinct per processor, which is what keeps group 1's cpu5 + // off group 0's. Reached only when the topology names no core + // at all, so no real core id shares the namespace. + None if !any_core_domain => u32::from(group) << 8 | u32::from(number), + None => return refuse(MissingPlacement::Core), + }; + let efficiency_class = match class_of.get(&id).copied() { + Some(class) => class, + // Zero is what the topology crate reports for a processor with + // no known owning core, so a machine with no core domains at + // all is uniformly class zero rather than partly unknown. + // + // No `MissingPlacement::EfficiencyClass` arm, because there is + // no input that reaches one: `cores()` yields only + // `DomainKind::Core` domains and each carries a class, so this + // map has exactly `core_of`'s keys and the refusal above has + // already returned. + None if !any_core_domain => 0, + None => return refuse(MissingPlacement::Core), + }; + let cache_domain = match cache_of.get(&id).copied() { + Some(domain) => Some(domain), + // `None` means "no level partitions this machine", which is a + // real and uniform answer. It must not also mean "this + // processor was left out of the level that does": two omitted + // processors would then compare equal and be reported as + // sharing a cache. + None if !any_cache_partition => None, + None => return refuse(MissingPlacement::CacheDomain), + }; let numa_node = match numa_of.get(&id).copied() { Some(node) => node, - // Node 0 is the correct answer rather than a fallback *only* - // here: a topology naming no memory domain at all describes a - // machine with one node, and every processor is in it. + // The same rule again: a topology naming no memory domain + // describes a machine with one node, and every processor is in + // it. None if !any_memory_domain => 0, - None => return Err(UnplacedProcessor { group, number }), + None => return refuse(MissingPlacement::NumaNode), }; + Ok(ProcessorPlace { group, number, - // The fallback keeps distinct processors distinct across groups: - // a topology that reports no core for this processor must not - // collapse group 1's cpu5 onto group 0's. - core: core_of - .get(&id) - .copied() - .unwrap_or_else(|| u32::from(group) << 8 | u32::from(number)), - // Zero is what the topology crate itself reports for a processor - // with no known owning core, so this agrees with `Processor`'s - // own `capacity` rather than inventing a separate convention. - efficiency_class: class_of.get(&id).copied().unwrap_or(0), - cache_domain: cache_of.get(&id).copied(), + core, + efficiency_class, + cache_domain, numa_node, }) }) diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index 9c7e5d3e..e8764d50 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -597,7 +597,7 @@ mod multi_group_conversion { Domain, DomainKind, Processor, ProcessorId, ProcessorSet, Topology, }; - use crate::fingerprint::places_from_topology; + use crate::fingerprint::{MissingPlacement, places_from_topology}; /// One processor per core, four cores per group, two groups -- with the /// numbers overlapping, which is how Windows really presents it. @@ -854,4 +854,143 @@ mod multi_group_conversion { nodes.sort_unstable(); assert_eq!(nodes, vec![1, 2], "the real node numbers must survive"); } + + // --- Unknown must never masquerade as known ------------------------------- + // + // Placing every online processor (rather than only those a core domain + // mentions) made three fallbacks reachable that had previously been dead: + // a synthetic core id, class zero, and a `None` cache domain. Each compares + // *equal* to a real value, so `classify` would report a shared core, class + // or cache the machine does not have. Each is now refused instead, and each + // test below pins the false sharing that would otherwise be reported. + + /// `bare_processors`, plus a real core domain covering the given numbers. + fn with_core_domain(count: u8, id: u32, members: &[u8]) -> Topology { + let mut topology = bare_processors(count); + let mask = members.iter().fold(0_usize, |m, n| m | (1 << n)); + topology.domains.push(Domain { + kind: DomainKind::Core { + simultaneous_multithreading: members.len() > 1, + efficiency_class: 0, + }, + id, + processors: ProcessorSet::from_group_mask(0, mask), + }); + topology + } + + #[test] + fn a_synthetic_core_id_that_would_collide_with_a_real_one_is_refused() { + // The collision, concretely. cpu2 has no core domain, so the old + // fallback gave it `group << 8 | number` == 2 -- and core domain id 2 + // genuinely exists here, covering cpu0 and cpu1. `classify` compares + // group and core, so it would have called cpu0 and cpu2 SMT siblings + // and attributed a shared L1 that does not exist. + let topology = with_core_domain(3, 2, &[0, 1]); + + let refused = places_from_topology(&topology) + .expect_err("cpu2 has no core, and core id 2 is already taken by a real domain"); + + assert_eq!((refused.group, refused.number), (0, 2)); + assert_eq!(refused.missing, MissingPlacement::Core); + assert!(refused.to_string().contains("g0/cpu2"), "{refused}"); + } + + #[test] + fn a_machine_with_no_core_domains_at_all_still_gets_distinct_synthetic_cores() { + // The other side of that rule: with no core domain anywhere there is no + // real id to collide with, so the synthetic namespace is safe and the + // fallback stays. It must still keep processors apart across groups, + // which is the reason it exists. + let mut topology = bare_processors(1); + topology.processors.push(Processor { + id: ProcessorId { + group: 1, + number: 0, + }, + online: true, + capacity: 0, + }); + topology.domains.push(Domain { + kind: DomainKind::Group, + id: 1, + processors: ProcessorSet::from_group_mask(1, 0b1), + }); + + let places = + places_from_topology(&topology).expect("no core domain exists to collide with"); + + assert_eq!(places.len(), 2); + assert_ne!( + places[0].core, places[1].core, + "g0/cpu0 and g1/cpu0 must not share a synthetic core id" + ); + } + + #[test] + fn an_unknown_efficiency_class_is_not_reported_as_class_zero() { + // Zero is a genuine Windows efficiency class, so an uncovered processor + // defaulting to it becomes indistinguishable from a real class-0 core -- + // and `within_class_pair` would pair them as a same-class measurement. + // The class travels with the core, so this is the same refusal. + let topology = with_core_domain(2, 7, &[0]); + + let refused = places_from_topology(&topology) + .expect_err("cpu1 has no core, so its class is unknown rather than zero"); + + assert_eq!((refused.group, refused.number), (0, 1)); + assert_eq!(refused.missing, MissingPlacement::Core); + } + + #[test] + fn a_processor_omitted_from_the_partitioning_cache_level_is_refused() { + // `None` already means "no level partitions this machine". Letting it + // also mean "this processor was left out of the level that does" makes + // two omitted processors compare equal, and `classify` reports them as + // sharing a cache -- a confident same-cache row for a pair whose cache + // membership nobody knows. + // + // Three processors, each its own core so the level genuinely divides + // them, and an L2 partition covering only two. + let mut topology = bare_processors(3); + for (id, member) in [(10_u32, 0_u8), (11, 1), (12, 2)] { + topology.domains.push(Domain { + kind: DomainKind::Core { + simultaneous_multithreading: false, + efficiency_class: 0, + }, + id, + processors: ProcessorSet::from_group_mask(0, 1 << member), + }); + } + for (id, mask) in [(20_u32, 0b001_usize), (21, 0b010)] { + topology.domains.push(Domain { + kind: DomainKind::Cache { + level: 2, + associativity: 8, + line_size: 64, + size_bytes: 1024 * 1024, + cache_type: windows_topology_sys::CacheKind::Unified, + }, + id, + processors: ProcessorSet::from_group_mask(0, mask), + }); + } + + let refused = places_from_topology(&topology) + .expect_err("cpu2 is in no cache domain at the partitioning level"); + + assert_eq!((refused.group, refused.number), (0, 2)); + assert_eq!(refused.missing, MissingPlacement::CacheDomain); + } + + #[test] + fn no_partitioning_cache_at_all_is_reported_as_none_rather_than_refused() { + // The uniform absence, which is a real answer: a machine no cache level + // divides has `None` for every processor, and that must keep working. + let places = + places_from_topology(&bare_processors(2)).expect("no cache level divides this machine"); + + assert!(places.iter().all(|place| place.cache_domain.is_none())); + } } diff --git a/tools/run-mutants.ps1 b/tools/run-mutants.ps1 index 778c72f1..b2fbb940 100644 --- a/tools/run-mutants.ps1 +++ b/tools/run-mutants.ps1 @@ -150,14 +150,29 @@ if (-not $OutputDirectory) { # Stamped per run, not merely per scope. A path derived from the package or # file alone is the same path every time, so a second run of the same scope # overwrites the analysis this parameter promises to preserve -- and two - # concurrent runs write into one directory. The stamp sorts chronologically, - # so the most recent run is the last one listed. + # concurrent runs write into one directory. + # + # The timestamp sorts chronologically so the most recent run reads last, and + # the short random suffix is what actually makes it unique: the stamp has + # one-second resolution, so two runs launched inside the same second -- a + # script starting several scopes at once, which is the case that most wants + # separate output -- would otherwise select the same directory and interleave + # their results. $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss') - $OutputDirectory = Join-Path $repo ".scratch\mutants-$leaf-$stamp" + $unique = [guid]::NewGuid().ToString('N').Substring(0, 6) + $OutputDirectory = Join-Path $repo ".scratch\mutants-$leaf-$stamp-$unique" } $werKey = 'HKCU:\Software\Microsoft\Windows\Windows Error Reporting' $hadKey = Test-Path $werKey +# Anything WER or the JIT debugger is already holding when this starts belongs +# to somebody else -- an unrelated crash report the user is reading, or a live +# debugging session. Recording them now is what lets the cleanup below kill only +# what this run produced. Matching by name alone would terminate those too. +$preexistingFaultHandlers = @( + Get-Process -Name 'WerFault', 'WerFaultSecure', 'vsjitdebugger' -ErrorAction SilentlyContinue | + ForEach-Object { $_.Id } +) # `.GetValue(name, $null)` rather than `Get-ItemProperty -Name`: under # `Set-StrictMode -Version Latest` the latter throws when the property is absent # (which is the default state of this one), so the wrapper died before it could @@ -197,8 +212,17 @@ finally { # the dialog suppressed these should not appear at all; killing them is # insurance against a stale one pinning a target file and failing the next # build with a confusing "access denied". + # + # **Only the ones this run produced.** An earlier version matched by name, + # which also terminated a crash report the user was reading or a debugger + # they had attached to something else entirely -- a wrapper for a mutation + # sweep has no business doing that. Processes alive before this started are + # excluded by id, and a pid recorded then cannot be confused with a later + # one: Windows will not reuse it while the process object is still open, + # and these are all still running when the snapshot is taken. foreach ($name in 'WerFault', 'WerFaultSecure', 'vsjitdebugger') { Get-Process -Name $name -ErrorAction SilentlyContinue | + Where-Object { $preexistingFaultHandlers -notcontains $_.Id } | ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue } } @@ -212,6 +236,12 @@ finally { Write-Report "WER dialog setting restored." -Level note } +# cargo-mutants treats `--output` as the PARENT and creates `mutants.out` inside +# it, so this join is correct rather than a doubled path. Verified on this +# workspace: a run with `--output .scratch\mutants-encoding-` produced +# `.scratch\mutants-encoding-\mutants.out\caught.txt` with 22 lines, and +# the summary below reported 22. Written down because the path reads like a +# duplication and has already been challenged once. $out = Join-Path $OutputDirectory 'mutants.out' foreach ($name in 'caught', 'missed', 'timeout', 'unviable') { $path = Join-Path $out "$name.txt" From 84b3eb63ba3d7c730b80ffcf3ca6c2c720a3d993 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 22:20:13 -0400 Subject: [PATCH 184/361] fix(placement-probe): survive a stale temporary, and stop two tools bypassing their sink The backup's temporary was named for the record plus this process's id and nothing else, and created with create_new. A run killed mid-write leaves that file behind, and Windows reuses process ids -- so a later run issued the same id found the corpse under the only name it would ever try. The resulting AlreadyExists left write_temporary *before* the caller's suffix loop was reached, so the whole backup failed rather than landing under a next-best name. The temporary now carries its own attempt counter, matching the final name's budget. A stale file is stepped around rather than overwritten: it belongs to whatever left it. Sabotage-verified -- re-injecting the single-name temporary fails the new test with exactly the reported error, Os code 80 AlreadyExists. Both tools also bypassed the single-output-sink contract their own Write-Report doc comment states. run-mutants.ps1 emitted its per-category summary directly to the success stream; run-sabotage.ps1 did the same for the -List output, every blank line, the result table, and the injected patch text. All now route through the sink, which gained pipeline binding so a formatted table can flow into it. Verified: the -List path emits zero objects to the success stream. Checking that output turned up a worse defect in the same script. The harness prepends the `test` subcommand to a manifest's testArgs, but nine of the eleven manifests already begin with `test`. The result was `cargo test test -p ...`, in which the second word is not a subcommand but a TESTNAME filter -- a sweep claiming to run a package's suite while running a subset of it, the same false-green this tool exists to prevent. The vector is now normalised so either manifest spelling produces one `test`. No prior verification was weakened. Every crate swept so far keeps its tests under a `mod tests`, so the accidental filter matched all of them, and all fourteen recorded baselines report "0 filtered out". The defect was latent, and would have appeared on the first crate laid out differently. Completed items: SH-11.5, SH-11.6, SH-11.7 Completed item: SH-11.5: A hard-killed run could block the next one's backup entirely Completed item: SH-11.6: Both tools bypassed their own single-output-sink contract Completed item: SH-11.7: The sabotage harness silently narrowed its own sweep Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 27 +++++++++ .../src/bin/placement_probe/main.rs | 48 ++++++++++++--- .../src/bin/placement_probe/tests.rs | 38 ++++++++++++ tools/run-mutants.ps1 | 18 +++--- tools/run-sabotage.ps1 | 58 ++++++++++++------- 5 files changed, 154 insertions(+), 35 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 1b543d6a..6f2f527d 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -551,3 +551,30 @@ mutation wrapper. The conversion's three silent fallbacks are replaced by one ru `\mutants.out\caught.txt` with 22 lines, matching the 22 caught the wrapper reported. A comment now records the evidence, since the path reads like a duplication and has been challenged once already. + +- [x] **SH-11.5** -- **A hard-killed run could block the next one's backup entirely.** The temporary + was named for the record plus this process's id and nothing else, and created with `create_new`. A + run killed mid-write leaves that file behind, and Windows reuses process ids -- so a later run + issued the same id found the corpse under the only name it would ever try. The resulting + `AlreadyExists` left `write_temporary` *before* the caller's suffix loop was reached, so the whole + backup failed rather than landing under a next-best name. The temporary now carries its own + attempt counter, matching the final name's budget; a stale file is stepped around rather than + overwritten, since it belongs to whatever left it. + +- [x] **SH-11.6** -- **Both tools bypassed their own single-output-sink contract.** `run-mutants.ps1` + emitted its per-category summary directly to the success stream, and `run-sabotage.ps1` did the + same for the `-List` output, every blank line, the result table, and the injected patch text -- + each contradicting the `Write-Report` doc comment directly above them. All now route through the + sink, which gained pipeline binding so a formatted table can flow into it. Verified: the `-List` + path emits zero objects to the success stream. + +- [x] **SH-11.7** -- **The sabotage harness silently narrowed its own sweep.** Found while checking + SH-11.6's output: the harness prepends the `test` subcommand to a manifest's `testArgs`, but nine + of the eleven manifests already begin with `test`. The result was `cargo test test -p ...`, in + which the second word is not a subcommand but a TESTNAME filter -- a sweep claiming to run a + package's suite while running a subset of it, the same false-green this tool exists to prevent. + The vector is now normalised so either manifest spelling produces one `test`. + **No prior verification was weakened**: every crate swept so far keeps its tests under a + `mod tests`, so the accidental filter matched all of them, and all fourteen recorded baselines + report "0 filtered out". The defect was latent, and would have appeared on the first crate laid + out differently. diff --git a/crates/windows-placement-probe/src/bin/placement_probe/main.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs index 6a9b335a..7db7f7e7 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/main.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -331,18 +331,52 @@ fn write_backup_with( /// The path is returned even when the write failed, so the caller can remove it: /// a temporary left behind is litter under a name a collector might not /// recognise, which is only marginally better than litter under one it would. +/// +/// # Why the process id is not enough +/// +/// A hard-killed run leaves its `.partial` behind, and Windows reuses process +/// ids. A later run issued the same id would find the corpse under the only name +/// it would ever try, and `create_new` would fail with `AlreadyExists` -- from +/// *here*, before the caller's suffix loop is reached, so the whole backup would +/// fail rather than land under a next-best name. The id still separates +/// concurrent runs cheaply; the counter is what survives a stale one. fn write_temporary( name: &str, json: &str, write: &mut impl FnMut(&mut std::fs::File, &[u8]) -> std::io::Result<()>, ) -> std::io::Result<(String, std::io::Result<()>)> { - // The process id keeps two concurrent runs from colliding here, and the - // `.partial` suffix keeps the file out of any `*.json` collection. - let temporary = format!("{name}.{}.partial", std::process::id()); - let mut file = std::fs::File::create_new(&temporary)?; - let outcome = write(&mut file, json.as_bytes()).and_then(|()| file.sync_all()); - drop(file); - Ok((temporary, outcome)) + /// Matches the caller's final-name budget: the two loops fail for the same + /// reason and there is no cause to give one more patience than the other. + const MAX_ATTEMPTS: u32 = 100; + + let id = std::process::id(); + let mut last = None; + for attempt in 0..MAX_ATTEMPTS { + // The `.partial` suffix keeps the file out of any `*.json` collection. + let temporary = if attempt == 0 { + format!("{name}.{id}.partial") + } else { + format!("{name}.{id}-{attempt}.partial") + }; + match std::fs::File::create_new(&temporary) { + Ok(mut file) => { + let outcome = write(&mut file, json.as_bytes()).and_then(|()| file.sync_all()); + drop(file); + return Ok((temporary, outcome)); + } + // Taken -- by a live concurrent run, or by the remains of a dead one + // that held this id first. Either way the next name is untried. + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => last = Some(error), + Err(error) => return Err(error), + } + } + + Err(last.unwrap_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("{MAX_ATTEMPTS} temporaries starting from {name}.{id}.partial were all taken"), + ) + })) } /// Move `temporary` onto `final_name`, failing rather than replacing. diff --git a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs index b1e5b647..09f8921e 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs @@ -255,3 +255,41 @@ fn a_failed_write_never_creates_the_records_name_at_all() { .collect(); assert!(left.is_empty(), "nothing at all should remain: {left:?}"); } + +#[test] +fn a_stale_temporary_from_a_recycled_pid_does_not_fail_the_backup() { + // The defect: the temporary's name was the record's plus this process's id + // and nothing else, and it was created with `create_new`. A run that is hard + // killed leaves that file behind, and Windows reuses process ids -- so a + // later run issued the same id finds the corpse under the only name it would + // ever try. `create_new` fails with `AlreadyExists`, and because that error + // left `write_temporary` before the caller's suffix loop was reached, the + // whole backup failed rather than landing under a next-best name. + // + // Standing in for the recycled id: this process's own id is what the code + // will use, so writing that exact file first is indistinguishable from + // having inherited it. + let dir = scratch("stale-partial"); + let name = dir.join("record.json"); + let name = name.to_str().expect("utf-8 path"); + + let stale = format!("{name}.{}.partial", std::process::id()); + std::fs::write(&stale, "WRECKAGE").expect("the stale temporary must be creatable"); + + let written = + write_backup_to_new_file(name, "GOOD").expect("a stale temporary must not fail the backup"); + + assert_eq!( + written, name, + "the record must still get its canonical name: the collision was on the \ + temporary, which no reader ever sees, so it must not push the record \ + onto a suffix" + ); + assert_eq!(std::fs::read_to_string(&written).expect("readable"), "GOOD"); + assert_eq!( + std::fs::read_to_string(&stale).expect("readable"), + "WRECKAGE", + "the stale file belongs to whatever left it; stepping around it is the \ + fix, overwriting it would be a second bug" + ); +} diff --git a/tools/run-mutants.ps1 b/tools/run-mutants.ps1 index b2fbb940..3c61c3db 100644 --- a/tools/run-mutants.ps1 +++ b/tools/run-mutants.ps1 @@ -132,16 +132,18 @@ $ErrorActionPreference = 'Stop' # produce the content -- the repository's one-output-sink rule. function Write-Report { param( - [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message, + [Parameter(Mandatory = $true, ValueFromPipeline = $true)][AllowEmptyString()][string] $Message, [ValidateSet('info', 'detail', 'note', 'warn')][string] $Level = 'info' ) - $colour = switch ($Level) { - 'detail' { 'DarkGray' } - 'note' { 'Cyan' } - 'warn' { 'Yellow' } - default { 'Gray' } + process { + $colour = switch ($Level) { + 'detail' { 'DarkGray' } + 'note' { 'Cyan' } + 'warn' { 'Yellow' } + default { 'Gray' } + } + Write-Host $Message -ForegroundColor $colour } - Write-Host $Message -ForegroundColor $colour } $repo = (git rev-parse --show-toplevel).Replace('/', '\') @@ -246,7 +248,7 @@ $out = Join-Path $OutputDirectory 'mutants.out' foreach ($name in 'caught', 'missed', 'timeout', 'unviable') { $path = Join-Path $out "$name.txt" $count = if (Test-Path $path) { (Get-Content $path | Measure-Object -Line).Lines } else { 0 } - "{0,-9} {1}" -f $name, $count + "{0,-9} {1}" -f $name, $count | Write-Report } Write-Report "results: $out" -Level detail diff --git a/tools/run-sabotage.ps1 b/tools/run-sabotage.ps1 index d9123534..48d65a1b 100644 --- a/tools/run-sabotage.ps1 +++ b/tools/run-sabotage.ps1 @@ -121,16 +121,18 @@ $utf8NoBom = [System.Text.UTF8Encoding]::new($false) # then exits, and is the one case where the destination is part of the meaning. function Write-Report { param( - [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message, + [Parameter(Mandatory = $true, ValueFromPipeline = $true)][AllowEmptyString()][string] $Message, [ValidateSet('info', 'note', 'good', 'bad')][string] $Level = 'info' ) - $colour = switch ($Level) { - 'note' { 'Cyan' } - 'good' { 'Green' } - 'bad' { 'Red' } - default { 'Gray' } + process { + $colour = switch ($Level) { + 'note' { 'Cyan' } + 'good' { 'Green' } + 'bad' { 'Red' } + default { 'Gray' } + } + Write-Host $Message -ForegroundColor $colour } - Write-Host $Message -ForegroundColor $colour } # Writes to stderr and exits with a code, rather than Write-Error, which under @@ -245,18 +247,33 @@ New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null $package = $spec.package $testArgs = @('test', '-p', $package, '--locked') if ($spec.PSObject.Properties.Name -contains 'testArgs' -and $spec.testArgs) { - $testArgs = @('test') + $spec.testArgs + # The manifest may write the vector either way -- with the `test` subcommand + # or starting at the flags -- and both spellings are in use. Normalising here + # rather than prepending unconditionally is what keeps a manifest that does + # include it from producing `cargo test test ...`, in which the second word is + # not a subcommand but a TESTNAME filter, silently narrowing the sweep to the + # tests whose path happens to contain "test". + # + # That defect went unnoticed because every test in the crates swept so far + # lives under a `mod tests`, so the accidental filter matched all of them -- + # every baseline recorded "0 filtered out". A crate laid out differently would + # have run a subset and still reported a clean sweep. + $supplied = @($spec.testArgs) + if ($supplied.Count -gt 0 -and $supplied[0] -eq 'test') { + $supplied = @($supplied | Select-Object -Skip 1) + } + $testArgs = @('test') + $supplied } $selected = @($spec.sabotages | Where-Object { $_.name -like $Name }) if ($List) { - "Manifest : $manifestPath" - "Package : $package" - "Command : cargo $($testArgs -join ' ')" - '' + Write-Report "Manifest : $manifestPath" + Write-Report "Package : $package" + Write-Report "Command : cargo $($testArgs -join ' ')" + Write-Report '' $selected | ForEach-Object { - "{0,-10} {1}" -f $_.expect, $_.name + Write-Report ("{0,-10} {1}" -f $_.expect, $_.name) } exit 0 } @@ -300,7 +317,7 @@ if ($baseline.Outcome -ne 'passed') { ) -join "`n") 2 } Write-Report 'Baseline is green. Sweeping.' -Level note -'' +Write-Report '' $results = @() @@ -376,8 +393,9 @@ foreach ($sabotage in $selected) { Write-Report ("{0,-58} {1}" -f $sabotage.name, $actual) -Level $level } -'' -$results | Select-Object Sabotage, Expected, Actual, Ok | Format-Table -AutoSize -Wrap +Write-Report '' +$results | Select-Object Sabotage, Expected, Actual, Ok | Format-Table -AutoSize -Wrap | + Out-String | ForEach-Object { $_.TrimEnd("`r", "`n") } | Write-Report $unexpected = @($results | Where-Object { -not $_.Ok }) if ($unexpected.Count -eq 0) { @@ -385,14 +403,14 @@ if ($unexpected.Count -eq 0) { exit 0 } -'' +Write-Report '' Write-Report 'UNEXPECTED RESULTS -- read the patch before concluding the tests have a hole.' -Level bad Write-Report 'A sabotage that does not actually break anything will be survived for an honest reason.' -Level bad foreach ($result in $unexpected) { - '' + Write-Report '' Write-Report " $($result.Sabotage)" -Level bad Write-Report " expected $($result.Expected), got: $($result.Actual)" - $result.Patch + $result.Patch | Write-Report } -'' +Write-Report '' Exit-WithMessage "$($unexpected.Count) of $($results.Count) sabotages did not behave as declared." 1 From 0d2654622cd5edcfdc9b3351d2c1a617340fe782 Mon Sep 17 00:00:00 2001 From: Michael Grier Date: Tue, 1 Sep 2026 22:41:38 -0400 Subject: [PATCH 185/361] test(file-watcher): cover monitor mutation gaps The confirming all-feature sweep reports 47 caught, 18 unviable, 0 missed, and 2 timeout detections with 34 and 33 tests already failing. Completed item: M16.1: Adjudicate every mutant missed by the 2026-09-01 `monitor.rs` sweep. Add focused unit tests for genuine behavioral gaps in request formatting, registration and fault-state observation, re-key bookkeeping, file-parent failure classification, pending retry policy, liveness suppression while coalescing onto a faulted watcher, and volume-change answer cleanup. Verify every retained test by injecting the exact mutant it is meant to catch; record any equivalent or unreachable mutant rather than adding a proxy assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/CHECKLIST.md | 4 +- .../COMPLETED-CHECKLIST.md | 33 ++- .../windows-file-watcher/COMPLETED-PLANS.md | 1 + crates/windows-file-watcher/PLANS.md | 8 +- crates/windows-file-watcher/src/monitor.rs | 16 +- .../windows-file-watcher/src/monitor/tests.rs | 269 +++++++++++++++++- crates/windows-file-watcher/src/watcher.rs | 16 ++ 7 files changed, 332 insertions(+), 15 deletions(-) diff --git a/crates/windows-file-watcher/CHECKLIST.md b/crates/windows-file-watcher/CHECKLIST.md index 1e0b719d..40decfb4 100644 --- a/crates/windows-file-watcher/CHECKLIST.md +++ b/crates/windows-file-watcher/CHECKLIST.md @@ -15,8 +15,8 @@ with origin) is standard procedure and is not listed as an item. Completed milestones are archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md). -> **NEXT ACTIONABLE ITEM: none.** M1 through M14 are archived/done. Only the parked, ungated M-inf horizon -> items remain, and none is a current obligation. +> **NEXT ACTIONABLE ITEM: none.** M1 through M16 are done. Only the parked, ungated M-inf horizon items +> remain, and none is a current obligation. ## M4 -- Coalescing by directory and file targets diff --git a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md index 4beaac7b..1a783a62 100644 --- a/crates/windows-file-watcher/COMPLETED-CHECKLIST.md +++ b/crates/windows-file-watcher/COMPLETED-CHECKLIST.md @@ -1027,4 +1027,35 @@ The two that remain (`Receiver::recv_timeout -> None` and `take -> None`) had ** failed** before the kill, and neither is held by any single slow test. They are the inherent floor rather than a defect: a mutant that breaks the queue's core makes most of the suite fail, and a suite of failures takes longer than a suite of passes -- so it overruns a deadline set at 3x the *passing* baseline. No -test-side bound can reach that, and nothing is being missed. \ No newline at end of file +test-side bound can reach that, and nothing is being missed. + +## Moved 2026-09-01 -- M16.1: close the monitor mutation-testing gaps + +### M16.1 -- Adjudicate every mutant missed by the 2026-09-01 `monitor.rs` sweep. *(completed 2026-09-01 22:39:25 -04:00)* + +The input sweep ran 317 default-feature tests and reported 15 missed mutants, 18 unviable mutants, and no +timeouts. All 15 misses were genuine observability gaps, grouped into seven contracts rather than treated as +unrelated source edits: + +- `Request` debug output names its variant and fields. +- registration, transient fault detail, and permanent stop reason are observable through `Monitor`. +- re-keying updates each routed subscription's stored `DirectoryId`, so later cancellation retires the + shared watcher. +- a file target's parent-open failure retains retryable versus permanent classification. +- a standing slot reserved only for volume confirmation does not make open-fault recovery interactive. +- `Established` is suppressed when a new route coalesces onto a faulted watcher with no settled tier. +- stopping the last route after a volume-change question removes both subscription and directory resident + state. + +The two duplicated boolean rules were made derived facts: `awaits_open_answer` is shared by first-attempt and +later open retries, and `should_report_established` owns the liveness/fault predicate. Exhaustive truth-table +tests cover both predicates, while behavioral tests prove each is bound to the monitor path rather than tested +cosmetically. Three test-only `DirectoryWatcher` seams put an existing watcher into the transient-fault, +permanent-stop, and volume-change states that the monitor projects or resolves; production behavior is +unchanged. + +The all-feature confirming sweep tested 67 mutants in 10 minutes: 47 caught, 18 unviable, **0 missed**, and 2 +timeouts. Both timeouts are detections rather than gaps: `Core::submit -> Ok(())` produced 34 failing tests +before the 55-second kill, and `service -> ()` produced 33. Default and all-feature crate tests (including +doctests), default and all-feature crate Clippy, workspace Clippy, and debug/release workspace checks all +passed. diff --git a/crates/windows-file-watcher/COMPLETED-PLANS.md b/crates/windows-file-watcher/COMPLETED-PLANS.md index 4c66f79b..1e0b2ebc 100644 --- a/crates/windows-file-watcher/COMPLETED-PLANS.md +++ b/crates/windows-file-watcher/COMPLETED-PLANS.md @@ -8,3 +8,4 @@ Append-only archive of completed checklists, moved out of [PLANS.md](PLANS.md). | [CHECKLIST.md](CHECKLIST.md) | 2026-08-23 | PR #20 review response (M10-M12): M10 gives a client the real `FailureCode`/`OpenFailure` behind a fault or permanent stop instead of only which operation faulted (D-79, supersedes D-54). M11 re-keys a coalesced directory's identity on reopen and fixes a stale `DirectoryId` map key; empirical investigation found `ReOpenFile` needs `SeBackupPrivilege` and an `OpenFileById`-based fast path hangs/crashes once armed on the IOCP, so that fast path is disabled and the fully-tested path-based-only reopen is the only mechanism in play (D-80). M12 adds an opt-in per-subscription confirmation (`VolumeChangePolicy::Confirm`, `ArmGate::VolumeChangePending`, `Notification::VolumeChanged`, `Session::answer_volume_change`) when a reopen lands on a different volume than before (D-78). Decisions D-77...D-80 are recorded. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | | [CHECKLIST.md](CHECKLIST.md) | 2026-08-25 | M13 consumer test surface: an off-by-default `test-util` feature exposing the feed channel (`channel_with_bound`/`Sender`/`Delivery`/`Reservation`, previously `pub` only inside the private `queue` module) and valid-by-construction `for_test` builders for the two otherwise-unconstructible boundary types (`RelativeName`, `VolumeIdentity`), plus re-documented already-public pieces (`WatchId::from_raw`), a crate-level "Testing your consumer code" section, a runnable example, and a downstream-style integration test -- so a consumer can drive its own notification-handling code with synthetic notifications through the real `Receiver`, with no filesystem and no thread pool. The first instantiation of the "enable consumers to go below" testability pattern. Decisions D-81/D-82/D-83; D-82 reconciled with D-64 by audience. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | | [CHECKLIST.md](CHECKLIST.md) | 2026-08-27 | M14 contract audit: the converse of PR #42's 19 reactive review rounds -- a deliberate pass asking, for each of the ten workspace specification-gap categories, whether this crate states the answer or leaves it to omission. M14.1 (D-12/D-27/D-28/D-30) found three shipped documentation defects: `DesyncCause`'s type-level doc claimed every cause is advisory and answered by a re-scan while its own `Stopped` variant said the opposite, "The Desync primitive" enumerated four causes of five, and "Delivery and saturation" still described the pre-D-29 drop policy plus the exact latch phrasing D-39 corrects. It also stated two load-bearing rules for the first time: the standing slot shared by `RetryQuestion`/`VolumeChanged` rests on a mutual-exclusion invariant, and "every request produces a completion" holds for lifecycle requests only. M14.2 (D-10/D-13/D-17/D-26/D-57) found three legal sequences the contract excluded -- a liveness bracket can open with `Resumed` and can close with `Desync { Stopped }` instead of `Resumed`, `Established` is not necessarily a watch's first notification, and the tier is re-resolved on every reopen -- all folded into the harness's `schedule` docs. M14.3 swept all nine advisory predicates for the `has_room` shape and found one: `Receiver::is_empty` excludes owed latches and end-of-stream, so a client waiting on the doorbell (signalled on all three, D-41) would spin; fixed by publishing D-41's own predicate as `Receiver::has_pending`. The subsequent review round found the paired defect in the original `has_room` fix: the wake edge (`free() == 1`) had not been updated to match the revised predicate (`free() > latched.len()`), so a latch owed meant the prod fired one slot early and never re-fired, wedging the watcher -- both are now derived from a single `best_effort_room()`. Decision D-84 records what a second implementation of the contract exposed. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | +| [CHECKLIST.md](CHECKLIST.md) | 2026-09-01 | M16 monitor mutation closure: 15 surviving mutants were covered by focused tests for request formatting, monitor state projection, re-key cleanup, file-parent classification, retry-slot policy, faulted-watcher liveness suppression, and volume-change cleanup. Shared predicates now own the duplicated boolean rules. The confirming all-feature sweep found 47 caught, 18 unviable, 0 missed, and 2 timeout detections with 34/33 tests already failing. | N/A | diff --git a/crates/windows-file-watcher/PLANS.md b/crates/windows-file-watcher/PLANS.md index 0fe4a404..6c3da7d3 100644 --- a/crates/windows-file-watcher/PLANS.md +++ b/crates/windows-file-watcher/PLANS.md @@ -4,11 +4,9 @@ Active planned work for the crate. Completed checklists are archived in [COMPLETED-PLANS.md](COMPLETED-PLANS.md), and their milestones in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md). -All milestones through M14 (M10-M12 the PR #20 review response; M13 the consumer test surface; M14 the -ten-category contract audit) are complete, so there is no active plan. [CHECKLIST.md](CHECKLIST.md) remains -for its parked `M-inf` horizon bucket: work placed outside v1 by a recorded design decision, holding nothing -pending. A row returns to the table below when a post-v1 line of work graduates a horizon item into a -numbered milestone. +All numbered milestones through M16 are complete, so there is no active plan. [CHECKLIST.md](CHECKLIST.md) +remains for its parked `M-inf` horizon bucket, which is outside v1 by recorded design decision and is not +pending work. | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| diff --git a/crates/windows-file-watcher/src/monitor.rs b/crates/windows-file-watcher/src/monitor.rs index c0a7f21e..3999199e 100644 --- a/crates/windows-file-watcher/src/monitor.rs +++ b/crates/windows-file-watcher/src/monitor.rs @@ -796,6 +796,11 @@ fn open_file_target(path: &std::path::Path) -> Opened { } } +/// Whether an open-class fault must wait for this route's interactive answer. +fn awaits_open_answer(retry: RetryMode, has_fault_slot: bool) -> bool { + retry == RetryMode::Interactive && has_fault_slot +} + /// Build the timer that fires this subscription's next open retry (M5.1). The /// callback submits `Request::Retry` through a `Weak`, resolved lazily /// (see `Monitor::new`) because `Core` cannot exist until after the servicing @@ -836,7 +841,7 @@ fn park_pending( // subscription's eventual permanent failure (rare) falls back to the // ordinary best-effort path rather than blocking registration on it. let terminal = sink.reserve(); - let awaiting_answer = options.retry == RetryMode::Interactive && fault_slot.is_some(); + let awaiting_answer = awaits_open_answer(options.retry, fault_slot.is_some()); let mut state = lock(resident); state.subscriptions.insert( watch, @@ -873,6 +878,11 @@ fn park_pending( Ok(()) } +/// Whether a settled-tier notification accurately describes this route now. +fn should_report_established(report_liveness: bool, is_faulted: bool) -> bool { + report_liveness && !is_faulted +} + /// Route a successfully opened target into a coalesced watcher (D-6), starting /// one if this is the first subscription to reach this directory. /// @@ -947,7 +957,7 @@ fn route_established( // `Suspended`/`RetryQuestion` fault notifications, and will get its // own `Resumed`/`Established` from `resolve_fault_success` once (if) // the watcher recovers. - if options.report_liveness && !is_faulted { + if should_report_established(options.report_liveness, is_faulted) { let _ = sink.send(Notification::Established { watch, mode }); } return Routed::Live; @@ -1060,7 +1070,7 @@ fn retry_pending( match open_target(&path, options.subtree) { Opened::Pending(detail) => { - let awaiting_answer = options.retry == RetryMode::Interactive && fault_slot.is_some(); + let awaiting_answer = awaits_open_answer(options.retry, fault_slot.is_some()); let mut state = lock(resident); state.subscriptions.insert( watch, diff --git a/crates/windows-file-watcher/src/monitor/tests.rs b/crates/windows-file-watcher/src/monitor/tests.rs index 00c86515..d2cef4f3 100644 --- a/crates/windows-file-watcher/src/monitor/tests.rs +++ b/crates/windows-file-watcher/src/monitor/tests.rs @@ -8,11 +8,18 @@ use std::time::{Duration, Instant}; -use super::Monitor; -use crate::queue::{Notification, Receiver}; +use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_NOT_SUPPORTED}; + +use super::{ + Monitor, Opened, Request, Subscription, awaits_open_answer, lock, open_file_target, + should_report_established, +}; +use crate::directory::{DirectoryHandle, FaultDetail, OpenFailure, VolumeIdentity}; +use crate::queue::{Notification, Receiver, WatchId}; +use crate::retry::FaultOperation; use crate::session::Session; use crate::testing::TempDir; -use crate::watch::{Watch, WatchOptions}; +use crate::watch::{RetryMode, VolumeChangeDecision, VolumeChangePolicy, Watch, WatchOptions}; /// What teardown is allowed to take. Cancellation retires an outstanding read at /// once, so this only fires if teardown waited for a change instead. @@ -348,10 +355,14 @@ fn cancelling_the_last_coalesced_subscription_tears_down_the_watcher() { .expect("register"); monitor.quiesce(); + let a_id = a.id(); + let b_id = b.id(); a.cancel(); b.cancel(); monitor.quiesce(); + assert!(!monitor.is_registered(a_id)); + assert!(!monitor.is_registered(b_id)); assert_eq!(monitor.watcher_count(), 0); assert_eq!(monitor.directory_count(), 0, "the watcher is torn down"); @@ -529,7 +540,15 @@ fn a_path_based_reopen_that_lands_on_a_new_directory_rekeys_so_a_later_subscript "the recreated directory's watcher must be re-keyed, not duplicated" ); - drop((watch, second)); + watch.cancel(); + second.cancel(); + monitor.quiesce(); + assert_eq!( + monitor.directory_count(), + 0, + "cancelling re-keyed routes must retire their shared watcher" + ); + drop(monitor); dir.cleanup(); } @@ -622,3 +641,245 @@ fn a_path_based_reopen_that_collides_with_another_watched_directory_migrates_rou // following it into `dir_a`. dir_b.cleanup(); } + +fn with_only_directory_watcher( + monitor: &Monitor, + inspect: impl FnOnce(&crate::watcher::DirectoryWatcher) -> T, +) -> T { + let resident = lock(&monitor.resident); + assert_eq!(resident.directories.len(), 1); + inspect( + resident + .directories + .values() + .next() + .expect("the directory watcher exists"), + ) +} + +#[test] +fn request_debug_reports_the_variant_and_fields() { + let request = Request::Retry { + watch: WatchId::from_raw(17), + }; + + assert_eq!(format!("{request:?}"), "Retry { watch: WatchId(17) }"); +} + +#[test] +fn only_interactive_routes_with_a_standing_slot_await_an_open_answer() { + let cases = [ + (RetryMode::Defaults, false, false), + (RetryMode::Defaults, true, false), + (RetryMode::Interactive, false, false), + (RetryMode::Interactive, true, true), + ]; + + for (retry, has_fault_slot, expected) in cases { + assert_eq!( + awaits_open_answer(retry, has_fault_slot), + expected, + "retry={retry:?}, has_fault_slot={has_fault_slot}" + ); + } +} + +#[test] +fn established_is_reported_only_for_a_live_opted_in_route() { + let cases = [ + (false, false, false), + (false, true, false), + (true, false, true), + (true, true, false), + ]; + + for (report_liveness, is_faulted, expected) in cases { + assert_eq!( + should_report_established(report_liveness, is_faulted), + expected, + "report_liveness={report_liveness}, is_faulted={is_faulted}" + ); + } +} + +#[test] +fn file_target_parent_failures_retain_their_retry_classification() { + let dir = TempDir::new("monitor-file-parent-failures"); + + let missing_parent = dir.path().join("missing-parent").join("leaf.txt"); + match open_file_target(&missing_parent) { + Opened::Pending(detail) => assert!(detail.failure.is_retryable()), + Opened::Handle { .. } | Opened::Failed(_) => { + panic!("a missing parent must remain retryable") + } + } + + let file_parent = dir.path().join("not-a-directory"); + std::fs::write(&file_parent, b"x").expect("create the non-directory parent"); + match open_file_target(&file_parent.join("leaf.txt")) { + Opened::Failed(detail) => assert_eq!(detail.failure, OpenFailure::NotADirectory), + Opened::Handle { .. } | Opened::Pending(_) => { + panic!("a file used as the parent must fail permanently") + } + } + + dir.cleanup(); +} + +#[test] +fn monitor_projects_a_routed_watchers_fault_and_permanent_stop() { + let dir = TempDir::new("monitor-project-watcher-state"); + let monitor = Monitor::new().expect("create the monitor"); + let (session, _receiver) = monitor.session(); + let watch = session + .subscribe( + dir.path(), + WatchOptions::new().retry(RetryMode::Interactive), + ) + .expect("register the subscription"); + monitor.quiesce(); + + let detail = FaultDetail::synthetic(OpenFailure::Unsupported, ERROR_NOT_SUPPORTED); + with_only_directory_watcher(&monitor, |watcher| { + watcher.enter_fault_for_test(detail, FaultOperation::Arm); + }); + assert_eq!(monitor.fault_detail(watch.id()), Some(detail)); + assert!(monitor.stop_reason(watch.id()).is_none()); + + with_only_directory_watcher(&monitor, |watcher| { + watcher.record_stop_for_test(std::io::Error::from_raw_os_error( + i32::try_from(ERROR_ACCESS_DENIED).expect("Win32 errors fit in i32"), + )); + }); + assert_eq!( + monitor + .stop_reason(watch.id()) + .and_then(|error| error.raw_os_error()), + Some(i32::try_from(ERROR_ACCESS_DENIED).expect("Win32 errors fit in i32")) + ); + assert_eq!(monitor.fault_detail(watch.id()), None); + + drop(watch); + drop(monitor); + dir.cleanup(); +} + +#[test] +fn a_volume_confirmation_slot_does_not_make_open_retries_interactive() { + let dir = TempDir::new("monitor-volume-slot-open-retry"); + let target = dir.path().join("not-yet"); + let monitor = Monitor::new().expect("create the monitor"); + let (session, receiver) = monitor.session(); + let watch = session + .subscribe( + &target, + WatchOptions::new().on_volume_change(VolumeChangePolicy::Confirm), + ) + .expect("register the subscription"); + monitor.quiesce(); + + let resident = lock(&monitor.resident); + let Some(Subscription::Pending { + awaiting_answer, .. + }) = resident.subscriptions.get(&watch.id()) + else { + panic!("the missing target must be pending") + }; + assert!(!awaiting_answer); + drop(resident); + + while let Some(notification) = receiver.try_recv() { + assert!( + !matches!(notification, Notification::RetryQuestion { watch: id, .. } if id == watch.id()), + "a volume-only standing slot must not receive an open-fault question" + ); + } + + drop(watch); + drop(monitor); + dir.cleanup(); +} + +#[test] +fn coalescing_onto_a_faulted_watcher_suppresses_established() { + let dir = TempDir::new("monitor-faulted-coalesce"); + let monitor = Monitor::new().expect("create the monitor"); + let (session, receiver) = monitor.session(); + let first = session + .subscribe( + dir.path(), + WatchOptions::new().retry(RetryMode::Interactive), + ) + .expect("register the first subscription"); + monitor.quiesce(); + + let detail = FaultDetail::synthetic(OpenFailure::Unsupported, ERROR_NOT_SUPPORTED); + with_only_directory_watcher(&monitor, |watcher| { + watcher.enter_fault_for_test(detail, FaultOperation::Arm); + }); + + let second = session + .subscribe(dir.path(), WatchOptions::new().report_liveness(true)) + .expect("coalesce the second subscription"); + monitor.quiesce(); + + while let Some(notification) = receiver.try_recv() { + assert!( + !matches!(notification, Notification::Established { watch, .. } if watch == second.id()), + "a faulted watcher has no settled tier to report" + ); + } + + drop((first, second)); + drop(monitor); + dir.cleanup(); +} + +#[test] +fn stopping_the_only_route_after_a_volume_change_removes_its_resident_state() { + let dir = TempDir::new("monitor-volume-stop-cleanup"); + let monitor = Monitor::new().expect("create the monitor"); + let (session, receiver) = monitor.session(); + let watch = session + .subscribe( + dir.path(), + WatchOptions::new().on_volume_change(VolumeChangePolicy::Confirm), + ) + .expect("register the subscription"); + monitor.quiesce(); + + let current = DirectoryHandle::open(dir.path()) + .expect("open the watched directory") + .volume_identity() + .expect("read its volume identity"); + let previous = [0, u32::MAX] + .into_iter() + .map(|serial| VolumeIdentity::synthetic(serial, "TEST-FS", "TEST-VOLUME")) + .find(|candidate| *candidate != current) + .expect("one synthetic serial differs from the real volume"); + with_only_directory_watcher(&monitor, |watcher| { + watcher.simulate_volume_change_for_test(previous); + }); + + let mut asked = false; + while let Some(notification) = receiver.try_recv() { + if matches!(notification, Notification::VolumeChanged { watch: id, .. } if id == watch.id()) + { + asked = true; + } + } + assert!( + asked, + "the confirming route must receive the volume question" + ); + + session.answer_volume_change(watch.id(), VolumeChangeDecision::Stop); + monitor.quiesce(); + + assert!(!monitor.is_registered(watch.id())); + assert_eq!(monitor.directory_count(), 0); + + drop(watch); + drop(monitor); + dir.cleanup(); +} diff --git a/crates/windows-file-watcher/src/watcher.rs b/crates/windows-file-watcher/src/watcher.rs index 7c1e1596..319ae50a 100644 --- a/crates/windows-file-watcher/src/watcher.rs +++ b/crates/windows-file-watcher/src/watcher.rs @@ -1470,6 +1470,22 @@ impl DirectoryWatcher { lock(&self.inner.fault).as_ref().map(|state| state.detail) } + #[cfg(test)] + pub(crate) fn enter_fault_for_test(&self, detail: FaultDetail, operation: FaultOperation) { + self.inner.enter_fault(detail, operation); + } + + #[cfg(test)] + pub(crate) fn record_stop_for_test(&self, error: io::Error) { + self.inner.record_stop(error); + } + + #[cfg(test)] + pub(crate) fn simulate_volume_change_for_test(&self, previous: VolumeIdentity) { + *lock(&self.inner.volume_identity) = Some(previous); + self.inner.retry_reestablish(); + } + /// Which tier is currently servicing this directory (D-13/D-17). #[must_use] pub(crate) fn mode(&self) -> WatchMode { From 80bfaa3cfd37ff842403c063840bb8aec36bb44a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 23:39:30 -0400 Subject: [PATCH 186/361] fix(placement-probe): count every online processor in the fingerprint Fingerprint::processors is documented as the logical-processor count but was summed over core-domain membership. That agreed with the documented meaning only while every processor was guaranteed to sit in a core domain, and the previous commit stopped guaranteeing it: places_from_topology now explicitly accepts a topology naming no cores and places every online processor. The banner consequently read `0p/0c` for a machine the measurement was about to use four processors on -- a defect this branch created rather than inherited. The count is now read off topology.processors with the same `online` filter the placement applies, so the summary counts exactly what the measurement will use. `cores` deliberately still counts core domains: zero there is the honest report that the topology named none, not an invented value. The two fields answer different questions and only one of them had a source that could disagree with the measurement. Both new tests assert equality against places_from_topology's own output rather than a literal, so the summary and the measurement cannot drift apart again. Verified red before the fix: both reported `left: 0`, exactly the `0p` in the report. Sweeping the fix turned up two consequences worth recording. cache_domain_sizes fills itself with the processor count when no cache level partitions the host, so the bare-machine render silently improved from `L-[0]` to `L-[4]`. numa_node_sizes did not, and now does not sum to processors in that one case: it reports the nodes the topology named, and a bare topology names none, while every placement still reports the documented node-0 default. Keeping that asymmetry is deliberate. The cache list can afford to fill itself because it renders `L-` for "no partitioning level", so `L-[16]` cannot be mistaken for a real single-domain level. The NUMA list has no such marker, so `numa[16]` would be indistinguishable from a host that genuinely reported one node of 16, and the more useful fact -- that the machine said nothing about NUMA -- would be lost. The behaviour is documented on the field and pinned by a test asserting the whole render, including the !!SYNTHETIC!! provenance marker. The stronger fix needs a marker, which is a serialized field and so a schema bump, tracked as PT-6.2 beside the existing PT-6.1. Completed items: SH-12.1, SH-12.2 Completed item: SH-12.1: The banner undercounted the machine it was about to measure Completed item: SH-12.2: Two consequences of that fix, found by sweeping it rather than reported Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 18 ++++ CHECKLIST-ship-topology-and-queues.md | 28 +++++++ .../src/fingerprint.rs | 37 ++++++++- .../src/fingerprint/tests.rs | 82 ++++++++++++++++++- 4 files changed, 163 insertions(+), 2 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 9567435e..0cffbe22 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -522,6 +522,24 @@ and numbered when that has happened. is what a collector needing equivalence should read. Raised by review 5073245942 on pull request #56. +- [ ] **PT-6.2** -- **Give the NUMA list an absence marker, so an unreported node set is not read as + one node.** [fingerprint.rs](crates/windows-placement-probe/src/fingerprint.rs) renders + `numa_node_sizes` as the nodes the topology *reported*, so a host naming no memory domains renders + `numa[]` while every processor is still counted and every placement still reports node `0` -- the + documented single-node default. The node list therefore does not sum to `processors` in that one + case. + The asymmetry with the cache list is deliberate, not an oversight: `cache_domain_sizes` fills itself + with the processor count when no level partitions the host, but it can afford to, because it renders + `L-` for "no partitioning level" and so `L-[16]` cannot be mistaken for a real single-domain level. + The NUMA list has no such marker, so `numa[16]` would be indistinguishable from a host that genuinely + reported one node of 16, and the more useful fact -- that the machine said nothing about NUMA -- + would be lost. + The behaviour is documented on the field and pinned by + `a_bare_topology_renders_its_processors_but_claims_no_numa_nodes`, so nothing is currently wrong; + this item is the stronger fix. A marker is a serialized-field change and therefore a schema bump, + so like PT-6.1 it is **deliberately gated on some other reason to bump the schema**. Found while + fixing the processor count raised by review on pull request #56. + **Not gated on the release, unlike the rest of this file.** The work is an extension of the affinity measurement, which today lives in [crates/windows-platform-probes](crates/windows-platform-probes) and moves wholesale under PT-2.1. Build it there now; it travels with everything else. diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 6f2f527d..a158aaa9 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -578,3 +578,31 @@ mutation wrapper. The conversion's three silent fallbacks are replaced by one ru `mod tests`, so the accidental filter matched all of them, and all fourteen recorded baselines report "0 filtered out". The defect was latent, and would have appeared on the first crate laid out differently. + +## M12: PR #56 seventh review round + +- [x] **SH-12.1** -- **The banner undercounted the machine it was about to measure.** + `Fingerprint::processors` is documented as the logical-processor count but was summed over + core-domain membership, which agreed with that meaning only while every processor was guaranteed to + sit in a core domain. SH-11.1 stopped guaranteeing it: `places_from_topology` now explicitly accepts + a topology naming no cores and places every online processor. The banner consequently read + `0p/0c` for a machine the measurement was about to use four processors on -- a defect this branch + created rather than inherited. + The count is now read off `topology.processors` with the same `online` filter the placement applies, + so the summary counts exactly what the measurement will use. `cores` deliberately still counts core + domains: zero there is the honest report that the topology named none. + Sabotage-verified. Two new tests pin both directions -- an uncored processor is still counted, an + offline slot is not -- and each asserts equality against `places_from_topology`'s own output rather + than a literal, so the two cannot drift apart again. + +- [x] **SH-12.2** -- **Two consequences of that fix, found by sweeping it rather than reported.** + `cache_domain_sizes` fills itself with the processor count when no cache level partitions the host, + so the bare-machine render silently improved from `L-[0]` to `L-[4]`. + `numa_node_sizes` did not, and now does not sum to `processors` in that one case: it reports the + nodes the topology *named*, and a bare topology names none, while every placement still reports the + documented node-`0` default. Keeping it that way is deliberate -- the cache list can afford to fill + itself because `L-` marks the absence, and `numa[4]` would be indistinguishable from a host that + genuinely reported one node of four. The behaviour is now documented on the field and pinned by a + test that asserts the whole render, including the `!!SYNTHETIC!!` provenance marker. The stronger + fix needs a marker, which is a serialized field and so a schema bump, tracked as + [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) `PT-6.2`. diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index 0517b837..c4a65ee1 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -277,6 +277,23 @@ pub struct Fingerprint { /// `(efficiency class, processor count)`, ascending by class. pub efficiency_classes: Vec<(u8, usize)>, /// Processors per NUMA node, ascending. + /// + /// The nodes the topology **reported**, so this does not necessarily sum to + /// [`processors`](Self::processors): a topology naming no memory domains + /// leaves this empty, while every processor is still counted and every + /// placement still reports node `0` -- the documented single-node default + /// for exactly that case. + /// + /// Empty is deliberately not rendered as one node covering the machine, even + /// though [`cache_domain_sizes`](Self::cache_domain_sizes) does exactly that + /// when no level partitions the host. The difference is that the cache field + /// has somewhere to put the absence -- it renders `L-` for "no partitioning + /// level", so `L-[16]` cannot be mistaken for a real single-domain level. + /// The NUMA list has no such marker, so `numa[16]` would be + /// indistinguishable from a host that genuinely reported one node of 16, and + /// the more useful fact -- that the machine said nothing about NUMA -- would + /// be lost. Adding that marker is a serialized-field change and so a schema + /// bump; it is tracked as `PT-6.2` rather than done here. pub numa_node_sizes: Vec, /// Where the topology behind this fingerprint came from. /// @@ -328,7 +345,25 @@ impl Fingerprint { #[must_use] pub fn from_topology(topology: &Topology) -> Self { let cores: Vec<_> = topology.cores().collect(); - let processors: usize = cores.iter().map(|core| core.processors.len()).sum(); + // Read off the processor list, not off core-domain membership, and with + // the same `online` filter `places_from_topology` applies -- so the + // banner counts exactly what the measurement will use. + // + // Summing core membership agreed with this only while every processor + // was guaranteed to sit in a core domain, which this module stopped + // guaranteeing when it began accepting a topology that names no cores. + // The banner then read `0p` for a machine about to be measured on four + // processors. + // + // `cores` below is left counting core domains: zero there is the honest + // report that the topology named none, not an invented value. The two + // fields answer different questions and only one of them had a source + // that could disagree with the measurement. + let processors = topology + .processors + .iter() + .filter(|processor| processor.online) + .count(); let smt = cores.iter().any(|core| core.processors.len() > 1); let mut efficiency_classes: Vec<(u8, usize)> = Vec::new(); diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index e8764d50..51a95d66 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -597,7 +597,7 @@ mod multi_group_conversion { Domain, DomainKind, Processor, ProcessorId, ProcessorSet, Topology, }; - use crate::fingerprint::{MissingPlacement, places_from_topology}; + use crate::fingerprint::{Fingerprint, MissingPlacement, places_from_topology}; /// One processor per core, four cores per group, two groups -- with the /// numbers overlapping, which is how Windows really presents it. @@ -993,4 +993,84 @@ mod multi_group_conversion { assert!(places.iter().all(|place| place.cache_domain.is_none())); } + + #[test] + fn the_processor_count_is_every_online_processor_not_every_cored_one() { + // The defect, and it is one this crate's own change created. The count + // was summed over core-domain membership, which agreed with the field's + // documented meaning only while every processor was guaranteed to sit in + // a core domain. `places_from_topology` now explicitly accepts a + // topology that names no cores and places every online processor -- so + // the banner said `0p/0c` for a machine the measurement was about to use + // four processors on. + // + // The two must be read off the same thing. `cores` staying zero is not + // the same bug: the topology genuinely named no cores, and reporting + // that is the honest answer rather than an invented one. + let topology = bare_processors(4); + let places = places_from_topology(&topology).expect("no core domain is a legal shape"); + let fingerprint = Fingerprint::from_topology(&topology); + + assert_eq!( + fingerprint.processors, 4, + "a processor no core mentions is still a processor" + ); + assert_eq!( + fingerprint.processors, + places.len(), + "the summary must count what the measurement will actually use" + ); + assert_eq!(fingerprint.cores, 0, "no core domain was reported"); + } + + #[test] + fn the_processor_count_excludes_offline_processors() { + // The same rule from the other side: `places_from_topology` filters on + // `online`, so counting every entry in `topology.processors` would swing + // the disagreement the other way and overstate the machine. + let mut topology = bare_processors(4); + topology.processors[2].online = false; + + let places = places_from_topology(&topology).expect("no core domain is a legal shape"); + let fingerprint = Fingerprint::from_topology(&topology); + + assert_eq!( + fingerprint.processors, 3, + "an offline slot is not a processor" + ); + assert_eq!(fingerprint.processors, places.len()); + } + #[test] + fn a_bare_topology_renders_its_processors_but_claims_no_numa_nodes() { + // Pins the whole bare-machine render, because fixing the processor count + // changed two things at once and both should be deliberate. + // + // `L-[4]` improved silently: the unpartitioned branch fills the cache + // list with the processor count, so it used to read `L-[0]`. + // + // `numa[]` is the deliberate asymmetry. Every placement for this + // topology reports node 0, so the node list no longer sums to the + // processor count -- but `L-` marks the cache absence and nothing marks + // a NUMA one, so rendering `numa[4]` would be indistinguishable from a + // host that really did report one node of four, and the more useful fact + // would be lost. See the field docs and PT-6.2. + let fingerprint = Fingerprint::from_topology(&bare_processors(4)); + + // The `!!SYNTHETIC!!` prefix is load-bearing rather than noise: a + // hand-built topology must not render a string a real host could also + // produce, so it is asserted here with everything else. + assert_eq!( + fingerprint.to_string(), + format!( + "!!SYNTHETIC!! {} 4p/0c smt- L-[4] ec[] numa[]", + std::env::consts::ARCH + ) + ); + assert!( + fingerprint.numa_node_sizes.iter().sum::() < fingerprint.processors, + "the node list is what the topology reported, not a partition of the \ + processors; this asymmetry is documented, so a future change that \ + removes it should fail here and be made on purpose" + ); + } } From f71f792e9dcf29e15201260793a12370cd698ad8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Tue, 1 Sep 2026 23:59:38 -0400 Subject: [PATCH 187/361] fix(placement-probe): stop a record splicing two machines, and route output through one sink Two findings against the placement probe, both landing in the same binary. The tool announced a shape read at one instant while core_affinity::measure discovered again at another, so a processor going offline -- or moving group or node -- between them produced a record whose `host` described one machine while every row was measured on a different one. Nothing in the file said so, and the host is precisely what a reader interprets row sets through. measure now reports the shape it actually ran on, as Observation::host. That is the fix that keeps the anti-synthetic boundary intact: the measurement still discovers for itself, and no seam accepts a fabricated shape from outside -- which is what measure's own documentation is protecting against. SubmissionRecord::new refuses when the announced and measured hosts differ, so the splice is unrepresentable rather than merely avoided at the one current call site. The tool checks first anyway and reports the disagreement in terms a runner can act on. Refusing rather than silently recording the measured shape, because the notice is what the runner consented to, and the run is cheap to repeat where a wrong record in a corpus is not. Separately, the binary wrote from 54 independent print sites. The repository's one-output-sink rule requires an output abstraction at the FIRST output site so the storage target and the formatting stay separable from the call sites that compose content. This binary had none, which is why its collection notice -- a disclosure a runner reads before agreeing to publish facts about their machine -- could only be exercised by running the process and capturing stdout. A Sink trait now carries the two streams the tool genuinely has (report and problem, kept apart so an error cannot end up in the text a runner pastes into a discussion thread). print_collection_notice and print_plan became render_* functions returning a String, matching the idiom the record report already used, and main is the only place that names the real streams. Verified as a pure refactor by building the binary before and after and comparing: --preview and --help are byte-identical, and --version differs only by the build identity correctly reporting the working tree as DIRTY. Eight new tests cover what was previously unreachable: that the notice shows the CPU model rather than describing it, that a withheld model reads differently from one the host would not report, that the topology row shows the fingerprint value, that the suppression hint appears only when it would do something, that the "does NOT collect" promises are still made, that the plan's total is the product it claims, and that the two streams cannot satisfy each other's assertions. Completed items: SH-13.1, SH-13.2 Completed item: SH-13.1: A record could splice two machines together Completed item: SH-13.2: The tool wrote from 54 independent print sites Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 36 +++ .../src/bin/placement_probe/main.rs | 236 +++++++++++++----- .../src/bin/placement_probe/sink.rs | 114 +++++++++ .../src/bin/placement_probe/tests.rs | 157 ++++++++++++ .../src/core_affinity.rs | 33 ++- .../src/core_affinity/tests.rs | 6 + crates/windows-placement-probe/src/record.rs | 44 +++- .../src/record/tests.rs | 66 +++++ 8 files changed, 629 insertions(+), 63 deletions(-) create mode 100644 crates/windows-placement-probe/src/bin/placement_probe/sink.rs diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index a158aaa9..a42cc69e 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -606,3 +606,39 @@ mutation wrapper. The conversion's three silent fallbacks are replaced by one ru test that asserts the whole render, including the `!!SYNTHETIC!!` provenance marker. The stronger fix needs a marker, which is a serialized field and so a schema bump, tracked as [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) `PT-6.2`. + +## M13: PR #56 eighth review round + +- [x] **SH-13.1** -- **A record could splice two machines together.** The tool announced a shape read + at one instant while `core_affinity::measure` discovered again at another, so a processor going + offline -- or moving group or node -- between them produced a record whose `host` described one + machine while every row was measured on a different one. Nothing in the file said so, and the host + is precisely what a reader interprets row sets *through*. + `measure` now reports the shape it actually ran on, as `Observation::host`, which is the fix that + keeps the anti-synthetic boundary intact: the measurement still discovers for itself and no seam + accepts a fabricated shape from outside. `SubmissionRecord::new` refuses when the announced and + measured hosts differ, so the splice is unrepresentable rather than merely avoided at the one + current call site; the tool checks first anyway and reports the disagreement in terms a runner can + act on. Refusing rather than silently recording the measured shape, because the notice is what the + runner consented to. + +- [x] **SH-13.2** -- **The tool wrote from 54 independent print sites.** The repository's + one-output-sink rule requires an output abstraction at the *first* output site so the storage + target and the formatting stay separable from the call sites that compose content. This binary had + none, which is why its collection notice -- a disclosure a runner reads before agreeing to publish + facts about their machine -- could only be exercised by running the process and capturing stdout. + A `Sink` trait now carries the two streams the tool genuinely has, `print_collection_notice` and + `print_plan` became `render_*` functions returning a `String` (matching the idiom the record report + already used), and `main` is the only place that names the real streams. + Verified as a pure refactor by comparing the built binary's output before and after: `--preview` + and `--help` are **byte-identical**, and `--version` differs only by the build identity correctly + reporting the working tree as `DIRTY`. Eight new tests cover what was previously unreachable, + including that the notice shows the model rather than describing it, that a withheld model reads + differently from one the host would not report, and that the two streams cannot satisfy each + other's assertions. + +- [ ] **SH-13.3** -- **`probe-core-affinity` writes from 67 independent print sites.** Same rule, same + fix, in [core_affinity.rs](crates/windows-platform-probes/src/bin/core_affinity.rs). + +- [ ] **SH-13.4** -- **`probe-doorbell-cost` writes from 27 independent print sites.** Same rule, same + fix, in [doorbell_cost.rs](crates/windows-platform-probes/src/bin/doorbell_cost.rs). diff --git a/crates/windows-placement-probe/src/bin/placement_probe/main.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs index 7db7f7e7..5d391250 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/main.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -5,11 +5,15 @@ //! outputs" is friction for someone doing a favour, and invites partial //! submissions that cannot be compared with each other. +use std::fmt::Write as _; use std::process::ExitCode; +mod sink; #[cfg(test)] mod tests; +use sink::{Sink, Stdio, emit}; + use windows_placement_probe::build_identity::BuildIdentity; use windows_placement_probe::core_affinity::{self, RunPlan}; use windows_placement_probe::fingerprint::{Fingerprint, places_from_topology}; @@ -38,17 +42,34 @@ struct Options { } fn main() -> ExitCode { + run(&mut Stdio) +} + +/// The whole tool, against any [`Sink`]. +/// +/// Separate from [`main`] so the streams are a parameter rather than a global. +/// `main` is the only place that names [`Stdio`]. +fn run(out: &mut impl Sink) -> ExitCode { let options = match parse_arguments() { Ok(options) => options, Err(message) => { - eprintln!("{message}"); + out.problem(&message); return ExitCode::FAILURE; } }; if options.help { - // stdout and success: help was asked for and was given. - println!("{}", help()); + // The report stream and success: help was asked for and was given. + // + // The trailing blank line is deliberate here and was verified against + // the previous build: `help()` ends with a newline and the `println!` + // that used to print it added a second, so the output ended with a + // blank line. `emit` drops a trailing newline rather than yielding an + // empty line, so keeping the spacing takes an explicit line. Whether + // that blank should exist at all is a question about the help text, not + // about where output goes -- and this change is only about the latter. + emit(out, &help()); + out.line(""); return ExitCode::SUCCESS; } @@ -57,7 +78,7 @@ fn main() -> ExitCode { // CI asserts on this line that a released artifact reports itself // official, and a runner can check the same thing before trusting a // download -- both need the commit and the source, not just "0.1.0". - println!("{}", BuildIdentity::current()); + out.line(&BuildIdentity::current().to_string()); return ExitCode::SUCCESS; } @@ -71,7 +92,7 @@ fn main() -> ExitCode { let topology = match Topology::discover() { Ok(topology) => topology, Err(error) => { - eprintln!("could not read this machine's topology: {error}"); + out.problem(&format!("could not read this machine's topology: {error}")); return ExitCode::FAILURE; } }; @@ -79,7 +100,7 @@ fn main() -> ExitCode { let places = match places_from_topology(&topology) { Ok(places) => places, Err(error) => { - eprintln!("could not read this machine's topology: {error}"); + out.problem(&format!("could not read this machine's topology: {error}")); return ExitCode::FAILURE; } }; @@ -95,43 +116,82 @@ fn main() -> ExitCode { // whose *numbers* are valid on this host while its node labels are not, so // every pin would succeed and real timings would be filed under fabricated // labels. Its rows carry their own places, so each row says what it measured. + // + // That leaves two readings of one machine taken at different instants, which + // is a real hazard rather than a theoretical one -- so the measurement + // reports the shape it saw and the two are compared below before anything is + // recorded. The seam stays closed; the skew it left behind is checked. let host = Fingerprint::from_topology(&topology); - print_collection_notice(&machine, &host, options.suppress_model); - print_plan(&plan); + emit( + out, + &render_collection_notice(&machine, &host, options.suppress_model), + ); + emit(out, &render_plan(&plan)); if options.preview { - println!(); - println!("Preview only -- nothing was measured and no file was written."); - println!("Run again without --preview to take the measurement."); + out.line(""); + out.line("Preview only -- nothing was measured and no file was written."); + out.line("Run again without --preview to take the measurement."); return ExitCode::SUCCESS; } - println!(); - println!("Measuring. This machine will be busy until it finishes."); - println!(); + out.line(""); + out.line("Measuring. This machine will be busy until it finishes."); + out.line(""); let observation = match core_affinity::measure() { Ok(observation) => observation, Err(error) => { - eprintln!("the measurement could not run: {error}"); + out.problem(&format!("the measurement could not run: {error}")); + return ExitCode::FAILURE; + } + }; + // The announced shape and the measured one must be the same shape, or the + // record is a splice of two machines: `host` would come from the reading + // above while every row came from the one `measure` took, with nothing in + // the file saying so and a reader interpreting the rows through the wrong + // machine. A processor going offline, or moving group or node, between the + // notice and the end of the measurement is enough to produce it. + // + // Refusing rather than quietly recording the measured shape, because the + // notice is what the runner read and consented to. A record that silently + // describes a different machine than the one they were shown is the outcome + // this tool's whole disclosure story exists to prevent -- and the run is + // cheap to repeat, whereas a wrong record in a corpus is not. + if observation.host != host { + out.problem( + "this machine changed while it was being measured, so the result was discarded.", + ); + out.problem(&format!(" announced: {host}")); + out.problem(&format!(" measured: {}", observation.host)); + out.problem("nothing was written. Run again on an otherwise idle machine."); + return ExitCode::FAILURE; + } + + // Cannot fail: the equality was just checked above. Handled rather than + // unwrapped anyway, because the constructor owns that invariant and a panic + // here would discard a measurement the runner has already paid for. + let record = match SubmissionRecord::new(&observation, host, machine) { + Ok(record) => record, + Err(error) => { + out.problem(&format!("the record could not be assembled: {error}")); return ExitCode::FAILURE; } }; - let record = SubmissionRecord::new(&observation, host, machine); let text = match submission::render_submission(&record) { Ok(text) => text, Err(error) => { - eprintln!("the record could not be written out: {error}"); + out.problem(&format!("the record could not be written out: {error}")); return ExitCode::FAILURE; } }; if !options.no_file { - write_backup(&record); + write_backup(out, &record); } - print!("{text}"); + emit(out, &text); ExitCode::SUCCESS } @@ -140,15 +200,33 @@ fn main() -> ExitCode { /// A person deciding whether to do this a favour should be able to decide with /// the real values in front of them rather than a promise about them, which is /// why the preview exists and why this prints what was actually read. -fn print_collection_notice(machine: &MachineDescription, host: &Fingerprint, suppressed: bool) { - println!("== windows-placement-probe =="); - println!(); - println!("This measures what thread placement costs on your machine, and prints"); - println!("a result you can paste into a discussion thread. It makes no network"); - println!("connections; sending the result is your decision and your action."); - println!(); - println!("What it collects about this machine, as read just now:"); - println!( +fn render_collection_notice( + machine: &MachineDescription, + host: &Fingerprint, + suppressed: bool, +) -> String { + let mut out = String::new(); + let _ = writeln!(out, "== windows-placement-probe =="); + let _ = writeln!(out); + let _ = writeln!( + out, + "This measures what thread placement costs on your machine, and prints" + ); + let _ = writeln!( + out, + "a result you can paste into a discussion thread. It makes no network" + ); + let _ = writeln!( + out, + "connections; sending the result is your decision and your action." + ); + let _ = writeln!(out); + let _ = writeln!( + out, + "What it collects about this machine, as read just now:" + ); + let _ = writeln!( + out, " cpu model {}", match (&machine.cpu_model, suppressed) { (Some(model), _) => model.as_str(), @@ -156,11 +234,13 @@ fn print_collection_notice(machine: &MachineDescription, host: &Fingerprint, sup (None, false) => "(this host would not say)", } ); - println!( + let _ = writeln!( + out, " os build {}", machine.os_build.as_deref().unwrap_or("(unknown)") ); - println!( + let _ = writeln!( + out, " virtualisation {}{}", machine.virtualisation, match &machine.virtualisation_name { @@ -174,48 +254,80 @@ fn print_collection_notice(machine: &MachineDescription, host: &Fingerprint, sup // not the model is named. A runner asked to judge that could not see the // thing they were being asked to judge, which is the one job the preview // has. - println!(" topology {host}"); - println!(" (processor, core, cache and NUMA layout)"); - println!(" timings how long a handoff takes at each placement"); - println!(); - println!("What it does NOT collect: your host name, your user name, file paths,"); - println!("environment variables, serial numbers, or anything about installed"); - println!("software. Read the printed record before sending it -- if you are not"); - println!("happy with something in it, do not send it."); + let _ = writeln!(out, " topology {host}"); + let _ = writeln!( + out, + " (processor, core, cache and NUMA layout)" + ); + let _ = writeln!( + out, + " timings how long a handoff takes at each placement" + ); + let _ = writeln!(out); + let _ = writeln!( + out, + "What it does NOT collect: your host name, your user name, file paths," + ); + let _ = writeln!( + out, + "environment variables, serial numbers, or anything about installed" + ); + let _ = writeln!( + out, + "software. Read the printed record before sending it -- if you are not" + ); + let _ = writeln!(out, "happy with something in it, do not send it."); if !suppressed { - println!(); - println!("Pass --no-cpu-model to withhold the model. Note that it does not make"); - println!("confidential hardware safe to submit: the topology describes the part"); - println!("whether or not it is named."); + let _ = writeln!(out); + let _ = writeln!( + out, + "Pass --no-cpu-model to withhold the model. Note that it does not make" + ); + let _ = writeln!( + out, + "confidential hardware safe to submit: the topology describes the part" + ); + let _ = writeln!(out, "whether or not it is named."); } + out } -fn print_plan(plan: &RunPlan) { - println!(); - println!("-- what this run will do --"); - println!(" {:>3} placement(s) on this machine", plan.placements); - println!(" {:>3} NUMA node pair(s)", plan.node_hops); - println!(" {:>3} efficiency class comparison(s)", plan.classes); - println!( +fn render_plan(plan: &RunPlan) -> String { + let mut out = String::new(); + let _ = writeln!(out); + let _ = writeln!(out, "-- what this run will do --"); + let _ = writeln!(out, " {:>3} placement(s) on this machine", plan.placements); + let _ = writeln!(out, " {:>3} NUMA node pair(s)", plan.node_hops); + let _ = writeln!(out, " {:>3} efficiency class comparison(s)", plan.classes); + let _ = writeln!( + out, " {:>3} timed handoffs in total ({} strategies x {} repetitions)", plan.timed_runs(), plan.strategies, plan.repetitions ); - println!(); - println!( + let _ = writeln!(out); + let _ = writeln!( + out, " Should take under {:.0} seconds, and usually much less. That is an", plan.estimated_seconds().ceil().max(1.0) ); - println!(" upper bound taken from the slowest machine measured so far -- how long"); - println!(" a handoff takes is the thing being measured, so it cannot be exact."); + let _ = writeln!( + out, + " upper bound taken from the slowest machine measured so far -- how long" + ); + let _ = writeln!( + out, + " a handoff takes is the thing being measured, so it cannot be exact." + ); + out } /// Write the record beside the report, as a convenience rather than a step. /// /// A failure here is reported and does not fail the run: the submission is the /// text on screen, and losing the backup copy costs nothing that matters. -fn write_backup(record: &SubmissionRecord) { +fn write_backup(out: &mut impl Sink, record: &SubmissionRecord) { // The same layout as the printed record, so the JSON a runner attaches and // the JSON embedded in the text they paste are byte-identical. // @@ -227,14 +339,24 @@ fn write_backup(record: &SubmissionRecord) { let json = match windows_placement_probe::paste_json::to_paste_json(record) { Ok(json) => json, Err(error) => { - println!("(could not serialize the record to a file: {error})"); + out.line(&format!( + "(could not serialize the record to a file: {error})" + )); return; } }; + // The report stream, not the problem stream, and deliberately so: the + // backup is a convenience, the submission is the text on screen, and a + // failure here belongs in the narrative the runner is reading rather than + // on a stream they may not see. match write_backup_to_new_file(&submission::file_name(record), &json) { - Ok(name) => println!("(a copy of the record was also written to {name})"), - Err(error) => println!("(could not write the backup: {error} -- paste the text below)"), + Ok(name) => out.line(&format!( + "(a copy of the record was also written to {name})" + )), + Err(error) => out.line(&format!( + "(could not write the backup: {error} -- paste the text below)" + )), } } diff --git a/crates/windows-placement-probe/src/bin/placement_probe/sink.rs b/crates/windows-placement-probe/src/bin/placement_probe/sink.rs new file mode 100644 index 00000000..46d54396 --- /dev/null +++ b/crates/windows-placement-probe/src/bin/placement_probe/sink.rs @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Mike Grier +//! The one place this tool writes. +//! +//! # Why an abstraction for something as simple as printing +//! +//! Every line this binary produces used to go straight to `println!` or +//! `eprintln!` from wherever it was composed, across roughly fifty sites. That +//! ties two unrelated concerns together: *what the text says* and *where it +//! goes*. Neither can then be exercised without the other, so the only way to +//! check the wording of the collection notice -- the paragraph a runner reads +//! before consenting to publish facts about their machine -- was to run the +//! process and capture its stdout. +//! +//! That matters more here than in most tools. The notice is a disclosure, its +//! exact wording is the thing under review, and this crate already renders its +//! *record* report to a `String` for the same reason. Output was the one part +//! that had not caught up. +//! +//! # What this is, and what it deliberately is not +//! +//! A sink, not a logging framework. [`Sink`] has two methods because this tool +//! writes to two streams and the distinction is real: the report goes to stdout +//! where a runner can pipe it, and problems go to stderr so a pipeline does not +//! swallow them. There are no levels, no filtering, and no formatting policy -- +//! the callers still own their text. +//! +//! Content is composed by `render_*` functions that append to a `&mut String` +//! and never touch a stream, matching the idiom the record report already uses. +//! A test calls those directly and asserts on the result; only `main` holds a +//! [`Stdio`], and [`Captured`] stands in for it where a test needs to observe +//! what a whole path emitted rather than what one renderer returned. + +/// Somewhere this tool's output can go. +pub trait Sink { + /// Emit one line of the report proper. + fn line(&mut self, text: &str); + + /// Emit one line describing something that went wrong. + /// + /// Separate from [`Sink::line`] because the two streams are separate: a + /// runner pipes the report somewhere, and a problem that travelled with it + /// would be pasted into a discussion thread instead of being seen. + fn problem(&mut self, text: &str); +} + +/// The real streams. +pub struct Stdio; + +impl Sink for Stdio { + fn line(&mut self, text: &str) { + println!("{text}"); + } + + fn problem(&mut self, text: &str) { + eprintln!("{text}"); + } +} + +/// A sink that keeps what it was given. +/// +/// The two streams are kept *separately*, because a test asserting that a +/// problem was reported must not be satisfied by the same words appearing in +/// the report -- which is the confusion the two streams exist to prevent. +/// +/// Test-only, and gated rather than merely unused in a release build: a second +/// [`Sink`] implementation that ships is a destination this tool can be pointed +/// at, and this one silently swallows everything. Compiling it out says plainly +/// that it is a stand-in for the streams during a test and not an alternative +/// to them. +#[cfg(test)] +#[derive(Debug, Default)] +pub struct Captured { + /// Lines written to the report stream, in order. + pub lines: Vec, + /// Lines written to the problem stream, in order. + pub problems: Vec, +} + +#[cfg(test)] +impl Sink for Captured { + fn line(&mut self, text: &str) { + self.lines.push(text.to_owned()); + } + + fn problem(&mut self, text: &str) { + self.problems.push(text.to_owned()); + } +} + +#[cfg(test)] +impl Captured { + /// The report stream as one string, as a reader would see it. + #[must_use] + pub fn report(&self) -> String { + self.lines.join("\n") + } +} + +/// Write a rendered block to `sink`, one line at a time. +/// +/// The `render_*` functions produce a whole block with embedded newlines and a +/// [`Sink`] speaks in lines, so this is the join between them. Splitting rather +/// than passing the block through keeps [`Captured`] line-addressable, which is +/// what lets a test say "the third line is the topology" instead of matching a +/// substring against the whole document. +/// +/// A trailing newline on `block` does not produce an extra empty line, because +/// `str::lines` does not yield one -- so a renderer may end its block either way +/// without changing what a reader sees. +pub fn emit(sink: &mut impl Sink, block: &str) { + for line in block.lines() { + sink.line(line); + } +} diff --git a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs index 09f8921e..45d32d7c 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs @@ -293,3 +293,160 @@ fn a_stale_temporary_from_a_recycled_pid_does_not_fail_the_backup() { fix, overwriting it would be a second bug" ); } + +// --------------------------------------------------------------------------- +// Output. +// +// These are the tests the sink exists to make possible. Every one of them was +// unwritable while the text went straight to `println!` from the site that +// composed it: the only way to see this output was to run the process and +// capture a stream, which is a test of the operating system rather than of the +// wording. +// +// The wording is the point. The notice is a disclosure -- it is what a runner +// reads before deciding to publish facts about their machine -- so the promises +// it makes are exactly the thing worth pinning. +// --------------------------------------------------------------------------- + +use super::sink::{Captured, Sink, emit}; +use super::{render_collection_notice, render_plan}; +use windows_placement_probe::fingerprint::Fingerprint; +use windows_placement_probe::machine::MachineDescription; + +/// A description with every field known, so a test can tell "withheld" from +/// "this host would not say" -- which the notice renders differently and which +/// a real machine may not offer both of. +fn described() -> MachineDescription { + let mut machine = MachineDescription::read(false); + machine.cpu_model = Some("Test CPU 9000".to_owned()); + machine.os_build = Some("10.0.99999".to_owned()); + machine +} + +fn host() -> Fingerprint { + Fingerprint::from_topology(&windows_topology_sys::Topology::default()) +} + +#[test] +fn the_notice_names_the_model_it_is_about_to_publish() { + // The disclosure's central promise: what it says it collects is what it + // collects. A runner judging the model has to be shown the model. + let notice = render_collection_notice(&described(), &host(), false); + + assert!( + notice.contains("Test CPU 9000"), + "the notice must show the value, not the category: {notice}" + ); +} + +#[test] +fn suppressing_the_model_says_so_rather_than_going_quiet() { + // A blank where a value was promised reads as "this host would not say", + // which is a different claim from "you asked me not to". Both are honest + // answers and the runner is owed the right one. + let mut machine = described(); + machine.cpu_model = None; + + let withheld = render_collection_notice(&machine, &host(), true); + let unknown = render_collection_notice(&machine, &host(), false); + + assert!(withheld.contains("(withheld: --no-cpu-model)")); + assert!(unknown.contains("(this host would not say)")); + assert_ne!( + withheld, unknown, + "the two absences must not render identically" + ); +} + +#[test] +fn the_notice_shows_the_topology_value_not_a_description_of_it() { + // A correction that is easy to undo. Every other row shows what was read; + // this one once named a subject instead, while the paragraph below it warns + // that the topology identifies the hardware whether or not the model is + // named. A runner asked to judge that could not see the thing being judged. + let host = host(); + let notice = render_collection_notice(&described(), &host, false); + + assert!( + notice.contains(&host.to_string()), + "the fingerprint itself must appear: {notice}" + ); +} + +#[test] +fn the_suppression_hint_is_offered_only_when_it_would_do_something() { + // Advising --no-cpu-model to somebody who already passed it is noise that + // reads as though the flag did not take effect. + assert!( + render_collection_notice(&described(), &host(), false) + .contains("--no-cpu-model to withhold") + ); + assert!( + !render_collection_notice(&described(), &host(), true) + .contains("--no-cpu-model to withhold") + ); +} + +#[test] +fn the_notice_keeps_promising_what_it_does_not_collect() { + // The half of the disclosure a reader is most likely to be reassured by, + // and the half most likely to be quietly dropped in an edit. + let notice = render_collection_notice(&described(), &host(), false); + + for promise in [ + "host name", + "user name", + "file paths", + "environment variables", + ] { + assert!( + notice.contains(promise), + "the notice must keep naming {promise} among what it does not collect" + ); + } +} + +#[test] +fn the_plan_totals_agree_with_the_multiplication_it_shows() { + // The plan is a consent document too: a runner decides to spend the machine + // on the strength of these counts, so the total must be the product it + // claims rather than an independently maintained number. + let plan = windows_placement_probe::core_affinity::RunPlan { + placements: 4, + node_hops: 8, + memory_placements_per_hop: 2, + classes: 2, + strategies: 2, + repetitions: 3, + }; + + let rendered = render_plan(&plan); + + assert!(rendered.contains(&format!("{:>3} timed handoffs", plan.timed_runs()))); + assert!(rendered.contains("2 strategies x 3 repetitions")); +} + +#[test] +fn a_captured_sink_keeps_the_two_streams_apart() { + // The property the whole abstraction rests on. If a problem could satisfy an + // assertion about the report, every test above would be checking the wrong + // stream and would keep passing while the tool wrote its errors into the + // text a runner pastes into a discussion thread. + let mut captured = Captured::default(); + captured.line("report"); + captured.problem("problem"); + + assert_eq!(captured.report(), "report"); + assert_eq!(captured.problems, vec!["problem".to_owned()]); +} + +#[test] +fn emitting_a_block_gives_the_sink_one_line_at_a_time() { + // What makes a captured report addressable by line rather than by substring + // search, and what keeps a renderer free to end its block with a newline or + // without one. + let mut captured = Captured::default(); + emit(&mut captured, "one\ntwo\n"); + + assert_eq!(captured.lines, vec!["one".to_owned(), "two".to_owned()]); +} diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index c8e7ae76..5cda73fd 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -77,8 +77,11 @@ //! is the same reason the two probes this one extends are absent from CI. use std::collections::BTreeMap; +use std::io::ErrorKind; -use crate::fingerprint::{ProcessorPlace, Slice, discover_places}; +use windows_topology_sys::Topology; + +use crate::fingerprint::{Fingerprint, ProcessorPlace, Slice, places_from_topology}; use crate::peer_index_cache::{ITEMS, Strategy, time_model_on, time_model_placed}; /// Repetitions per placement; the median is reported. @@ -361,6 +364,23 @@ pub struct Measurement { /// Everything one invocation measured. #[derive(Debug, Clone)] pub struct Observation { + /// The machine's shape *as this measurement read it*. + /// + /// # Why the measurement reports its own host rather than being told one + /// + /// This function discovers the topology itself, deliberately (see the note + /// on [`measure`] against adding an injection seam). A caller that announced + /// a plan therefore holds a reading taken at a different instant, and the + /// two can disagree: a processor going offline, or moving group or node, + /// between them would leave a record whose `host` describes one machine + /// while every row was measured on another. Nothing in the record would say + /// so, and a reader interpreting row sets through that host would be + /// interpreting them through the wrong machine. + /// + /// Reporting the shape this run actually saw lets the caller compare the two + /// and refuse, without a seam that would let a *fabricated* shape in -- the + /// exact trade [`measure`]'s own documentation is protecting. + pub host: Fingerprint, /// Every logical processor, as discovered. pub processors: Vec, /// One within-class, within-cache pair per efficiency class, per strategy. @@ -603,7 +623,15 @@ pub fn memory_placements(producer: ProcessorPlace, consumer: ProcessorPlace) -> /// /// Returns whatever [`discover_places`] failed with. pub fn measure() -> std::io::Result { - let processors = discover_places()?; + // One discovery, two derivations, so the shape reported alongside the rows + // is the shape the rows were measured on. Calling `discover_places()` and + // then reading the topology again would reintroduce, inside this function, + // exactly the skew `Observation::host` exists to let the caller detect. + let topology = Topology::discover()?; + let processors = places_from_topology(&topology).map_err(|unplaceable| { + std::io::Error::new(ErrorKind::InvalidData, unplaceable.to_string()) + })?; + let host = Fingerprint::from_topology(&topology); assert_group_support(&processors); let pairs = representative_pairs(&processors); let mut measurements = Vec::new(); @@ -706,6 +734,7 @@ pub fn measure() -> std::io::Result { } Ok(Observation { + host, processors, by_class, measurements, diff --git a/crates/windows-placement-probe/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs index db91d5c3..076446c5 100644 --- a/crates/windows-placement-probe/src/core_affinity/tests.rs +++ b/crates/windows-placement-probe/src/core_affinity/tests.rs @@ -8,6 +8,8 @@ //! mislabel a pair, because every conclusion it prints is keyed on that label. use super::{Placement, RunPlan, classify, memory_placements, node_pairs, representative_pairs}; +use windows_topology_sys::Topology; + use crate::fingerprint::ProcessorPlace; /// A processor on its own physical core, which is the non-SMT case. @@ -1092,6 +1094,10 @@ fn hop_row( /// An observation carrying only the given node-pair rows. fn observation_of(rows: Vec) -> super::Observation { super::Observation { + // These tests exercise row lookup, which never consults the host. A + // bare topology is the smallest shape that is a real conversion rather + // than a hand-built `Fingerprint` literal. + host: crate::fingerprint::Fingerprint::from_topology(&Topology::default()), processors: Vec::new(), by_class: Vec::new(), measurements: Vec::new(), diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index f9fdf1e2..e5c0ba45 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -243,8 +243,44 @@ impl From<&Measurement> for MeasurementRecord { impl SubmissionRecord { /// Assemble a record from a completed run. - #[must_use] - pub fn new(observation: &Observation, host: Fingerprint, machine: MachineDescription) -> Self { + /// + /// `host` is the shape the runner was *shown* before consenting. It must + /// equal [`Observation::host`], the shape the measurement actually ran on. + /// + /// # Errors + /// + /// [`ErrorKind::InvalidData`](std::io::ErrorKind::InvalidData) if those two + /// disagree, which means the machine changed between the announcement and + /// the end of the measurement. + /// + /// # Why this is a refusal here rather than a check at the call site + /// + /// A record built from mismatched halves is a *splice*: its `host` describes + /// one machine while every row was measured on another, and nothing in the + /// file says so. A reader interpreting row sets through that host -- which is + /// what the host is for -- interprets them through the wrong machine. + /// + /// The tool does check before calling, and reports the disagreement far + /// better than an error type can. But a check at one call site is only as + /// durable as the next call site's author remembering it, and this is the + /// constructor every such author will reach for. Refusing here makes the + /// splice unrepresentable rather than merely currently-avoided. + pub fn new( + observation: &Observation, + host: Fingerprint, + machine: MachineDescription, + ) -> std::io::Result { + if host != observation.host { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "the announced host and the measured host differ, so this record would \ + describe two machines: announced {host}, measured {}", + observation.host + ), + )); + } + // One reading, split into the parts each consumer needs, so the record // and the file named after it can never describe different instants. let since_epoch = SystemTime::now() @@ -252,7 +288,7 @@ impl SubmissionRecord { .unwrap_or_default(); let now = since_epoch.as_secs(); - Self { + Ok(Self { schema_version: SCHEMA_VERSION, recorded_at: iso8601_utc(now), recorded_at_epoch_seconds: now, @@ -264,7 +300,7 @@ impl SubmissionRecord { placements: observation.measurements.iter().map(Into::into).collect(), node_hops: observation.by_node_pair.iter().map(Into::into).collect(), by_class: observation.by_class.iter().map(Into::into).collect(), - } + }) } /// Whether every part of this record is trustworthy. diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index d8c0ded4..b4fd9dd3 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -285,3 +285,69 @@ fn the_civil_conversion_round_trips_across_a_long_span() { previous = Some((year, month, dom)); } } + +/// An observation carrying nothing but the host it claims to have measured on. +/// +/// The rows are empty because the splice these tests are about is between the +/// two *hosts*; a row would only make the fixture longer without making the +/// question sharper. +fn observation_on(host: Fingerprint) -> crate::core_affinity::Observation { + crate::core_affinity::Observation { + host, + processors: Vec::new(), + by_class: Vec::new(), + measurements: Vec::new(), + by_node_pair: Vec::new(), + } +} + +/// The shape a four-processor bare topology produces, as a real conversion. +fn measured_host() -> Fingerprint { + Fingerprint::from_topology(&windows_topology_sys::Topology::default()) +} + +#[test] +fn a_record_cannot_splice_an_announced_host_onto_another_machines_rows() { + // The defect. The tool announces a shape read at one instant and the + // measurement discovers again at another, so a processor going offline -- + // or moving group or node -- between them yields a record whose `host` + // describes one machine while every row was measured on a different one. + // Nothing in the file says so, and the host is precisely what a reader + // interprets row sets *through*, so the rows get read against the wrong + // machine. + // A processor arriving, rather than leaving, purely because the fixture's + // bare topology has none to remove. The direction does not matter: the + // record is a splice either way. + let announced = measured_host(); + let mut measured = announced.clone(); + measured.processors += 1; + + let error = SubmissionRecord::new( + &observation_on(measured), + announced, + MachineDescription::read(true), + ) + .expect_err("a record spanning two machines must not be assembled"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!( + error.to_string().contains("two machines"), + "the message must say what is wrong rather than only that something is: {error}" + ); +} + +#[test] +fn a_record_assembles_when_the_announced_and_measured_hosts_agree() { + // The other half, so the refusal above is known to be discriminating rather + // than a constructor that now always fails. + let host = measured_host(); + + let record = SubmissionRecord::new( + &observation_on(host.clone()), + host.clone(), + MachineDescription::read(true), + ) + .expect("identical hosts are the ordinary case"); + + assert_eq!(record.host, host); +} From 4cfd7dd65ca1722f23b722d3ffe931afc587b4f6 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 00:07:06 -0400 Subject: [PATCH 188/361] refactor(platform-probes): route the two new probes through one output sink The repository's one-output-sink rule requires an output abstraction at a tool's FIRST output site, so the storage target and the formatting stay separable from the call sites that compose content. probe-core-affinity wrote from 67 independent print sites and probe-doorbell-cost from 27, so neither probe's findings could be observed except by running the process and capturing a stream. That is a real loss for a probe specifically. These binaries exist so a claim in a design note can be re-run rather than re-argued, which makes their output evidence -- and evidence that can only be eyeballed is weaker than evidence a test can read. A Report sink now lives in the crate's lib, shared by both. One stream, not two: unlike the placement probe these have only ever written to stdout, and inventing a diagnostic stream they do not use would add a distinction the tools do not make. Each main is now three lines -- measure, render, emit -- and is the only place that names the real stream. One find the mechanical part would have missed: core_affinity's render called fingerprint::print_banner(), which writes to stdout itself. Left alone it would have put the identifying line on the terminal while leaving it out of the returned report, so a captured report would be missing the one line saying which machine produced it -- and the !!SYNTHETIC!! taint marker with it. banner_line() already existed for exactly this and is now used. Verified as a pure refactor by running both probes before and after and comparing with numerals masked, since their output is timing-dependent and byte equality is not available: 38 lines and 50 lines respectively, structurally identical both times. The other twelve probes still print directly (307 sites). They predate this sink and are outside the scope of the round that introduced it, and each conversion needs its own before/after comparison to stay a refactor rather than a rewrite. Queued as SH-13.4 rather than left as a note, because a half-adopted abstraction is the state most likely to be forgotten: the next probe author sees twelve neighbours printing directly and reasonably concludes that is the house style. Completed item: SH-13.3: The two new probes wrote from 94 independent print sites between them Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 33 +- .../src/bin/core_affinity.rs | 291 +++++++++++++----- .../src/bin/doorbell_cost.rs | 126 ++++++-- crates/windows-platform-probes/src/lib.rs | 1 + crates/windows-platform-probes/src/report.rs | 105 +++++++ .../src/report/tests.rs | 71 +++++ 6 files changed, 519 insertions(+), 108 deletions(-) create mode 100644 crates/windows-platform-probes/src/report.rs create mode 100644 crates/windows-platform-probes/src/report/tests.rs diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index a42cc69e..1b45490c 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -637,8 +637,31 @@ mutation wrapper. The conversion's three silent fallbacks are replaced by one ru differently from one the host would not report, and that the two streams cannot satisfy each other's assertions. -- [ ] **SH-13.3** -- **`probe-core-affinity` writes from 67 independent print sites.** Same rule, same - fix, in [core_affinity.rs](crates/windows-platform-probes/src/bin/core_affinity.rs). - -- [ ] **SH-13.4** -- **`probe-doorbell-cost` writes from 27 independent print sites.** Same rule, same - fix, in [doorbell_cost.rs](crates/windows-platform-probes/src/bin/doorbell_cost.rs). +- [x] **SH-13.3** -- **The two new probes wrote from 94 independent print sites between them.** Same + rule as SH-13.2, in [core_affinity.rs](crates/windows-platform-probes/src/bin/core_affinity.rs) + (67 sites) and [doorbell_cost.rs](crates/windows-platform-probes/src/bin/doorbell_cost.rs) (27). + A `Report` sink now lives in [report.rs](crates/windows-platform-probes/src/report.rs), shared by + both. One stream, not two: unlike the placement probe these have only ever written to stdout, and + inventing a diagnostic stream they do not use would be adding a distinction the tools do not make. + Each `main` is now three lines -- measure, render, emit -- and is the only place naming the real + stream. + One find during the conversion that the mechanical part would have missed: `render` called + `fingerprint::print_banner()`, which writes to stdout *itself*. Left alone it would have put the + identifying line on the terminal while leaving it out of the returned report, so a captured report + would be missing the one line saying which machine produced it -- and the `!!SYNTHETIC!!` taint + marker with it. `banner_line()` already existed for exactly this and is now used. + Verified as a pure refactor by running both probes before and after and comparing with numerals + masked (their output is timing-dependent, so byte equality is not available): 38 lines and 50 lines + respectively, **structurally identical** both times. + +- [ ] **SH-13.4** -- **The other twelve probes still print directly, and now there is a sink to + adopt.** `probe-peer-index-cache` (55 sites), `probe-request-cost` (45), `probe-topology` (32), + `probe-queue-contention` (27), `probe-ioring` (24), `probe-completion-port` (22), + `probe-worker-context` (22), `probe-device-map` (21), `probe-cancel-io` (19), + `probe-pool-growth` (16), `probe-handle-state` (14), `probe-error-mode` (10) -- 307 sites. + Deliberately **not** done in the review round that introduced the sink: those probes predate it and + are outside that round's scope, and each conversion needs its own before/after comparison against + the probe's real output, which is what makes it a refactor rather than a rewrite. + Queued rather than left as a note precisely because a half-adopted abstraction is the state most + likely to be forgotten -- the next probe author will see twelve neighbours printing directly and + reasonably conclude that is the house style. diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index 2e0b8ea2..b336277c 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -2,17 +2,39 @@ //! Prints whether it matters where the two ends of a queue run. +use std::fmt::Write as _; + use windows_placement_probe::core_affinity::{Observation, Placement, measure}; use windows_placement_probe::peer_index_cache::Strategy; +use windows_platform_probes::report::{Stdout, emit}; fn main() -> std::io::Result<()> { - windows_placement_probe::fingerprint::print_banner(); - println!("== does it matter where the two ends of a queue run? ==\n"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render(&measure()?)); + Ok(()) +} - let observation = measure()?; +/// The probe's whole report, as text. +fn render(observation: &Observation) -> String { + let mut out = String::new(); + // `banner_line`, not `print_banner`: the latter writes to stdout itself, + // which would put a line on the terminal that the returned report does not + // contain -- so a captured report would be missing the one line that says + // which machine produced it, and the taint marker with it. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); + let _ = writeln!( + out, + "== does it matter where the two ends of a queue run? ==\n" + ); - println!("processors, as discovered:"); - println!( + let _ = writeln!(out, "processors, as discovered:"); + let _ = writeln!( + out, " {:>8} {:>16} {:>13}", "cpu", "efficiency class", "cache domain" ); @@ -20,7 +42,8 @@ fn main() -> std::io::Result<()> { // Group and number together: a number is unique only within its group, // so two distinct processors on a machine with more than 64 of them // would otherwise both render as `cpu5`. - println!( + let _ = writeln!( + out, " {:>8} {:>16} {:>13}", format!("g{}/cpu{}", place.group, place.number), place.efficiency_class, @@ -40,7 +63,8 @@ fn main() -> std::io::Result<()> { seen.dedup(); seen }; - println!( + let _ = writeln!( + out, "\n {} efficiency class(es), {} cache domain(s)", classes.len(), { @@ -56,8 +80,12 @@ fn main() -> std::io::Result<()> { ); if !observation.by_class.is_empty() { - println!("\n-- the same handoff, within each efficiency class --"); - println!( + let _ = writeln!( + out, + "\n-- the same handoff, within each efficiency class --" + ); + let _ = writeln!( + out, "{:<12} {:>8} {:>8} {:>12} {:>12} {:>10}", "class", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" ); @@ -78,7 +106,8 @@ fn main() -> std::io::Result<()> { .iter() .find(|m| m.producer.efficiency_class == class && m.strategy == Strategy::Cached); if let (Some(base), Some(cached)) = (base, cached) { - println!( + let _ = writeln!( + out, "{:<12} {:>8} {:>8} {:>12.1} {:>12.1} {:>10.1}", format!("class {class}"), format!("g{}/cpu{}", base.producer.group, base.producer.number), @@ -89,14 +118,16 @@ fn main() -> std::io::Result<()> { ); } } - println!( + let _ = writeln!( + out, " (Windows numbers efficiency classes with the FASTER cores higher, so\n \ the highest class here is the performance one.)" ); } - println!("\n-- the handoff, by placement --"); - println!( + let _ = writeln!(out, "\n-- the handoff, by placement --"); + let _ = writeln!( + out, "{:<26} {:>8} {:>8} {:>12} {:>12} {:>10} {:>10}", "placement", "prod", "cons", "base ns/it", "cached ns/it", "base depth", "cach depth" ); @@ -121,7 +152,8 @@ fn main() -> std::io::Result<()> { ) else { // Absent is a finding, not a gap: it means this machine cannot // express the placement at all. - println!( + let _ = writeln!( + out, "{:<26} {:>8} {:>8} {:>12} {:>12} {:>10} {:>10}", placement.label(), "-", @@ -133,7 +165,8 @@ fn main() -> std::io::Result<()> { ); continue; }; - println!( + let _ = writeln!( + out, "{:<26} {:>8} {:>8} {:>12.1} {:>12.1} {:>10.1} {:>10.1}", placement.label(), format!("g{}/cpu{}", base.producer.group, base.producer.number), @@ -145,16 +178,17 @@ fn main() -> std::io::Result<()> { ); } - println!("\nthe slice each row was measured on:"); + let _ = writeln!(out, "\nthe slice each row was measured on:"); for placement in all { if let Some(base) = observation.get(placement, Strategy::Baseline) { - println!(" {:<26} {}", placement.label(), base.slice); + let _ = writeln!(out, " {:<26} {}", placement.label(), base.slice); } } - print_node_distances(&observation); + render_node_distances(&mut out, observation); - println!( + let _ = writeln!( + out, " interpretation: " @@ -162,11 +196,20 @@ interpretation: let expressible = observation.placements(); if expressible.len() < 2 { - println!(" This machine expresses only one placement, so it cannot answer"); - println!(" the question. That is a fact about the host, not a null result:"); - println!(" a homogeneous single-cache machine has nowhere else to put the"); - println!(" two threads."); - return Ok(()); + let _ = writeln!( + out, + " This machine expresses only one placement, so it cannot answer" + ); + let _ = writeln!( + out, + " the question. That is a fact about the host, not a null result:" + ); + let _ = writeln!( + out, + " a homogeneous single-cache machine has nowhere else to put the" + ); + let _ = writeln!(out, " two threads."); + return out; } // Whether the two factors can be told apart at all on this host. If every @@ -176,13 +219,31 @@ interpretation: let confounded = !expressible.contains(&Placement::SameCacheCrossClass) && !expressible.contains(&Placement::CrossCacheSameClass); if confounded { - println!(" CAUTION: on this machine the efficiency classes and the cache"); - println!(" domains coincide exactly, so every cross-class pair is also a"); - println!(" cross-cache pair. The two effects are perfectly CONFOUNDED here"); - println!(" and nothing below separates them. Read the rows as 'within a"); - println!(" domain' versus 'across domains', and do not attribute the"); - println!(" difference to core speed or to cache without a machine whose"); - println!(" classes and caches cut differently.\n"); + let _ = writeln!( + out, + " CAUTION: on this machine the efficiency classes and the cache" + ); + let _ = writeln!( + out, + " domains coincide exactly, so every cross-class pair is also a" + ); + let _ = writeln!( + out, + " cross-cache pair. The two effects are perfectly CONFOUNDED here" + ); + let _ = writeln!( + out, + " and nothing below separates them. Read the rows as 'within a" + ); + let _ = writeln!( + out, + " domain' versus 'across domains', and do not attribute the" + ); + let _ = writeln!( + out, + " difference to core speed or to cache without a machine whose" + ); + let _ = writeln!(out, " classes and caches cut differently.\n"); } // Batch depth is read from the CACHED runs, never the baseline ones. @@ -240,36 +301,89 @@ interpretation: } else { "cross-class " }; - println!(" batch depth with caching on, {within}: {same:.1} items per shared read"); - println!(" batch depth with caching on, {across}: {cross:.1} items per shared read"); + let _ = writeln!( + out, + " batch depth with caching on, {within}: {same:.1} items per shared read" + ); + let _ = writeln!( + out, + " batch depth with caching on, {across}: {cross:.1} items per shared read" + ); if cross > same * 2.0 { - println!("\n SEPARATION DEEPENS THE BATCH. The two sides decouple: one runs"); - println!(" ahead, a real backlog forms, and each shared read is amortised"); - println!(" over it. That is the condition peer-index caching needs, and it"); - println!(" is a property of PLACEMENT -- not of the architecture."); + let _ = writeln!( + out, + "\n SEPARATION DEEPENS THE BATCH. The two sides decouple: one runs" + ); + let _ = writeln!( + out, + " ahead, a real backlog forms, and each shared read is amortised" + ); + let _ = writeln!( + out, + " over it. That is the condition peer-index caching needs, and it" + ); + let _ = writeln!( + out, + " is a property of PLACEMENT -- not of the architecture." + ); } else if same > cross * 2.0 { - println!("\n THE HYPOTHESIS IS REFUTED, AND BACKWARDS. Threads placed"); - println!( + let _ = writeln!( + out, + "\n THE HYPOTHESIS IS REFUTED, AND BACKWARDS. Threads placed" + ); + let _ = writeln!( + out, " TOGETHER batch {:.0}x deeper than threads placed apart, where the", same / cross.max(0.001) ); - println!(" prediction was the reverse -- that mismatched cores would"); - println!(" decouple and batch deeply."); - println!(" A coherent reading: a cheap handoff lets the producer race ahead"); - println!(" and build a backlog, while an expensive one throttles it into"); - println!(" lockstep, so each side arrives to find exactly one item. Cost"); - println!(" drives depth, rather than depth being set by core speed."); - println!(" That is a hypothesis this run does not test, and it must not be"); - println!(" recorded as a finding -- what IS established is that the"); - println!(" original prediction is wrong."); + let _ = writeln!( + out, + " prediction was the reverse -- that mismatched cores would" + ); + let _ = writeln!(out, " decouple and batch deeply."); + let _ = writeln!( + out, + " A coherent reading: a cheap handoff lets the producer race ahead" + ); + let _ = writeln!( + out, + " and build a backlog, while an expensive one throttles it into" + ); + let _ = writeln!( + out, + " lockstep, so each side arrives to find exactly one item. Cost" + ); + let _ = writeln!( + out, + " drives depth, rather than depth being set by core speed." + ); + let _ = writeln!( + out, + " That is a hypothesis this run does not test, and it must not be" + ); + let _ = writeln!( + out, + " recorded as a finding -- what IS established is that the" + ); + let _ = writeln!(out, " original prediction is wrong."); } else { - println!( + let _ = writeln!( + out, "\n Placement does NOT move batch depth here ({:.2}x).", cross / same ); - println!(" The hypothesis that unequal core speeds drive the batching is"); - println!(" not supported, and the difference between hosts needs another"); - println!(" explanation. Recording a refutation is the point of running it."); + let _ = writeln!( + out, + " The hypothesis that unequal core speeds drive the batching is" + ); + let _ = writeln!( + out, + " not supported, and the difference between hosts needs another" + ); + let _ = writeln!( + out, + " explanation. Recording a refutation is the point of running it." + ); } } @@ -285,17 +399,22 @@ interpretation: .get(Placement::CrossCacheCrossClass, Strategy::Baseline) .or_else(|| observation.get(Placement::CrossCacheSameClass, Strategy::Baseline)), ) { - println!( + let _ = writeln!( + out, "\n the unoptimised handoff costs {:.1} ns/item together and {:.1} ns/item", near.nanos_per_item, far.nanos_per_item ); - println!( + let _ = writeln!( + out, " apart -- {:.1}x for crossing the boundary, with no code change.", far.nanos_per_item / near.nanos_per_item ); } - println!("\n does the verdict on caching depend on placement?\n"); + let _ = writeln!( + out, + "\n does the verdict on caching depend on placement?\n" + ); let mut verdicts = Vec::new(); for placement in expressible { let (Some(base), Some(cached)) = ( @@ -312,7 +431,8 @@ interpretation: } else { "no effect" }; - println!( + let _ = writeln!( + out, " {:<26} {:>7.2}x {verdict}", placement.label(), speedup @@ -323,35 +443,51 @@ interpretation: verdicts.dedup(); if verdicts.len() > 1 { - println!("\n THE VERDICT FLIPS WITHIN ONE MACHINE. A technique whose sign"); - println!(" depends on where two threads are scheduled cannot be adopted or"); - println!(" rejected by a fixed decision. Any answer has to name the"); - println!(" placement it holds for."); + let _ = writeln!( + out, + "\n THE VERDICT FLIPS WITHIN ONE MACHINE. A technique whose sign" + ); + let _ = writeln!( + out, + " depends on where two threads are scheduled cannot be adopted or" + ); + let _ = writeln!( + out, + " rejected by a fixed decision. Any answer has to name the" + ); + let _ = writeln!(out, " placement it holds for."); } else { - println!("\n The verdict is the same at every placement on this host, so"); - println!(" placement alone does not explain the disagreement between hosts."); + let _ = writeln!( + out, + "\n The verdict is the same at every placement on this host, so" + ); + let _ = writeln!( + out, + " placement alone does not explain the disagreement between hosts." + ); } - Ok(()) + out } /// Print the per-node-pair handoff cost, when the host has nodes to cross. /// /// Silent on a single-node machine: there is nothing to say, and a header over /// an empty table invites the reader to wonder what went wrong. -fn print_node_distances(observation: &Observation) { +fn render_node_distances(out: &mut String, observation: &Observation) { let pairs = observation.node_pairs_measured(); if pairs.is_empty() { return; } - println!("\n-- the handoff, by NUMA node pair --"); + let _ = writeln!(out, "\n-- the handoff, by NUMA node pair --"); // A ring-placement column, because a pair and a strategy no longer identify // one row: every hop is measured once with the ring on the producer's node // and once on the consumer's. Rendering one of them would drop half the // measurements and, worse, could pair a baseline taken at one placement // against a cached run taken at the other. - println!( + let _ = writeln!( + out, "{:<14} {:>8} {:>8} {:>8} {:>12} {:>12} {:>10}", "prod -> cons", "ring on", "prod", "cons", "base ns/it", "cached ns/it", "cach depth" ); @@ -359,7 +495,10 @@ fn print_node_distances(observation: &Observation) { // run asked for, since that is what identifies the row; a `!` means the // memory did not land there, so that row does not measure the placement it // names. - println!(" (`ring on` is the node requested; `!` means it landed elsewhere)"); + let _ = writeln!( + out, + " (`ring on` is the node requested; `!` means it landed elsewhere)" + ); let mut slowest: Option<(f64, (u32, u32))> = None; let mut fastest: Option<(f64, (u32, u32))> = None; @@ -379,7 +518,8 @@ fn print_node_distances(observation: &Observation) { else { continue; }; - println!( + let _ = writeln!( + out, "{:<14} {:>8} {:>8} {:>8} {:>12.1} {:>12.1} {:>10.1}", // `->`, not `<->`: hops are directed, because the producer // writes and the consumer reads. The probe crate's own report @@ -413,7 +553,8 @@ fn print_node_distances(observation: &Observation) { } if pairs.len() == 1 { - println!( + let _ = writeln!( + out, "\n One node pair, so this restates the `cross NUMA node` row above\n \ rather than adding to it. The table earns its place from three\n \ nodes upward, where the hops stop being interchangeable." @@ -424,7 +565,8 @@ fn print_node_distances(observation: &Observation) { let (Some((worst, worst_pair)), Some((best, best_pair))) = (slowest, fastest) else { return; }; - println!( + let _ = writeln!( + out, "\n {} node pairs. Cheapest hop {} <-> {} at {:.1} ns/item; dearest\n \ {} <-> {} at {:.1} ns/item -- a spread of {:.1}x.", pairs.len(), @@ -437,19 +579,22 @@ fn print_node_distances(observation: &Observation) { worst / best ); if worst / best < 1.2 { - println!( + let _ = writeln!( + out, " That spread is small enough that this host's nodes are close to\n \ equidistant, so the single `cross NUMA node` row above is a fair\n \ summary of it." ); } else { - println!( + let _ = writeln!( + out, " The hops are NOT interchangeable, so the single `cross NUMA node`\n \ row above reports whichever one was enumerated first and should not\n \ be read as 'the' cost of leaving a node." ); } - println!( + let _ = writeln!( + out, " This measures the handoff between two nodes; it is not a distance\n \ matrix read from firmware. Windows exposes no NUMA distance table, so\n \ these numbers are the observable rather than a restatement of ACPI." diff --git a/crates/windows-platform-probes/src/bin/doorbell_cost.rs b/crates/windows-platform-probes/src/bin/doorbell_cost.rs index e5a2f110..25af1cb1 100644 --- a/crates/windows-platform-probes/src/bin/doorbell_cost.rs +++ b/crates/windows-platform-probes/src/bin/doorbell_cost.rs @@ -12,55 +12,100 @@ //! adequate and the more delicate protocol -- publish intent, re-check, park -- //! can wait for evidence that it is worth its lost-wakeup risk. -use windows_platform_probes::doorbell_cost::{measure, measure_park_and_wake}; +use std::fmt::Write as _; +use windows_platform_probes::doorbell_cost::{Observation, measure, measure_park_and_wake}; + +use windows_platform_probes::report::{Stdout, emit}; fn main() { - println!("== what does a doorbell cost, against the syscall it guards? ==\n"); + // The only place that names the real stream. Everything above composes + // text; nothing above knows where it goes. + emit( + &mut Stdout, + &render(&measure(), measure_park_and_wake(20_000)), + ); +} - let observation = measure(); +/// The probe's whole report, as text. +fn render(observation: &Observation, park: Option) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "== what does a doorbell cost, against the syscall it guards? ==\n" + ); - println!("{:<30} {:>12}", "operation", "ns/op"); + let _ = writeln!(out, "{:<30} {:>12}", "operation", "ns/op"); for timing in &observation.timings { - println!("{:<30} {:>12.1}", timing.label, timing.nanos_per_op); + let _ = writeln!(out, "{:<30} {:>12.1}", timing.label, timing.nanos_per_op); } - let park = measure_park_and_wake(20_000); match park { - Some(ns) => println!("{:<30} {:>12.1}", "park_and_wake round trip", ns), - None => println!("{:<30} {:>12}", "park_and_wake round trip", "TIMED OUT"), + Some(ns) => { + let _ = writeln!(out, "{:<30} {:>12.1}", "park_and_wake round trip", ns); + } + None => { + let _ = writeln!( + out, + "{:<30} {:>12}", + "park_and_wake round trip", "TIMED OUT" + ); + } } - println!("\ninterpretation:"); + let _ = writeln!(out, "\ninterpretation:"); if let Some(atomic) = observation.get("atomic_fetch_add") && let Some(doorbell) = observation.get("set_reset_event") && atomic > 0.0 { - println!( + let _ = writeln!( + out, " a doorbell cycle costs {:.0}x an uncontended atomic ({:.0} ns vs {:.1} ns).", doorbell / atomic, doorbell, atomic ); if let Some(park) = park { - println!( + let _ = writeln!( + out, " an actual park-and-wake round trip costs {:.0}x that again ({:.0} ns),", park / doorbell, park ); - println!(" which is what is paid when the consumer genuinely sleeps."); + let _ = writeln!( + out, + " which is what is paid when the consumer genuinely sleeps." + ); } } // Deliberately NOT expressed as a share of the empty submit. See below. if let Some(submit) = observation.submit_nanos { - println!("\n CAUTION: an empty SubmitIoRing measured {submit:.0} ns, which is far too"); - println!(" cheap for a kernel transition -- it is almost certainly short-"); - println!(" circuiting in user mode when there is nothing queued. It is"); - println!(" therefore NOT a fair denominator, and any 'doorbell is N% of a"); - println!(" syscall' figure derived from it would be a confident wrong answer."); - println!(" The honest denominator is the cost of the real work a submission"); - println!(" carries, which this probe does not measure."); + let _ = writeln!( + out, + "\n CAUTION: an empty SubmitIoRing measured {submit:.0} ns, which is far too" + ); + let _ = writeln!( + out, + " cheap for a kernel transition -- it is almost certainly short-" + ); + let _ = writeln!( + out, + " circuiting in user mode when there is nothing queued. It is" + ); + let _ = writeln!( + out, + " therefore NOT a fair denominator, and any 'doorbell is N% of a" + ); + let _ = writeln!( + out, + " syscall' figure derived from it would be a confident wrong answer." + ); + let _ = writeln!( + out, + " The honest denominator is the cost of the real work a submission" + ); + let _ = writeln!(out, " carries, which this probe does not measure."); } // What can be said without a denominator: how much batching it takes for @@ -69,25 +114,44 @@ fn main() { && let Some(atomic) = observation.get("atomic_fetch_add") && atomic > 0.0 { - println!("\n batching is the lever, and it is a strong one. One doorbell per"); - println!(" drained batch costs, per operation:"); + let _ = writeln!( + out, + "\n batching is the lever, and it is a strong one. One doorbell per" + ); + let _ = writeln!(out, " drained batch costs, per operation:"); for batch in [1_u32, 8, 32, 128] { - println!( + let _ = writeln!( + out, " batch of {batch:>4}: {:>7.1} ns/op ({:.1}x an atomic)", doorbell / f64::from(batch), doorbell / f64::from(batch) / atomic ); } let break_even = (doorbell / atomic).ceil() as u32; - println!(" so at a batch of about {break_even}, the doorbell costs less per"); - println!(" operation than the atomic push it accompanies."); + let _ = writeln!( + out, + " so at a batch of about {break_even}, the doorbell costs less per" + ); + let _ = writeln!(out, " operation than the atomic push it accompanies."); } - println!("\n => The skip-when-busy rule is a refinement, not a prerequisite."); - println!(" Batching alone drives the doorbell below the cost of the push,"); - println!(" so a first implementation can always-signal and stay honest."); - println!(" Adopt the eventcount when a measurement against real work"); - println!(" justifies its lost-wakeup risk -- not before."); + let _ = writeln!( + out, + "\n => The skip-when-busy rule is a refinement, not a prerequisite." + ); + let _ = writeln!( + out, + " Batching alone drives the doorbell below the cost of the push," + ); + let _ = writeln!( + out, + " so a first implementation can always-signal and stay honest." + ); + let _ = writeln!( + out, + " Adopt the eventcount when a measurement against real work" + ); + let _ = writeln!(out, " justifies its lost-wakeup risk -- not before."); let atomic = observation.get("atomic_fetch_add").unwrap_or(f64::NAN); let already = observation @@ -95,7 +159,8 @@ fn main() { .unwrap_or(f64::NAN); let cycle = observation.get("set_reset_event").unwrap_or(f64::NAN); let wait0 = observation.get("wait_zero_signalled").unwrap_or(f64::NAN); - println!( + let _ = writeln!( + out, concat!( r#"{{"reason":"x-probe-doorbell-cost","arch":"{}","atomic_ns":{:.1},"#, r#""set_event_already_signalled_ns":{:.1},"set_reset_event_ns":{:.1},"#, @@ -115,4 +180,5 @@ fn main() { .doorbell_share_of_submit() .map_or("null".to_string(), |s| format!("{s:.4}")), ); + out } diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index 1470d76e..95795944 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -118,6 +118,7 @@ pub mod handle_state; pub mod ioring; pub mod pool_growth; pub mod queue_contention; +pub mod report; pub mod request_cost; pub mod topology; pub mod worker_context; diff --git a/crates/windows-platform-probes/src/report.rs b/crates/windows-platform-probes/src/report.rs new file mode 100644 index 00000000..3fcb1686 --- /dev/null +++ b/crates/windows-platform-probes/src/report.rs @@ -0,0 +1,105 @@ +// Copyright (c) 2026 Mike Grier +//! The one place a probe writes. +//! +//! # Why an abstraction for something as simple as printing +//! +//! The repository's rule is that a tool introduces an output abstraction at its +//! *first* output site, so that the storage target and the formatting stay +//! separable from the call sites that compose the content. A probe that calls +//! `println!` from fifty places has welded the two together: its findings can +//! only be observed by running the process and capturing a stream, so nothing +//! about its report can be asserted, diffed against a previous run, or written +//! anywhere but a terminal. +//! +//! That is a real loss for a probe specifically. These binaries exist so a +//! claim in a design note can be **re-run rather than re-argued**, which means +//! their output is evidence -- and evidence that can only be eyeballed is +//! weaker than evidence a test can read. +//! +//! # What this is, and what it deliberately is not +//! +//! A sink, not a logging framework: one method, no levels, no filtering, no +//! formatting policy. Callers still own their text. +//! +//! Only one stream, unlike the placement probe's near-identical sink, because +//! these probes have only ever written to stdout -- every one of their findings +//! is a finding, and none of them is a diagnostic competing with the report for +//! a reader's attention. Adding a second stream here would be inventing a +//! distinction the tools do not make. +//! +//! # This is not yet used by every probe +//! +//! The two probes added alongside this module route through it. The other +//! twelve predate it and still print directly; converting them is queued rather +//! than done here, so that this change stays reviewable and each conversion can +//! be checked against its probe's real output. + +use std::fmt::Write as _; + +/// Somewhere a probe's report can go. +pub trait Report { + /// Emit one line. + fn line(&mut self, text: &str); +} + +/// The real stream. +pub struct Stdout; + +impl Report for Stdout { + fn line(&mut self, text: &str) { + println!("{text}"); + } +} + +/// A report that keeps what it was given. +/// +/// The point of the whole abstraction: a probe's findings become a value a test +/// can read, rather than bytes only a terminal ever sees. +#[derive(Debug, Default)] +pub struct Captured { + /// Lines written, in order. + pub lines: Vec, +} + +impl Report for Captured { + fn line(&mut self, text: &str) { + self.lines.push(text.to_owned()); + } +} + +impl Captured { + /// The report as one string, as a reader would see it. + #[must_use] + pub fn text(&self) -> String { + self.lines.join("\n") + } +} + +/// Write a rendered block to `report`, one line at a time. +/// +/// A `render_*` function produces a whole block with embedded newlines and a +/// [`Report`] speaks in lines, so this is the join between them. Splitting +/// rather than passing the block through keeps [`Captured`] line-addressable, +/// which is what lets a test name a row instead of searching the whole document +/// for a substring. +/// +/// A trailing newline does not produce an extra empty line, because +/// `str::lines` does not yield one -- so a renderer may end its block either way +/// without changing what a reader sees. +pub fn emit(report: &mut impl Report, block: &str) { + for line in block.lines() { + report.line(line); + } +} + +/// Append `text` and a newline to `out`, discarding the impossible error. +/// +/// Every `render_*` function in these probes writes into a `String`, whose +/// `fmt::Write` impl cannot fail, so each call site would otherwise carry a +/// `let _ =` that says nothing. This says it once. +pub fn writeln_to(out: &mut String, text: &str) { + let _ = writeln!(out, "{text}"); +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-platform-probes/src/report/tests.rs b/crates/windows-platform-probes/src/report/tests.rs new file mode 100644 index 00000000..c9a59980 --- /dev/null +++ b/crates/windows-platform-probes/src/report/tests.rs @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for the report sink. +//! +//! These are small, and that is the point: the sink's whole job is to be the +//! seam that lets a probe's *findings* be asserted rather than eyeballed. What +//! is worth pinning here is the seam's own behaviour, so that a test written +//! against a probe's report can trust what it is reading. + +use super::{Captured, Report, emit, writeln_to}; + +#[test] +fn a_captured_report_keeps_its_lines_in_order() { + // Order is the property a probe's report depends on most: its tables are + // rows under a header, and a sink that reordered them would turn a correct + // measurement into a wrong one. + let mut captured = Captured::default(); + captured.line("header"); + captured.line("row one"); + captured.line("row two"); + + assert_eq!(captured.lines, ["header", "row one", "row two"]); + assert_eq!(captured.text(), "header\nrow one\nrow two"); +} + +#[test] +fn emitting_a_block_gives_the_report_one_line_at_a_time() { + // What makes a captured report addressable by line rather than by substring + // search -- so a test can say "the third row" instead of hoping a phrase is + // unique in the document. + let mut captured = Captured::default(); + emit(&mut captured, "one\ntwo\nthree"); + + assert_eq!(captured.lines, ["one", "two", "three"]); +} + +#[test] +fn a_trailing_newline_does_not_become_an_extra_blank_line() { + // A renderer may end its block with a newline or without one, and the two + // must look the same to a reader. Otherwise every `render_*` function would + // have to agree on a convention that nothing enforces, and the first one to + // drift would add a blank line nobody could account for. + let mut with = Captured::default(); + let mut without = Captured::default(); + emit(&mut with, "one\ntwo\n"); + emit(&mut without, "one\ntwo"); + + assert_eq!(with.lines, without.lines); +} + +#[test] +fn an_interior_blank_line_survives() { + // The other side of the previous test, and the reason it cannot simply + // filter empties: these reports use blank lines to separate sections, so a + // sink that swallowed them would run the tables together. + let mut captured = Captured::default(); + emit(&mut captured, "section\n\nnext"); + + assert_eq!(captured.lines, ["section", "", "next"]); +} + +#[test] +fn writeln_to_appends_a_line_rather_than_replacing_the_buffer() { + // `writeln_to` exists so the `let _ =` on an infallible `write!` is stated + // once rather than at every call site; this checks it composes, since a + // renderer calls it dozens of times in sequence. + let mut out = String::new(); + writeln_to(&mut out, "first"); + writeln_to(&mut out, "second"); + + assert_eq!(out, "first\nsecond\n"); +} From 0599a5d56d49601c5d5d419cb7fb2492156a8469 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 00:18:57 -0400 Subject: [PATCH 189/361] fix(waitable-queues)!: widen slotwise_mpsc positions to 64 bits on every target slotwise_mpsc's Vyukov sequence protocol rests on a producer's observation of a slot's sequence still being true when its compare-exchange succeeds, and the exchange guards only the tail. A producer suspended between the two resumes safely *because* the tail cannot have returned to the value it read -- which holds only while the counter cannot lap. With AtomicUsize it can. On a 32-bit target the counter laps after 2^32 claims, which at this crate's measured rates is a matter of minutes. The stalled producer then sees the same tail bits, succeeds at compare_exchange_weak(position, position + 1), and writes a slot that has since been refilled from the previous lap of the ring. Every other guard holds -- the position really is claimed by exactly one producer, which is what the SAFETY comment asserts; what fails is the older, separate claim that the slot was free. Positions and slot sequences are now a named `Position = u64` on every target, so the lap needs 2^64 claims and cannot be reached. On 64-bit this is exactly what usize already was; on 32-bit the exchange becomes a 64-bit one. Verified on a real 32-bit target rather than argued. The suite passes under i686-pc-windows-msvc (290 tests), and a probe there reports target_has_atomic = "64" with AtomicU64 lock-free -- so the fix costs a cmpxchg8b, not a hidden mutex, which is the outcome that would have made this a bad trade. Three narrowing points are stated where they happen rather than left to be re-derived. `slot_index` masks after casting, which is exact because the mask is capacity - 1 and a capacity fits a usize. `len` casts after clamping to the capacity, so the clamp is what makes it exact. `record_depth` saturates instead of casting, because a wrapped subtraction would otherwise truncate to an arbitrary small number and record a LOWER depth than the true one -- the one direction this gauge must never err in. `producers` stays AtomicUsize: it is a handle refcount, not a position, and nothing compares it against one. This also falsified a claim written in SH-6.1, that spsc and slotwise_mpsc "use usize positions and cannot be driven there at all". That was written from a 64-bit reading and never re-checked against the 32-bit support this crate otherwise takes seriously enough to have a dedicated BOUNDS_MAX derivation and a const assertion for. Corrected in place. Marked as breaking: Position appears in no public signature, but the atomic width of a shipped concurrent data structure is the kind of change a caller pinning a target may need to react to. reserving_mpsc has the same hazard and is NOT fixed here. It is worse -- its POSITION_BITS is hardcoded 32, so it laps on every target, not only 32-bit -- and its u64 claim word is already fully spent (32 position + 32 reservation, with MAX_RESERVED needing to cover BOUNDS_MAX = 2^31), so there is no spare bit for a generation without narrowing the capacity the shape offers. That is a design decision rather than a patch, and it is recorded with its options as SH-14.1 and SH-14.3. Completed item: SH-14.2: slotwise_mpsc had the same hole on a 32-bit target Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 78 ++++++++++++++++- .../src/slotwise_mpsc.rs | 85 ++++++++++++++----- 2 files changed, 140 insertions(+), 23 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 1b45490c..224f5708 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -288,9 +288,15 @@ D-31 says cannot be supported. - [ ] **SH-6.1** -- **The wraparound scenario, which is the one reachable correctness gap.** `reserving_mpsc` packs its position into 32 bits, so it wraps after 2^32 pushes -- about two minutes - at measured rates, and reachable in production within hours. `spsc` and `slotwise_mpsc` use `usize` - positions and cannot be driven there at all, so this gap belongs to the shape whose position is - narrow by design. + at measured rates, and reachable in production within hours. + **CORRECTION (review round nine).** An earlier version of this item said `spsc` and + `slotwise_mpsc` "use `usize` positions and cannot be driven there at all", and that is **false on a + 32-bit target**, where `usize` *is* 32 bits. The claim was written from a 64-bit reading and never + re-checked against the 32-bit support the crate otherwise takes seriously enough to have a + dedicated `BOUNDS_MAX` derivation and a `const` assertion for. `slotwise_mpsc` reaches the same + wrap on such a target; `spsc` does too, but has no compare-exchange claim to be raced, so wrapping + alone does not expose it. See SH-14.1 and SH-14.2, which are about the *correctness* hole this + testing gap was hiding. What exists today is *ring* wraparound (positions cycling through slots) and the packing arithmetic checked at the boundary; what does not is the queue actually crossing 2^32 end to end. **Tracking every item is impossible at that count**, so the invariants are the cheap ones: per-producer sequence @@ -665,3 +671,69 @@ mutation wrapper. The conversion's three silent fallbacks are replaced by one ru Queued rather than left as a note precisely because a half-adopted abstraction is the state most likely to be forgotten -- the next probe author will see twelve neighbours printing directly and reasonably conclude that is the house style. + +## M14: PR #56 ninth review round -- an ABA hole the wrap test would not have caught + +Two findings, both verified by derivation against the source. They are **not** the wrap gap SH-6.1 +already tracked: that item is about *testing* the wrap, and its planned stress would not have found +either of these, because neither requires the wrap alone -- each requires a producer to remain +stalled **across** it, inside a window a few instructions wide. + +**The shared shape of both.** A producer decides a slot is writable, is suspended, and resumes after +the claim counter has made a full lap back to the bit pattern it read. Its compare-exchange then +succeeds against a value that is numerically equal but logically a whole generation later, and the +decision it is about to act on was made against the *earlier* generation. The exchange protects the +counter; nothing protects the decision. + +- [ ] **SH-14.1** -- **`reserving_mpsc` can overwrite a live slot after 2^32 pushes, on every + target.** `POSITION_BITS` is 32 by construction -- the claim word is a `u64` split into a 32-bit + reservation count and a 32-bit position -- so this does **not** depend on a 32-bit `usize` and is + not a 32-bit-only concern. + The sequence: a producer reads `word = (reserved, position)` and calls + `has_room_beyond_reservations`, which reads `head` and returns room. It stalls. Other producers + claim 2^32 positions, wrapping `position` back to the value it read; with `reserved` at its steady + state (commonly zero), the *whole word* recurs. The stalled producer's + `compare_exchange_weak(word, ...)` now succeeds, and it publishes into a slot whose room was + decided against a `head` that has since advanced -- so the slot may hold an item the consumer has + not taken. The SAFETY comment above `publish` ("no other producer can also have claimed [this + position]") remains true and is not the property that fails; the failing property is that the slot + was free. + 2^32 pushes is **about two minutes at this crate's measured rates** (SH-6.1's own figure), so the + window is not exotic -- it needs an unlucky stall, not an unreachable one. + +- [x] **SH-14.2** -- **`slotwise_mpsc` had the same hole on a 32-bit target.** Its positions were + `AtomicUsize`, which is 32 bits there. A producer that has observed `sequence == position` -- the + slot is free -- and then stalls across a full lap resumes to find the same `tail` bits, succeeds + at `compare_exchange_weak(position, position + 1)`, and writes a slot that may now hold a live + item from the previous lap of the ring. The sequence observation that made the write safe is never + re-checked, and the exchange covers only `tail`. + This is the finding that also falsified SH-6.1's claim that this shape "cannot be driven there at + all" -- corrected in place. + **Fixed by widening the counter rather than by narrowing the platform.** Positions and slot + sequences are now a named `Position = u64` on every target, so the lap needs 2^64 claims and cannot + be reached. On 64-bit this is exactly what `usize` already was; on 32-bit the exchange becomes a + 64-bit one. Verified on a real 32-bit target rather than argued: the suite passes under + `i686-pc-windows-msvc` (290 tests), and a probe there reports `target_has_atomic = "64"` with + `AtomicU64` **lock-free** -- so the fix costs a `cmpxchg8b`, not a hidden mutex, which is the + outcome that would have made this a bad trade. + `producers` stays `AtomicUsize`: it is a handle refcount, not a position, and nothing compares it + against one. + +- [ ] **SH-14.3** -- **Decide the fix, which is a design decision rather than a patch.** Recorded + here so the options are not re-derived, with what each costs: + 1. **Widen the counter so it cannot lap.** For `slotwise_mpsc` this is `AtomicU64` instead of + `AtomicUsize`, which is free on 64-bit and costs a `cmpxchg8b`-class operation on 32-bit x86. + For `reserving_mpsc` there is **no room**: 32 position bits plus 32 reservation bits is exactly + the 64-bit word, and `MAX_RESERVED` must cover `BOUNDS_MAX` (2^31), so no generation field can + be carved out without narrowing the capacity the shape offers. + 2. **Narrow `reserving_mpsc`'s capacity to buy generation bits.** A smaller `BOUNDS_MAX` frees + bits in both halves. This does not *eliminate* the lap, it lengthens it -- any finite field + wraps -- so it is a mitigation whose adequacy has to be argued rather than a fix. + 3. **Re-validate after the claim.** Cheap to say, hard to do: once the exchange succeeds the + position is claimed, so there is no safe way to back out without a second protocol. + 4. **Drop 32-bit support explicitly** -- resolves SH-14.2 only, and leaves SH-14.1 untouched + because that one is target-independent. This narrows the platform, so per the repository's + platform-integrity rule it is the engineer's decision and not one to take in passing. + Whatever is chosen, the property is not observable from a test that merely crosses the wrap: it + needs a producer *held* between its decision and its exchange, which means a deliberate seam -- + the crate's existing race hooks (`ARM`, `CLEAR`, `CLAIM`) are the shape of what is needed. diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index ac66963e..cdee70d6 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -71,7 +71,31 @@ use core::cell::{Cell, UnsafeCell}; use core::fmt; use core::marker::PhantomData; use core::mem::MaybeUninit; -use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; + +/// A claim position, and the slot sequence numbers that are compared against +/// one. +/// +/// **64 bits on every target, deliberately, rather than `usize`.** The protocol +/// below rests on a producer's observation of a slot's sequence still being +/// true when its compare-exchange succeeds, and the exchange guards only the +/// tail. A producer suspended between the two resumes safely *because* the tail +/// cannot have returned to the value it read -- which holds only while the +/// counter cannot lap. +/// +/// With `usize` it can. On a 32-bit target the counter laps after 2^32 claims, +/// which at this crate's measured rates is a matter of minutes: the stalled +/// producer then sees the same tail bits, succeeds, and writes a slot that has +/// since been refilled from the previous lap of the ring. Every other guard in +/// this shape holds -- the position really is claimed by exactly one producer; +/// what fails is the older claim that the slot was free. +/// +/// 2^64 claims cannot be reached, so the lap cannot happen, and the argument is +/// restored on every target rather than only on the ones where `usize` happened +/// to be wide enough. The cost is confined to 32-bit, where the exchange becomes +/// a 64-bit one (`cmpxchg8b` on x86); on a 64-bit target this is exactly what +/// `usize` already was. +type Position = u64; use std::io; use std::os::windows::io::{BorrowedHandle, OwnedHandle}; use std::sync::Arc; @@ -190,7 +214,7 @@ fn build( let mut slots = Vec::with_capacity(capacity); for index in 0..capacity { slots.push(Slot { - sequence: AtomicUsize::new(index), + sequence: AtomicU64::new(index as Position), value: UnsafeCell::new(MaybeUninit::uninit()), }); } @@ -201,8 +225,8 @@ fn build( slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, - head: CacheAligned(AtomicUsize::new(0)), - tail: CacheAligned(AtomicUsize::new(0)), + head: CacheAligned(AtomicU64::new(0)), + tail: CacheAligned(AtomicU64::new(0)), producers: AtomicUsize::new(1), consumer_live: AtomicBool::new(true), doorbell: Doorbell::new(), @@ -232,7 +256,7 @@ struct Slot { /// neighbours, and that is intended: the contention this shape must avoid /// is on the two *positions*, which are padded apart below, not on the /// slots, which different producers touch at different indices anyway. - sequence: AtomicUsize, + sequence: AtomicU64, value: UnsafeCell>, } @@ -257,14 +281,14 @@ struct Shared { /// contended one while every individual load and store stays correct. That /// is a cost with no symptom other than being slow, which is exactly the /// kind that survives a code review. - head: CacheAligned, + head: CacheAligned, /// The claim counter. Advanced by a compare-and-swap, by any producer. /// /// Padded for the reason given on [`Shared::head`], and it matters more /// here than it does in `spsc`: this line is already the contended one, and /// letting the consumer's writes land on it too would add the consumer to /// the set of threads fighting over it. - tail: CacheAligned, + tail: CacheAligned, /// How many producer handles are alive. /// /// Reaching zero is the disconnection, and it is a count rather than a flag @@ -297,6 +321,16 @@ unsafe impl Sync for Shared {} unsafe impl Send for Shared {} impl Shared { + /// The slot a position addresses. + /// + /// The cast cannot lose anything the mask would have kept: the mask is + /// `capacity - 1`, and a capacity fits a `usize` by construction, so every + /// bit above the mask is discarded either way. Narrowing first and masking + /// second is the same answer as masking first and narrowing second. + fn slot_index(&self, position: Position) -> usize { + (position as usize) & self.mask + } + /// Items currently held, as a snapshot. /// /// **Counts slots a producer has claimed but not yet finished writing.** @@ -310,14 +344,18 @@ impl Shared { /// **Clamped to the capacity, because the two loads are not one instant.** /// `tail` is read first; if the consumer then drains past the value it /// held, `head` overtakes it and the wrapping subtraction yields a number - /// near `usize::MAX` -- a bounded queue claiming to hold more items than it - /// has slots. Over-reporting is the safe direction for this gauge and - /// under-reporting is not, so the skew is resolved towards "full" rather - /// than towards zero; what the clamp removes is only the impossible value. + /// near [`Position::MAX`] -- a bounded queue claiming to hold more items + /// than it has slots. Over-reporting is the safe direction for this gauge + /// and under-reporting is not, so the skew is resolved towards "full" + /// rather than towards zero; what the clamp removes is only the impossible + /// value. + /// + /// The clamp is also what makes the narrowing cast exact: the result is at + /// most the capacity, which is a `usize` by construction. fn len(&self) -> usize { let tail = self.tail.0.load(Ordering::Acquire); let head = self.head.0.load(Ordering::Acquire); - tail.wrapping_sub(head).min(self.capacity) + tail.wrapping_sub(head).min(self.capacity as Position) as usize } /// Whether the consumer would find an item right now. @@ -338,7 +376,7 @@ impl Shared { /// from returning stale values. fn has_ready_item(&self) -> bool { let position = self.head.0.load(Ordering::Relaxed); - let slot = &self.slots[position & self.mask]; + let slot = &self.slots[self.slot_index(position)]; slot.sequence.load(Ordering::Acquire) == position.wrapping_add(1) } } @@ -366,7 +404,7 @@ impl Drop for Shared { let mut position = head; while position != tail { let published = position.wrapping_add(1); - let slot = &mut self.slots[position & mask]; + let slot = &mut self.slots[(position as usize) & mask]; if *slot.sequence.get_mut() == published { // SAFETY: the slot's sequence says the producer finished // writing it and the consumer never took it, so it holds an @@ -410,7 +448,7 @@ impl Producer { // stale, so a stale read costs a retry rather than correctness. let mut position = self.shared.tail.0.load(Ordering::Relaxed); loop { - let slot = &self.shared.slots[position & self.shared.mask]; + let slot = &self.shared.slots[self.shared.slot_index(position)]; // Acquire: pairs with the consumer's release store when it frees a // slot, so a slot it has finished with is visible as free here. let sequence = slot.sequence.load(Ordering::Acquire); @@ -476,7 +514,7 @@ impl Producer { } } - let slot = &self.shared.slots[position & self.shared.mask]; + let slot = &self.shared.slots[self.shared.slot_index(position)]; // SAFETY: this thread's compare-and-swap claimed `position`, and a // position is claimed by exactly one producer. The consumer will not // read the slot until the release store below publishes it, and the @@ -526,12 +564,19 @@ impl Producer { // push raced, in every test that tracks high water, with the // offending value in hand. debug_assert!( - depth <= self.shared.capacity, + depth <= self.shared.capacity as Position, "depth {depth} exceeds capacity {}: the head was read after the \ publication and the consumer drained past this position", self.shared.capacity ); - self.shared.metrics.record_depth(depth); + // The assertion above is a `debug_assert`, so the cast must be + // sound in release too. Saturating rather than `as`: a wrapped + // subtraction on a 32-bit target would otherwise truncate to an + // arbitrary small number and record a *lower* depth than the true + // one, which is the direction this gauge must never err in. + self.shared + .metrics + .record_depth(usize::try_from(depth).unwrap_or(usize::MAX)); } // Release, and this is the publication: it must come after the write, @@ -675,7 +720,7 @@ impl Consumer { pub fn pop(&self) -> Option { // Relaxed: this thread is the only writer of `head`. let position = self.shared.head.0.load(Ordering::Relaxed); - let slot = &self.shared.slots[position & self.shared.mask]; + let slot = &self.shared.slots[self.shared.slot_index(position)]; // Acquire: pairs with the producer's release store, so an item it // published is visible here. let sequence = slot.sequence.load(Ordering::Acquire); @@ -711,7 +756,7 @@ impl Consumer { // that saw it early would overwrite an item this thread had not // finished taking. slot.sequence.store( - position.wrapping_add(self.shared.capacity), + position.wrapping_add(self.shared.capacity as Position), Ordering::Release, ); Some(item) From 24718ca3713f644c77e37d29f6a54ca87dfd9bb0 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 00:26:22 -0400 Subject: [PATCH 190/361] docs(waitable-queues): state the wait protocol's terminal step everywhere it is stated Five findings, one cause. blocking::recv has always had four steps -- pop, arm, check disconnection and take one last time, wait -- but the Waitable::arm contract, all three shapes' arm docs, two worked examples, and the README each described the three-step form. A caller following any of them waits forever at the end of the stream: the last producer's drop rings the doorbell ONCE, arm clears precisely that ring, and with no producer left nothing rings it again. The root defect is a contract that overstated itself. arm answers exactly one question -- can a later push be missed -- and on a producerless queue the answer is trivially no, so it returns true. Documented flatly as "safe to wait", that is an invitation to hang. It now says what it measures, and the four-step protocol is stated once on Waitable::arm with every other site pointing at it rather than paraphrasing it again, which is what let the statements drift apart in the first place. The final pop is not belt-and-braces either: a producer may push and THEN drop between the drain and the disconnection check, so skipping it discards an item that was successfully sent. That is what Parked::finish exists for, and the worked examples now show it. Pinned by arm_reports_safe_to_wait_on_an_empty_disconnected_queue, which asserts both halves -- arm returns true, and the doorbell is dark afterwards -- so the exception is bound to observable behavior rather than to prose. Both corrected doctests compile. The README is now compiled as a doctest (cfg(doctest), matching three sibling crates). It carries no code today, so this compiles nothing; it is there so the first example somebody adds is compiled rather than trusted, this round being the demonstration that prose nothing executes rots. Completed item: SH-14.4: Every statement of the wait protocol was missing its last step Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 20 ++++++++++ crates/windows-waitable-queues/README.md | 19 ++++++++- crates/windows-waitable-queues/src/lib.rs | 15 +++++++ .../src/reserving_mpsc.rs | 13 +++++-- .../src/slotwise_mpsc.rs | 27 +++++++++++-- crates/windows-waitable-queues/src/spsc.rs | 28 +++++++++++-- .../windows-waitable-queues/src/spsc/tests.rs | 39 +++++++++++++++++++ crates/windows-waitable-queues/src/traits.rs | 33 ++++++++++++++-- 8 files changed, 177 insertions(+), 17 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 224f5708..d1de8819 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -737,3 +737,23 @@ counter; nothing protects the decision. Whatever is chosen, the property is not observable from a test that merely crosses the wrap: it needs a producer *held* between its decision and its exchange, which means a deliberate seam -- the crate's existing race hooks (`ARM`, `CLEAR`, `CLAIM`) are the shape of what is needed. + +- [x] **SH-14.4** -- **Every statement of the wait protocol was missing its last step.** Five findings, + one cause. `blocking::recv` has always had four steps -- pop, `arm`, **check disconnection and take + one last time**, wait -- but the trait contract, all three shapes' `arm` docs, two worked examples, + and the README each described the three-step form. A caller following any of them waits forever at + the end of the stream: the last producer's drop rings the doorbell **once**, `arm` clears precisely + that ring, and with no producer left nothing rings it again. + The root defect is a contract that overstated itself. `arm` answers exactly one question -- can a + later *push* be missed -- and on a producerless queue the answer is trivially no, so it returns + `true`. Documented flatly as "safe to wait", that is an invitation to hang. It now says what it + measures, and the four-step protocol is stated on `Waitable::arm` with the other statements + pointing at it rather than paraphrasing it again. + Pinned by `arm_reports_safe_to_wait_on_an_empty_disconnected_queue`, which asserts both halves -- + `arm` returns `true`, *and* the doorbell is dark afterwards -- so the exception is bound to + observable behaviour rather than to prose. The final `pop` is likewise not belt-and-braces: a + producer may push *and then* drop between the drain and the check, which is what `Parked::finish` + exists for. + The README is now compiled as a doctest (`cfg(doctest)`, matching three sibling crates). It carries + no code today, so this compiles nothing -- it is there so the first example somebody adds is + compiled rather than trusted, this round being the demonstration that prose nothing executes rots. diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 6ee13af0..19296994 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -157,8 +157,23 @@ taken until the consumer clears it with `arm()`, and a producer's signal may land after the consumer has already drained -- so a wake means *there may be something*, never *there is something*. What the crate guarantees is the direction that matters: a wake is never missing. Follow the protocol the -blocking receivers use -- pop, `arm()`, re-check -- rather than treating the -handle as a readiness predicate. +blocking receivers use rather than treating the handle as a readiness +predicate. That protocol has **four** steps, and the fourth is the one that is +easy to leave out: + +1. take everything available; +2. `arm()`, and if it returns `false`, start again -- something arrived; +3. **check `is_disconnected()`, and if the producers are gone, take one last + time and stop.** `arm()` reports only whether a later *push* can be missed, + so on a queue with no producers left it still returns `true` -- having just + cleared the single doorbell ring their drop left behind. Waiting on the + strength of that `true` never wakes. The last take is not belt-and-braces + either: a producer may push *and then* drop between step 1 and this check; +4. only now, wait on the handle. + +`recv` already does all four. The steps matter when driving the handle +yourself -- through a `ThreadpoolWait`, or a `WaitForMultipleObjects` across +several queues -- because then there is nothing to delegate to. The event is created lazily, so a consumer that only polls never allocates a kernel object at all. diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 29edb5ae..43e8a0b8 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -288,3 +288,18 @@ pub use traits::{Bounded, Claim, Consumer, Drain, Observable, Producer, Reservin #[cfg(windows)] #[repr(align(128))] struct CacheAligned(T); + +// The README states this crate's wait protocol, and a review round found that +// statement had drifted from what `blocking::recv` actually does -- it named +// three steps where the code has four, and a caller following it would have +// waited forever at the end of the stream. That particular drift is fixed and +// pinned by a test, but the general risk is not: prose nothing executes can +// only rot. +// +// The README carries no code today, so this compiles nothing. It is here so +// that the first example somebody adds is compiled rather than trusted, which +// is the cheapest moment to close the gap. `cfg(doctest)` means the item exists +// only while rustdoc collects tests, so an ordinary build pays nothing. +#[cfg(all(doctest, windows))] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 503e702d..6a673ebe 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -1116,11 +1116,18 @@ impl Consumer { self.shared.doorbell.owned() } - /// Clears the doorbell and reports whether it is safe to wait on it. + /// Clears the doorbell and reports whether a later push could be missed. /// /// `true` means the queue had nothing takeable after the doorbell was - /// cleared, so any later push is guaranteed to signal and a wait cannot be - /// missed. `false` means something arrived in the meantime. + /// cleared, so any later push is guaranteed to signal. `false` means + /// something arrived in the meantime. /// + /// **`true` is not by itself permission to wait indefinitely.** It answers + /// only whether a later *push* can be missed, and says nothing about the + /// end of the stream: with every producer gone it still returns `true`, + /// having just cleared the single ring their drop left behind. See + /// [`Waitable::arm`](crate::Waitable::arm) for the four-step protocol an + /// indefinite wait needs, and the example on [`Self::doorbell`] for it + /// written out. /// /// Clearing must come before the check, which is the reverse of the order /// that reads naturally; see [D-9](../DESIGN-NOTES.md#d-9). diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index cdee70d6..fdac04ba 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -827,6 +827,19 @@ impl Consumer { /// if !rx.arm()? { /// continue; // Something arrived; waiting now would be wrong. /// } + /// // The end of the stream, which arming does not report. Without + /// // this the wait below never returns: the last producer's drop rang + /// // the doorbell once and `arm` has just cleared that ring. + /// // + /// // The final `pop` is not belt-and-braces -- a producer may push and + /// // then drop between the drain above and this check, and skipping it + /// // discards an item that was successfully sent. + /// if rx.is_disconnected() { + /// while let Some(item) = rx.pop() { + /// let _ = item; + /// } + /// return Ok(()); + /// } /// let handle = rx.doorbell()?; /// // SAFETY: a live event handle borrowed for the call. /// unsafe { WaitForSingleObject(handle.as_raw_handle(), INFINITE) }; @@ -854,12 +867,18 @@ impl Consumer { self.shared.doorbell.owned() } - /// Clears the doorbell and reports whether it is safe to wait on it. + /// Clears the doorbell and reports whether a later push could be missed. /// /// `true` means the queue had nothing takeable after the doorbell was - /// cleared, so any later push is guaranteed to signal and a wait cannot be - /// missed. `false` means something arrived in the meantime: take it instead - /// of waiting. + /// cleared, so any later push is guaranteed to signal. `false` means + /// something arrived in the meantime: take it instead of waiting. /// + /// **`true` is not by itself permission to wait indefinitely.** It answers + /// only whether a later *push* can be missed, and says nothing about the + /// end of the stream: with every producer gone it still returns `true`, + /// having just cleared the single ring their drop left behind. See + /// [`Waitable::arm`](crate::Waitable::arm) for the four-step protocol an + /// indefinite wait needs, and the example on [`Self::doorbell`] for it + /// written out. /// /// The order inside this method is the whole correctness argument, and it /// is the reverse of the one that reads naturally. Checking first would diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index 48c312d4..f5dc8cc6 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -747,6 +747,19 @@ impl Consumer { /// if !rx.arm()? { /// continue; // Something arrived; waiting now would be wrong. /// } + /// // The end of the stream, which arming does not report. Without + /// // this the wait below never returns: the last producer's drop rang + /// // the doorbell once and `arm` has just cleared that ring. + /// // + /// // The final `pop` is not belt-and-braces -- a producer may push and + /// // then drop between the drain above and this check, and skipping it + /// // discards an item that was successfully sent. + /// if rx.is_disconnected() { + /// while let Some(item) = rx.pop() { + /// let _ = item; + /// } + /// return Ok(()); + /// } /// let handle = rx.doorbell()?; /// // SAFETY: a live event handle borrowed for the call. /// unsafe { WaitForSingleObject(handle.as_raw_handle(), INFINITE) }; @@ -774,12 +787,19 @@ impl Consumer { self.shared.doorbell.owned() } - /// Clears the doorbell and reports whether it is safe to wait on it. + /// Clears the doorbell and reports whether a later push could be missed. /// /// `true` means the queue was still empty after the doorbell was cleared, - /// so any later push is guaranteed to signal and a wait cannot be missed. - /// `false` means something arrived in the meantime: take it instead of - /// waiting. + /// so any later push is guaranteed to signal. `false` means something + /// arrived in the meantime: take it instead of waiting. + /// + /// **`true` is not by itself permission to wait indefinitely.** It answers + /// only whether a later *push* can be missed, and says nothing about the + /// end of the stream: with every producer gone it still returns `true`, + /// having just cleared the single ring their drop left behind. See + /// [`Waitable::arm`](crate::Waitable::arm) for the four-step protocol an + /// indefinite wait needs, and the example on [`Self::doorbell`] for it + /// written out. /// /// The order inside this method is the whole correctness argument, and it /// is the reverse of the one that reads naturally. Clearing *first* and diff --git a/crates/windows-waitable-queues/src/spsc/tests.rs b/crates/windows-waitable-queues/src/spsc/tests.rs index ba9db09b..ec0ff7eb 100644 --- a/crates/windows-waitable-queues/src/spsc/tests.rs +++ b/crates/windows-waitable-queues/src/spsc/tests.rs @@ -455,6 +455,45 @@ fn arm_reports_safe_to_wait_when_empty() { ); } +#[test] +fn arm_reports_safe_to_wait_on_an_empty_disconnected_queue() { + // **The exception `arm`'s contract has to state, and the reason the + // documented protocol needs a fourth step.** + // + // `arm` answers one question -- can a later *push* be missed -- and on a + // queue with no producers left the answer is trivially no, so it says + // `true`. Read as "safe to wait", which is what the contract used to say + // flatly, that is a permanent hang: the last producer's drop rings the + // doorbell exactly once, `arm` clears precisely that ring, and nothing + // remains to ring it again. + // + // `blocking::recv` has always had the missing step -- it checks + // disconnection and takes one last item before waiting. What was wrong was + // every *statement* of the protocol: the trait's contract, three shapes' + // method docs, three worked examples, and the README all described the + // three-step form a caller could follow into an indefinite wait. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + rx.doorbell().expect("the doorbell must be creatable"); + drop(tx); + + assert!( + rx.is_disconnected(), + "the producer is gone, so the stream has ended" + ); + assert_eq!(rx.pop(), None, "and nothing is left to take"); + assert!( + rx.arm().expect("arming must succeed"), + "arm reports on missed pushes, not on the end of the stream -- so it \ + says `true` here, and a caller that treats that as permission to wait \ + indefinitely never wakes" + ); + assert!( + !doorbell_is_lit(&rx), + "and it has consumed the one-shot wakeup the producer's drop left, \ + which is what makes the wait permanent rather than merely long" + ); +} + #[test] fn arm_relights_the_doorbell_for_a_later_push() { let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs index af968346..f18c0fa3 100644 --- a/crates/windows-waitable-queues/src/traits.rs +++ b/crates/windows-waitable-queues/src/traits.rs @@ -372,12 +372,11 @@ pub trait Waitable { /// Returns the error from `CreateEventW` or `DuplicateHandle`. fn doorbell_owned(&self) -> io::Result; - /// Clears the doorbell and reports whether it is safe to wait on it. + /// Clears the doorbell and reports whether a later push could be missed. /// /// `true` means the queue had nothing to take *after* the doorbell was - /// cleared, so any later push is guaranteed to signal and a wait cannot be - /// missed. `false` means something arrived in the meantime: take it instead - /// of waiting. + /// cleared, so any later push is guaranteed to signal. `false` means + /// something arrived in the meantime: take it instead of waiting. /// /// **Waiting without arming is a permanent hang, not an occasional missed /// wakeup.** The full argument is in @@ -385,6 +384,32 @@ pub trait Waitable { /// before the emptiness check, which is the reverse of the order that reads /// naturally. /// + /// # `true` is not by itself permission to wait + /// + /// It answers exactly one question -- *can a later push be missed* -- and + /// says nothing about the end of the stream. On a queue whose producers are + /// all gone the answer to that question is trivially no, so this returns + /// `true`; but the last producer's drop rings the doorbell **once**, this + /// call clears precisely that ring, and nothing remains to ring it again. A + /// caller that waits indefinitely on the strength of `true` alone therefore + /// never wakes. + /// + /// So an indefinite wait needs four steps, not three: + /// + /// 1. take everything available; + /// 2. `arm`, and if it returns `false`, start again -- something arrived; + /// 3. **check [`Disconnectable::is_disconnected`], and if the producers are + /// gone, take one last time before reporting the end of the stream.** + /// That last take is not belt-and-braces: a producer may push *and then* + /// drop in the window between step 1 and this check, and skipping it + /// discards an item that was successfully sent; + /// 4. only now, wait. + /// + /// This is what [`Consumer::recv`](crate::Consumer) already does; the steps + /// are spelled out because a caller driving the handle itself -- through a + /// `ThreadpoolWait`, or a `WaitForMultipleObjects` over several queues -- + /// cannot delegate to it. + /// /// # Errors /// /// Returns the error from `CreateEventW` on the first call. From 07882f0d22574b897bfa74dfb07069cf540a6487 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 01:09:08 -0400 Subject: [PATCH 191/361] test(file-enumeration): close mutation gaps in the error and path surfaces A mutation sweep left 23 survivors across error.rs and path.rs. Two missing kinds of test, not 23 missing assertions. The error surface asserted only that a description is non-empty, so replacing every describe with a constant survived -- and for the variants whose text is wrapped by an outer write!, so did returning the empty string, because the prefix alone kept the rendering non-empty. Descriptions exist to tell failures apart, so the tests now assert the set is pairwise distinct, which catches every constant substitution at once. SessionError's three routes to its underlying error (typed accessor, source chain, rendered suffix) are each asserted, since a mutation that emptied one left the others intact. The path boundaries were tested with comfortably-wrong values -- a 400-character path, a relative path eight units past the limit -- which prove a check exists but not that it sits at the right unit. Exactly 259 units is now asserted accepted and 260 rejected, which pins MAX_PATH_CONTENT and the comparison together. Two path tests also revealed why their mutants survived. The existing drive-relative case, \\?\\C:foo, has no separator, so it is refused before the root is inspected and never reaches is_drive_designator at all; \\?\\C:foo\\bar does reach it. And the drive-designator rule's two halves are only separable by a root like 1:, which has the colon in the right place and is still not a drive -- there is deliberately no companion case for a colonless root, because the outer contains(&COLON) guard means one never reaches the check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/error/tests.rs | 198 ++++++++++++++++++ .../src/path/tests.rs | 95 +++++++++ 2 files changed, 293 insertions(+) diff --git a/crates/windows-file-enumeration-sys/src/error/tests.rs b/crates/windows-file-enumeration-sys/src/error/tests.rs index fc760603..0fcee8d2 100644 --- a/crates/windows-file-enumeration-sys/src/error/tests.rs +++ b/crates/windows-file-enumeration-sys/src/error/tests.rs @@ -108,3 +108,201 @@ fn a_native_failure_has_no_nested_source() { let error = EnumerationError::DirectoryOpen(Win32Error::from_code(3)); assert!(error.source().is_none()); } + +// --------------------------------------------------------------------------- +// The description surface. +// +// A mutation sweep replaced every `describe` with `"xyzzy"` and with `""`, and +// nothing failed. The tests above ask only whether the rendered string is +// non-empty, which both a constant and -- for the variants whose text is +// wrapped by an outer `write!` -- the empty string satisfy. +// +// What was missing is the assertion that the variants say *different* things. +// A description exists to tell one failure from another, so collapsing the set +// onto one value is precisely the defect worth catching, and distinctness +// catches every constant substitution at once rather than one string at a time. +// --------------------------------------------------------------------------- + +/// Asserts that a set of descriptions is usable: each non-empty, and no two the +/// same. +/// +/// Both halves are load-bearing and neither implies the other. Non-emptiness +/// alone passes when every variant returns the same constant; distinctness +/// alone passes when the descriptions are distinct but meaningless. +fn assert_descriptions_are_distinct(descriptions: &[(&str, &str)]) { + for (variant, text) in descriptions { + assert!( + !text.is_empty(), + "{variant} has no description, so a reader learns nothing from it" + ); + } + for (index, (variant, text)) in descriptions.iter().enumerate() { + for (other_variant, other_text) in &descriptions[index + 1..] { + assert_ne!( + text, other_text, + "{variant} and {other_variant} describe themselves identically, so the \ + description cannot tell them apart" + ); + } + } +} + +#[test] +fn every_request_failure_describes_itself_distinctly() { + assert_descriptions_are_distinct(&[ + ("EmptyPath", RequestFailure::EmptyPath.describe()), + ("InteriorNul", RequestFailure::InteriorNul.describe()), + ("PathTooLong", RequestFailure::PathTooLong.describe()), + ( + "NotFullyQualified", + RequestFailure::NotFullyQualified.describe(), + ), + ("PathResolution", RequestFailure::PathResolution.describe()), + ( + "BufferCapacityUnrepresentable", + RequestFailure::BufferCapacityUnrepresentable.describe(), + ), + ]); +} + +#[test] +fn every_begin_failure_describes_itself_distinctly() { + assert_descriptions_are_distinct(&[ + ( + "SubmissionRingFull", + BeginFailure::SubmissionRingFull.describe(), + ), + ( + "CompletionRingFull", + BeginFailure::CompletionRingFull.describe(), + ), + ("Abandoned", BeginFailure::Abandoned.describe()), + ("TokenCapture", BeginFailure::TokenCapture.describe()), + ( + "BufferAllocation", + BeginFailure::BufferAllocation.describe(), + ), + ]); +} + +#[test] +fn every_session_failure_describes_itself_distinctly() { + // The two capacity variants are the pair most at risk: they differ by one + // word, and a copy-paste that left both saying "submission" would be + // invisible to a non-emptiness check while sending a reader to the wrong + // ring. + assert_descriptions_are_distinct(&[ + ( + "SubmissionCapacityTooSmall", + SessionFailure::SubmissionCapacityTooSmall.describe(), + ), + ( + "CompletionCapacityTooSmall", + SessionFailure::CompletionCapacityTooSmall.describe(), + ), + ("WorkObject", SessionFailure::WorkObject.describe()), + ]); +} + +#[test] +fn every_predicate_failure_describes_itself_distinctly() { + assert_descriptions_are_distinct(&[ + ( + "EmptyAttributeMask", + PredicateFailure::EmptyAttributeMask.describe(), + ), + ("EmptyNameSet", PredicateFailure::EmptyNameSet.describe()), + ]); +} + +#[test] +fn every_malformed_record_reason_describes_itself_distinctly() { + // `every_malformed_record_reason_describes_itself` above checks the + // *rendered error*, which wraps these in "a native record failed + // validation: {}" -- so it stays non-empty even when `describe` returns + // nothing at all. This checks the descriptions themselves. + assert_descriptions_are_distinct(&[ + ("Alignment", MalformedRecord::Alignment.describe()), + ( + "TruncatedFixedFields", + MalformedRecord::TruncatedFixedFields.describe(), + ), + ( + "NextEntryOffset", + MalformedRecord::NextEntryOffset.describe(), + ), + ("OddNameLength", MalformedRecord::OddNameLength.describe()), + ( + "NameOutOfBounds", + MalformedRecord::NameOutOfBounds.describe(), + ), + ("NegativeSize", MalformedRecord::NegativeSize.describe()), + ]); +} + +#[test] +fn a_malformed_record_error_carries_its_reason_into_the_rendered_text() { + // Binds the wrapper to what it wraps. Without this the outer `write!` could + // drop the description entirely and every remaining assertion would still + // hold, because the prefix alone is non-empty. + for detail in [ + MalformedRecord::Alignment, + MalformedRecord::TruncatedFixedFields, + MalformedRecord::NextEntryOffset, + MalformedRecord::OddNameLength, + MalformedRecord::NameOutOfBounds, + MalformedRecord::NegativeSize, + ] { + let rendered = EnumerationError::MalformedRecord(detail).to_string(); + assert!( + rendered.contains(detail.describe()), + "{detail:?} renders as {rendered:?}, which does not contain its own description" + ); + } +} + +// --------------------------------------------------------------------------- +// Session errors: the source chain. +// --------------------------------------------------------------------------- + +#[test] +fn a_session_failure_without_an_os_error_behind_it_has_neither_source_nor_suffix() { + let error = SessionError::new(SessionFailure::WorkObject); + + assert_eq!(error.failure(), SessionFailure::WorkObject); + assert!(error.os_error().is_none()); + assert!(error.source().is_none()); + assert_eq!( + error.to_string(), + SessionFailure::WorkObject.describe(), + "with nothing behind it the rendering is exactly the description" + ); +} + +#[test] +fn a_session_failure_with_an_os_error_exposes_it_three_ways() { + // Three separate routes to the same underlying error, each of which a + // caller may reasonably use: the typed accessor, the standard `source` + // chain, and the rendered text. A mutation that returned `None` from either + // accessor left the other two intact, so each needs asserting. + let error = SessionError::with_source( + SessionFailure::WorkObject, + io::Error::from_raw_os_error(ERROR_ACCESS_DENIED as i32), + ); + + let os_error = error.os_error().expect("the OS error was supplied"); + assert_eq!(os_error.raw_os_error(), Some(ERROR_ACCESS_DENIED as i32)); + + let source = error.source().expect("the OS error is the source"); + assert_eq!(source.to_string(), os_error.to_string()); + + let rendered = error.to_string(); + assert!( + rendered.starts_with(SessionFailure::WorkObject.describe()), + "{rendered}" + ); + assert!( + rendered.len() > SessionFailure::WorkObject.describe().len(), + "the OS error must be appended rather than dropped: {rendered}" + ); +} diff --git a/crates/windows-file-enumeration-sys/src/path/tests.rs b/crates/windows-file-enumeration-sys/src/path/tests.rs index 44a28cbb..0e5013da 100644 --- a/crates/windows-file-enumeration-sys/src/path/tests.rs +++ b/crates/windows-file-enumeration-sys/src/path/tests.rs @@ -167,3 +167,98 @@ fn a_device_namespace_path_is_resolved_rather_than_kept_verbatim() { let prepared = prepare_str(r"\\.\C:\Windows\..\Windows").expect("resolvable"); assert_eq!(text(&prepared), r"\\.\C:\Windows"); } + +// --------------------------------------------------------------------------- +// Boundaries. +// +// The tests above use comfortably-wrong values -- a 400-character path, a +// relative path eight units past the limit -- which prove the check exists but +// not that it is in the right place. A mutation sweep moved the limit by one in +// both directions and changed `>` to `>=` and `==`, and every one of those +// survived. These pin the exact unit at which the answer changes. +// --------------------------------------------------------------------------- + +/// An absolute path of exactly `units` UTF-16 units, already in normal form so +/// `GetFullPathNameW` returns it unchanged and the resolved length is the input +/// length. +fn absolute_path_of_length(units: usize) -> String { + let prefix = r"C:\"; + format!("{prefix}{}", "a".repeat(units - prefix.len())) +} + +#[test] +fn an_ordinary_path_of_exactly_max_path_content_is_accepted() { + // 259 = MAX_PATH - 1, the longest path that leaves room for the terminator. + // Rejecting this is the off-by-one that a "400 characters is too long" test + // cannot see, and it is the expensive direction: it refuses a path Windows + // would have opened. + let path = absolute_path_of_length(259); + assert_eq!(path.chars().count(), 259); + + let prepared = prepare_str(&path).expect("259 units is within the ordinary limit"); + assert_eq!(text(&prepared), path); +} + +#[test] +fn an_ordinary_path_one_unit_past_max_path_content_is_rejected() { + // 260 counts the terminator, so 260 content units do not fit. + let path = absolute_path_of_length(260); + assert_eq!(path.chars().count(), 260); + + let error = prepare_str(&path).expect_err("260 units leaves no room for the terminator"); + assert_eq!(error.failure(), RequestFailure::PathTooLong); +} + +#[test] +fn the_ordinary_limit_is_one_less_than_max_path() { + // The relationship the two tests above rest on, stated directly so a change + // to the constant fails here with its reason rather than only as a puzzling + // length assertion elsewhere. + assert_eq!(MAX_PATH_CONTENT, MAX_PATH - 1); + assert_eq!(MAX_PATH_CONTENT, 259); +} + +#[test] +fn a_verbatim_drive_relative_path_with_a_separator_is_rejected() { + // `\\?\C:foo` -- the existing case -- has no separator at all, so it is + // refused before the root is ever inspected and never reaches the + // drive-designator check. This form does reach it: the root is `C:foo`, + // which contains a colon but is not a drive. + // + // Without this, the check could report every root as a drive and nothing + // would notice. + let error = prepare_str(r"\\?\C:foo\bar").expect_err("drive-relative, not fully qualified"); + assert_eq!(error.failure(), RequestFailure::NotFullyQualified); +} + +#[test] +fn a_verbatim_root_needs_a_letter_before_its_colon_not_merely_a_colon() { + // Both halves of the drive-designator test are load-bearing, and only a + // root that satisfies one but not the other shows it. `1:` has the colon in + // the right place and is still not a drive, so a check that accepted + // *either* condition would wave it through. + for path in [r"\\?\1:\", r"\\?\1:\dir"] { + let error = prepare_str(path).expect_err("a digit is not a drive letter"); + assert_eq!( + error.failure(), + RequestFailure::NotFullyQualified, + "for {path}" + ); + } + + // Deliberately no companion case for "second unit is not a colon". The + // check is guarded by `root.contains(&COLON)`, so a colonless root -- a + // volume GUID, say -- never reaches it and is accepted on its own terms. + // `1:` is therefore the only shape that satisfies one half of the rule + // while failing the other, which is what makes it the whole test. + let prepared = prepare_str(r"\\?\Ca\dir").expect("a colonless root is not a drive at all"); + assert_eq!(text(&prepared), r"\\?\Ca\dir"); +} + +#[test] +fn a_verbatim_drive_root_is_still_accepted() { + // The positive case for the two tests above, so a check that rejected + // everything would not pass them by being uniformly strict. + let prepared = prepare_str(r"\\?\C:\dir").expect("a drive root is fully qualified"); + assert_eq!(text(&prepared), r"\\?\C:\dir"); +} From 49019f2eb55d9ba22c356425a53ce1e70060ceb3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 01:15:24 -0400 Subject: [PATCH 192/361] test(file-enumeration): close reservation-accounting gaps, and record two survivors that are not gaps The completion ring's can_reserve has two conditions and only one was tested: reservations_never_consume_the_last_slot fills the reservation budget, so nothing filled the data. A full ring now has to refuse a reservation, or one is handed out against a ring with nowhere to put the terminal outcome it promises. Releasing an unused reservation gives back two counts -- room, and one active enumeration -- and a mutation that moved either the wrong way survived, because nothing observed the counts after a release. Both are now asserted, and deliberately through different routes: reserved() reads the first directly, while the second is only visible through is_closed, which is what a receiver uses to decide the stream has ended. A ring left with a phantom active enumeration never reports closure, so the receiver waits forever for an outcome nobody owes it. Two survivors are NOT test gaps and are documented in place rather than papered over with a test written to chase a score. from_filetime's bitwise-or is an equivalent mutant: the shift puts the high word in bits 32..64 and the low word in bits 0..32, so the operands share no set bit and or, xor, and plus agree on every input. No test can distinguish them, and one written to try would assert a property the code does not have. ordinal_equal_ignoring_case's two i32 fallbacks need a slice of more than 2^31 UTF-16 units -- over four gigabytes of name. That is unreachable in practice rather than untested; the branches stay because "cannot happen" and "is handled" are different claims and the second costs two lines. DEFAULT_BUFFER_CAPACITY gets a const assertion rather than a test, which is the stronger form: it fails the build rather than a run somebody chose to make. The mutant 64 + 1024 = 1088 passed every functional test -- still above the minimum, still a legal capacity -- while no longer being a power of two, which is the property the default is chosen for. Verified in both directions: the mutation fails to compile with the assertion present and compiles cleanly without it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/completion_ring/tests.rs | 64 +++++++++++++++++++ .../src/pattern.rs | 6 ++ .../src/request.rs | 32 ++++++++++ .../src/timestamp.rs | 6 ++ 4 files changed, 108 insertions(+) diff --git a/crates/windows-file-enumeration-sys/src/completion_ring/tests.rs b/crates/windows-file-enumeration-sys/src/completion_ring/tests.rs index 42e832cb..3e52ef1e 100644 --- a/crates/windows-file-enumeration-sys/src/completion_ring/tests.rs +++ b/crates/windows-file-enumeration-sys/src/completion_ring/tests.rs @@ -305,3 +305,67 @@ fn is_signalled(handle: BorrowedHandle<'_>) -> bool { let result = unsafe { WaitForSingleObject(handle.as_raw_handle() as HANDLE, 0) }; result == WAIT_OBJECT_0 } + +#[test] +fn a_ring_whose_slots_are_all_occupied_refuses_a_reservation() { + // `can_reserve` has two conditions and the tests above exercise only one of + // them. `reservations_never_consume_the_last_slot` fills the *reservation* + // budget; this fills the *data*, leaving the reservation budget untouched. + // + // Without it the room check could report every ring as having space and + // nothing would fail: a reservation would be handed out against a ring with + // nowhere to put the terminal outcome it promises to deliver. + let ring = ring(4); + let mut sent = 0; + while ring.try_send_entry(entry(1, "full")).is_ok() { + sent += 1; + assert!(sent <= 4, "a capacity-4 ring must stop accepting entries"); + } + + assert!( + !ring.has_data_room(), + "the ring is full, which is the state under test" + ); + assert_eq!(ring.reserved(), 0, "and no reservation is holding it"); + assert!( + ring.reserve_terminal(EnumerationId::from_raw(1)).is_none(), + "a full ring cannot promise to deliver a terminal outcome" + ); +} + +#[test] +fn releasing_an_unused_reservation_gives_back_both_of_the_things_it_took() { + // A reservation takes two counts -- a slot's worth of room, and one active + // enumeration -- so giving it back has to return both. A mutation that + // moved either the wrong way survived every existing test, because nothing + // observed the counts *after* a release. + // + // The two are observed differently on purpose: `reserved()` reads the first + // directly, and the second is only visible through `is_closed`, which is + // what a receiver uses to decide the stream has ended. A ring left with a + // phantom active enumeration never reports closure, so a receiver waits + // forever on an outcome nobody owes it. + // `CompletionRing::new` starts at one session, so this ring already has the + // one that `remove_session` below gives back. + let ring = ring(4); + let slot = ring + .reserve_terminal(EnumerationId::from_raw(1)) + .expect("room"); + assert_eq!(ring.reserved(), 1); + + drop(slot); + + assert_eq!( + ring.reserved(), + 0, + "the reserved room must come back, or the ring shrinks by one slot per \ + abandoned reservation" + ); + ring.remove_session(); + assert!( + ring.is_closed(), + "with its session gone and nothing active, the stream has ended -- a \ + reservation that did not give back its active count would keep this \ + open forever" + ); +} diff --git a/crates/windows-file-enumeration-sys/src/pattern.rs b/crates/windows-file-enumeration-sys/src/pattern.rs index b0dbea4a..61608e62 100644 --- a/crates/windows-file-enumeration-sys/src/pattern.rs +++ b/crates/windows-file-enumeration-sys/src/pattern.rs @@ -198,6 +198,12 @@ fn ordinal_equal_ignoring_case(left: &[u16], right: &[u16]) -> bool { if left.is_empty() { return true; } + // Both fallbacks below survive a mutation run, and neither is a test gap: + // reaching one needs a slice of more than 2^31 UTF-16 units, which is over + // four gigabytes of name. No filesystem produces that and no unit test + // should allocate it, so these are unreachable in practice rather than + // untested. They are kept because "cannot happen" and "is handled" are + // different claims, and the second one costs two lines. let Ok(left_len) = i32::try_from(left.len()) else { // A run this long cannot be a filesystem name; fall back to the exact // comparison rather than truncating the length and comparing a prefix. diff --git a/crates/windows-file-enumeration-sys/src/request.rs b/crates/windows-file-enumeration-sys/src/request.rs index 05fcef92..23f4b4b7 100644 --- a/crates/windows-file-enumeration-sys/src/request.rs +++ b/crates/windows-file-enumeration-sys/src/request.rs @@ -35,6 +35,38 @@ pub const MINIMUM_BUFFER_CAPACITY: usize = 1024; /// to it. pub(crate) const RECORD_ALIGNMENT: usize = 8; +// The relationships these capacities depend on, checked by the compiler rather +// than by a test -- they are facts about constants, so a test could only report +// after the fact, on a build somebody chose to run. +// +// A mutation run replaced `64 * 1024` with `64 + 1024`, and every test passed: +// 1088 is still above the minimum and still a legal capacity, so nothing that +// merely enumerates a directory can tell the difference. What it is *not* is a +// whole number of records' worth of aligned buffer, which is the property the +// default is chosen for. +const _: () = { + assert!( + DEFAULT_BUFFER_CAPACITY.is_power_of_two(), + "the default is sized to whole pages and record alignments; a value that \ + is merely 'big enough' would pass every functional test while making \ + each refill straddle a boundary" + ); + assert!( + DEFAULT_BUFFER_CAPACITY > MINIMUM_BUFFER_CAPACITY, + "the default must leave real headroom over the floor, or the two serve \ + the same purpose and one of them is a lie" + ); + assert!( + MINIMUM_BUFFER_CAPACITY.is_multiple_of(RECORD_ALIGNMENT), + "every capacity is handed to Win32 as a buffer length, and a length that \ + is not a whole number of alignments cannot hold a whole final record" + ); + assert!( + DEFAULT_BUFFER_CAPACITY.is_multiple_of(RECORD_ALIGNMENT), + "as above, for the capacity almost every caller actually uses" + ); +}; + /// One directory to enumerate, with the predicate and bounds that apply to it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct EnumerationRequest { diff --git a/crates/windows-file-enumeration-sys/src/timestamp.rs b/crates/windows-file-enumeration-sys/src/timestamp.rs index 9767fab9..2d3e59d3 100644 --- a/crates/windows-file-enumeration-sys/src/timestamp.rs +++ b/crates/windows-file-enumeration-sys/src/timestamp.rs @@ -49,6 +49,12 @@ impl WindowsFileTimestamp { /// `FILETIME` instead -- not because the crate stores one. #[must_use] pub const fn from_filetime(time: FILETIME) -> Self { + // A mutation run reports `|` here as replaceable by `^`, and it is an + // equivalent mutant rather than a gap: the shift puts the high word in + // bits 32..64 and the low word occupies bits 0..32, so the two operands + // share no set bit and `|`, `^`, and `+` all agree on every input. No + // test can distinguish them, and one written to try would be asserting + // a property the code does not have. let ticks = ((time.dwHighDateTime as u64) << 32) | (time.dwLowDateTime as u64); Self(ticks as i64) } From 9a9163c777beeaee9fd894b578e19fd4643cec17 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 03:38:22 -0400 Subject: [PATCH 193/361] test: close accessor and boundary mutation gaps across three crates A whole-repo mutation sweep found the same two shapes of gap in several crates, so these are fixed together as the kinds of test that were missing rather than one assertion at a time. **Builder accessors were never read back.** Every namespace-request test builds a request with `with_*` and then performs it, and the perform path reads the struct's fields directly -- so nothing distinguished an accessor reporting the truth from one reporting a constant. `desired_access`, `share_mode`, `creation_disposition`, `subtree`, and `filter` all survived replacement by constants. The new tests configure deliberately non-zero, pairwise-distinct values and read each back, and assert that distinctness rather than leaving it to the reader's eye -- these are Win32 constants whose values are not obvious, and a collision between two of them would silently weaken the test into one that cannot tell those accessors apart. The unset case is asserted too, because `OpenFile::new` documents that every parameter starts at "the caller said nothing" rather than at a plausible-looking open. **Path boundaries were tested with comfortably-wrong values.** A 400-character path proves a check exists but not that it sits at the right unit, so moving the limit by one in either direction survived in both crates that carry this near-identical path contract. Exactly 259 units is now asserted accepted and 260 rejected, which pins MAX_PATH_CONTENT and the comparison together. Two path tests also record why their mutants survived: `\\?\C:foo` has no separator, so it is refused before the root is inspected and never reaches is_drive_designator at all, and the drive-designator rule's two halves are only separable by a root like `1:`. There is deliberately no companion case for a colonless root, because the outer contains(&COLON) guard means one never reaches the check. Two survivors are recorded rather than chased. The impersonation crate's `TOKEN_DUPLICATE | TOKEN_QUERY` is an equivalent mutant -- single-bit rights share no set bit, so `|` and `^` agree. It gets a const assertion rather than prose, which makes the equivalence checkable: if a composite right such as TOKEN_READ were ever folded in, `^` would start *clearing* a bit the capture needs, and that failure is invisible at the call site. `RegisteredBuffers::is_empty` cannot return true, and the reason is a platform behaviour worth pinning: nothing in the crate rejects an empty vector, but the kernel refuses the submission with E_INVALIDARG. A new test asserts that refusal, so if a future Windows accepts it the test fails and the accessor becomes reachable. Manufacturing an in-crate struct literal to kill the mutant would instead have asserted a shape the API cannot produce. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/lib.rs | 17 ++++ crates/windows-ioring-sys/src/batch.rs | 12 +++ crates/windows-ioring-sys/src/batch/tests.rs | 42 ++++++++++ .../src/open/tests.rs | 66 +++++++++++++++ .../src/path/tests.rs | 84 +++++++++++++++++++ .../src/watch/tests.rs | 48 +++++++++++ 6 files changed, 269 insertions(+) diff --git a/crates/windows-impersonation-token-sys/src/lib.rs b/crates/windows-impersonation-token-sys/src/lib.rs index 7d1c6b29..def3e480 100644 --- a/crates/windows-impersonation-token-sys/src/lib.rs +++ b/crates/windows-impersonation-token-sys/src/lib.rs @@ -86,6 +86,23 @@ use windows_sys::Win32::System::Threading::{ }; const THREAD_TOKEN_CAPTURE_ACCESS: TOKEN_ACCESS_MASK = TOKEN_DUPLICATE | TOKEN_QUERY; + +// A mutation run reports the `|` above as replaceable by `^`, and it is an +// equivalent mutant rather than a gap: these are single-bit access rights, so +// the operands share no set bit and `|` and `^` agree. No test can distinguish +// them. +// +// The assertion is what makes that argument checkable rather than merely +// stated. If a future access mask were folded in that overlapped -- a composite +// right such as `TOKEN_READ`, which includes `TOKEN_QUERY` -- the equivalence +// would quietly stop holding and `^` would start *clearing* a bit the capture +// needs. That failure is invisible at the call site and would surface as a +// permission error much later, so it is caught here at compile time instead. +const _: () = assert!( + TOKEN_DUPLICATE & TOKEN_QUERY == 0, + "the capture mask combines single-bit rights; overlapping bits would make \ + the combinator load-bearing rather than a spelling choice" +); const PROCESS_TOKEN_CAPTURE_ACCESS: TOKEN_ACCESS_MASK = TOKEN_DUPLICATE; const CAPTURED_TOKEN_ACCESS: TOKEN_ACCESS_MASK = TOKEN_IMPERSONATE; diff --git a/crates/windows-ioring-sys/src/batch.rs b/crates/windows-ioring-sys/src/batch.rs index a8ac20f6..b25b35dd 100644 --- a/crates/windows-ioring-sys/src/batch.rs +++ b/crates/windows-ioring-sys/src/batch.rs @@ -651,6 +651,18 @@ impl RegisteredBuffers { } /// Whether this registration holds no buffers. + /// + /// Always `false` in practice, and a mutation run reports the constant as + /// surviving for that reason rather than for want of a test. Nothing in + /// this crate rejects an empty vector, but the kernel refuses the + /// submission with `E_INVALIDARG`, so a caller never holds an empty + /// registration. `windows_refuses_an_empty_buffer_registration` pins that + /// platform behaviour; if a future Windows accepts it, that test fails and + /// this becomes reachable. + /// + /// Kept because it is half of the `len`/`is_empty` pair every Rust + /// collection surface offers, and because "cannot happen today" is a + /// weaker claim than "cannot happen". #[must_use] pub fn is_empty(&self) -> bool { self.buffers.is_empty() diff --git a/crates/windows-ioring-sys/src/batch/tests.rs b/crates/windows-ioring-sys/src/batch/tests.rs index e5861931..1cfd7fd5 100644 --- a/crates/windows-ioring-sys/src/batch/tests.rs +++ b/crates/windows-ioring-sys/src/batch/tests.rs @@ -488,3 +488,45 @@ fn the_debug_rendering_names_the_registration_and_its_identity() { "the operation's identity must appear: {rendering}" ); } + +#[test] +fn windows_refuses_an_empty_buffer_registration() { + // Written while chasing `RegisteredBuffers::is_empty -> false`, which a + // mutation run reports as surviving. It survives because the state it would + // misreport **cannot be reached**: nothing in this crate rejects an empty + // vector -- `register_buffers` only checks that the count fits a `u32` and + // that the ring has no prior table -- but the kernel refuses the submission + // with `E_INVALIDARG`, so no caller ever holds an empty registration and + // `is_empty` never has occasion to return `true`. + // + // That makes the mutant unreachable rather than untested, and manufacturing + // an in-crate struct literal to kill it would assert a shape the API cannot + // produce. What is worth pinning is the platform behaviour itself, because + // it is undocumented, it is the reason the accessor looks untested, and a + // future version that started accepting empty registrations would change + // which states this crate can be in. + let mut ring = IoRing::new(8, 8).expect("create ring"); + let mut batch = Batch::new(&mut ring); + let pending = batch + .register_buffers(Vec::>::new()) + .expect("this crate queues it; the refusal comes from the kernel"); + let user_data = pending.user_data(); + batch.submit_and_wait(1, 5_000).expect("submit"); + + let completion = ring + .try_pop() + .expect("pop") + .expect("the registration completion is ready"); + assert_eq!(completion.user_data(), user_data); + + let Err(error) = pending + .claim_if(&completion) + .expect("its own completion is accepted") + else { + panic!("Windows accepted an empty buffer registration; is_empty is now reachable"); + }; + assert!( + error.to_string().contains("0x80070057"), + "expected E_INVALIDARG, got {error}" + ); +} diff --git a/crates/windows-namespace-request-sys/src/open/tests.rs b/crates/windows-namespace-request-sys/src/open/tests.rs index 252a5a3d..5d49d3bb 100644 --- a/crates/windows-namespace-request-sys/src/open/tests.rs +++ b/crates/windows-namespace-request-sys/src/open/tests.rs @@ -385,3 +385,69 @@ fn a_copy_duplicates_the_template_rather_than_sharing_the_owner() { copy.perform() .expect("the copy's template outlived the original"); } + +#[test] +fn every_configured_parameter_reads_back_through_its_own_accessor() { + // A mutation run replaced `desired_access`, `share_mode`, and + // `creation_disposition` with constants and nothing failed. The tests above + // build requests with `with_*` and then *open* them, so they exercise the + // fields through Win32 -- which is exactly what cannot distinguish an + // accessor reporting the truth from one reporting a constant, because the + // open path reads the struct's fields directly rather than through them. + // + // Every value here is deliberately non-zero and pairwise distinct. + // `OpenFile::new` starts every parameter at zero, so a test that configured + // a zero -- or reused one value twice -- would be satisfied by + // `-> Default::default()` and by an accessor reading a neighbour's field. + let fixture = Fixture::new("open-accessors"); + let request = request_for(fixture.directory()) + .with_desired_access(FILE_GENERIC_READ) + .with_share_mode(FILE_SHARE_READ) + .with_creation_disposition(OPEN_EXISTING) + .with_flags_and_attributes(FILE_FLAG_BACKUP_SEMANTICS); + + assert_eq!(request.desired_access(), FILE_GENERIC_READ); + assert_eq!(request.share_mode(), FILE_SHARE_READ); + assert_eq!(request.creation_disposition(), OPEN_EXISTING); + assert_eq!(request.flags_and_attributes(), FILE_FLAG_BACKUP_SEMANTICS); + + // The property the four assertions above rest on, stated rather than left + // to the reader's eye: these are Win32 constants and their values are not + // obvious, so a collision between two of them would silently weaken the + // test into one that cannot tell those accessors apart. + let configured = [ + FILE_GENERIC_READ, + FILE_SHARE_READ, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + ]; + for (index, value) in configured.iter().enumerate() { + assert_ne!( + *value, 0, + "a zero is indistinguishable from the unset default" + ); + for other in &configured[index + 1..] { + assert_ne!( + value, other, + "two parameters share a value, so this test cannot tell their \ + accessors apart" + ); + } + } +} + +#[test] +fn an_unset_parameter_reads_back_as_nothing_rather_than_as_a_plausible_open() { + // The contract `OpenFile::new` states: every parameter starts at "the + // caller said nothing" rather than at a plausible-looking open, because a + // plausible default is exactly what a caller cannot see they got. An + // accessor that invented one would hide that from them. + let fixture = Fixture::new("open-unset"); + let request = request_for(fixture.directory()); + + assert_eq!(request.desired_access(), 0); + assert_eq!(request.share_mode(), 0); + assert_eq!(request.creation_disposition(), 0); + assert_eq!(request.flags_and_attributes(), 0); + assert!(request.security().is_none()); +} diff --git a/crates/windows-namespace-request-sys/src/path/tests.rs b/crates/windows-namespace-request-sys/src/path/tests.rs index eec60ded..e8d81f34 100644 --- a/crates/windows-namespace-request-sys/src/path/tests.rs +++ b/crates/windows-namespace-request-sys/src/path/tests.rs @@ -236,3 +236,87 @@ fn an_error_without_an_os_code_renders_only_its_description() { assert_eq!(error.to_string(), PathFailure::EmptyPath.description()); assert!(std::error::Error::source(&error).is_none()); } + +// --------------------------------------------------------------------------- +// Boundaries. +// +// A mutation sweep moved the ordinary path limit by one in both directions and +// changed `>` to `>=` and `==`, and every one of those survived: the tests +// above use comfortably-wrong lengths, which prove a check exists but not that +// it sits at the right unit. +// +// This is the same block, and for the same reason, as the one in +// `windows-file-enumeration-sys`'s path module -- the two crates carry +// near-identical path contracts, and the sweep found the same gap in both. +// --------------------------------------------------------------------------- + +/// An absolute path of exactly `units` UTF-16 units, already in normal form so +/// `GetFullPathNameW` returns it unchanged and the resolved length equals the +/// input length. +fn absolute_path_of_length(units: usize) -> String { + let prefix = r"C:\"; + format!("{prefix}{}", "a".repeat(units - prefix.len())) +} + +#[test] +fn an_ordinary_path_of_exactly_max_path_content_is_accepted() { + // 259 = MAX_PATH - 1, the longest path that leaves room for the terminator. + // Rejecting it is the off-by-one a "much too long" test cannot see, and it + // is the expensive direction: it refuses a path Windows would have opened. + let path = absolute_path_of_length(259); + assert_eq!(path.chars().count(), 259); + + let prepared = prepare_str(&path).expect("259 units is within the ordinary limit"); + assert_eq!(text(&prepared), path); +} + +#[test] +fn an_ordinary_path_one_unit_past_max_path_content_is_rejected() { + let path = absolute_path_of_length(260); + assert_eq!(path.chars().count(), 260); + + let error = prepare_str(&path).expect_err("260 units leaves no room for the terminator"); + assert_eq!(error.failure(), PathFailure::PathTooLong); +} + +#[test] +fn the_ordinary_limit_is_one_less_than_max_path() { + // The relationship the two tests above rest on, stated directly so a change + // to the constant fails here with its reason rather than only as a puzzling + // length assertion elsewhere. + assert_eq!(MAX_PATH_CONTENT, MAX_PATH - 1); + assert_eq!(MAX_PATH_CONTENT, 259); +} + +#[test] +fn a_verbatim_drive_relative_path_with_a_separator_is_rejected() { + // `\\?\C:foo` has no separator at all, so it is refused before the root is + // ever inspected and never reaches the drive-designator check. This form + // does reach it: the root is `C:foo`, which contains a colon but is not a + // drive. Without it, the check could report every root as a drive and + // nothing would notice. + let error = prepare_str(r"\\?\C:foo\bar").expect_err("drive-relative, not fully qualified"); + assert_eq!(error.failure(), PathFailure::NotFullyQualified); +} + +#[test] +fn a_verbatim_root_needs_a_letter_before_its_colon_not_merely_a_colon() { + // Both halves of the drive-designator rule are load-bearing, and only a + // root that satisfies one but not the other separates them. `1:` has the + // colon in the right place and is still not a drive, so a check accepting + // *either* condition would wave it through. + for path in [r"\\?\1:\", r"\\?\1:\dir"] { + let error = prepare_str(path).expect_err("a digit is not a drive letter"); + assert_eq!( + error.failure(), + PathFailure::NotFullyQualified, + "for {path}" + ); + } + + // Deliberately no companion case for "second unit is not a colon": the + // check is guarded by `root.contains(&COLON)`, so a colonless root -- a + // volume GUID, say -- never reaches it and is accepted on its own terms. + let prepared = prepare_str(r"\\?\Ca\dir").expect("a colonless root is not a drive at all"); + assert_eq!(text(&prepared), r"\\?\Ca\dir"); +} diff --git a/crates/windows-namespace-request-sys/src/watch/tests.rs b/crates/windows-namespace-request-sys/src/watch/tests.rs index 1544f131..5f550b9f 100644 --- a/crates/windows-namespace-request-sys/src/watch/tests.rs +++ b/crates/windows-namespace-request-sys/src/watch/tests.rs @@ -289,3 +289,51 @@ fn a_watch_moves_to_another_thread_and_still_signals() { "a notification handle is usable from a thread that did not create it" ); } + +#[test] +fn the_configured_subtree_and_filter_read_back_through_their_accessors() { + // `subtree -> false` and `filter -> Default::default()` both survived a + // mutation run. Every test above builds a watch and then *performs* it, and + // the perform path reads the fields directly -- so nothing distinguished an + // accessor reporting the truth from one reporting a constant. + // + // `WatchDirectory::new` defaults `subtree` to false and the filter to an + // empty set, so both values here are deliberately the opposite: a `true` + // subtree, and a filter with bits set. A test that watched the default + // shape would pass against either constant. + let fixture = Fixture::new("watch-accessors"); + let filter = NotifyFilter::FILE_NAME | NotifyFilter::LAST_WRITE; + let request = watch_for(&fixture, filter).with_subtree(true); + + assert!( + request.subtree(), + "a caller that asked to watch the subtree must be able to see that it did" + ); + assert_eq!(request.filter(), filter); + assert_ne!( + filter, + NotifyFilter::default(), + "the filter under test must differ from the default, or a constant \ + accessor passes" + ); +} + +#[test] +fn an_unconfigured_watch_reads_back_as_the_narrowest_one() { + // The other half: the defaults are what `new` documents rather than what an + // accessor invents, and asserting them is what stops the test above from + // being satisfied by an accessor that always reports the configured shape. + let fixture = Fixture::new("watch-defaults"); + let text = fixture + .directory() + .to_str() + .expect("the fixture path is valid UTF-8"); + let request = + WatchDirectory::new(prepare(&Wtf16String::from(text)).expect("prepare the fixture path")); + + assert!( + !request.subtree(), + "watching a whole tree is the expensive choice and must be asked for" + ); + assert_eq!(request.filter(), NotifyFilter::default()); +} From a07b50c53fda11acc8e664553bed77b6bdba29c1 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 03:43:51 -0400 Subject: [PATCH 194/361] test(namespace-request): close accessor and error-surface mutation gaps The second half of this crate's mutation survivors, all of one shape per block. **Accessors were never read back.** `OpenFileByIdentifier`'s four parameters and `VolumeInformation`'s four fields all survived replacement by constants, for the same reason `OpenFile`'s did: every test builds a request and then performs it, and the perform path reads the struct's fields directly. VolumeInformation needed a different approach, because its values come from a real volume -- so the existing tests cannot assert exact numbers and a constant is as plausible as the truth. It is now built directly with distinct values, which is what tests the wiring rather than the query. Three of its fields are u32, and nothing but distinct values can catch an accessor returning its neighbour; every real query would hide that behind plausible numbers. Each of these tests asserts its own distinctness precondition rather than leaving it to the reader's eye. The values are Win32 constants whose numbers are not obvious, and a collision between two of them would silently weaken the test into one that cannot tell those accessors apart. **The error surface asserted which failure, never what it said.** `PathFailure::description` survived replacement by a constant. Distinctness is the assertion that catches it -- a description exists to tell one failure from another, so collapsing the set is the defect worth catching, and non-emptiness alone would not notice because a constant is non-empty too. The no-source case also now asserts the exact rendering, which is what binds Display to description: without it the formatter could drop the description entirely. SecurityCaptureError's `raw_os_error` survived replacement by None, Some(0), Some(1), and Some(-1), and its `source` by None. Both are now asserted against each other rather than against a literal code: which error Windows reports for a zeroed descriptor is its business, but whatever it is must reach a caller identically through the typed accessor and through the standard source chain -- which rules out every constant the sweep tried, including the plausible ones. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/open_by_id/tests.rs | 78 ++++++++++++++++++- .../src/path/tests.rs | 50 ++++++++++++ .../src/security/tests.rs | 40 ++++++++++ .../src/volume/tests.rs | 50 +++++++++++- 4 files changed, 216 insertions(+), 2 deletions(-) diff --git a/crates/windows-namespace-request-sys/src/open_by_id/tests.rs b/crates/windows-namespace-request-sys/src/open_by_id/tests.rs index b0ddb68a..7e81a81f 100644 --- a/crates/windows-namespace-request-sys/src/open_by_id/tests.rs +++ b/crates/windows-namespace-request-sys/src/open_by_id/tests.rs @@ -18,8 +18,8 @@ use windows_sys::Win32::Storage::FileSystem::{ }; use super::{FileIdentifier, OpenFileByIdentifier}; -use crate::CapturedHandle; use crate::handle::tests::{FILE_CONTENTS, Fixture, handle_allocation}; +use crate::{CapturedHandle, SecurityAttributes}; const AUDITED_SHARE: u32 = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; @@ -275,3 +275,79 @@ fn a_request_performs_the_same_way_on_another_thread() { assert_eq!(length, FILE_CONTENTS.len() as u64); } + +#[test] +fn every_configured_parameter_reads_back_through_its_own_accessor() { + // Four accessors -- `desired_access`, `share_mode`, `security`, and + // `flags_and_attributes` -- all survived replacement by constants in a + // mutation run. Every test above builds a request and then *performs* it, + // and the perform path reads the struct's fields directly, so nothing + // distinguished an accessor that reports the truth from one that does not. + // + // `OpenFileByIdentifier::new` starts every parameter at zero or `None`, so + // the values here are deliberately non-zero and pairwise distinct: a zero + // would be indistinguishable from the default, and a repeated value would + // let one accessor read a neighbour's field undetected. + let _allocating = handle_allocation() + .read() + .expect("the lock is not poisoned"); + let fixture = Fixture::new("byid-accessors"); + let file = fixture.open_file(); + let id = file_id_of(&file); + let hint = open_directory_for_hint(&fixture); + + let request = OpenFileByIdentifier::new( + CapturedHandle::capture(hint.as_handle()).expect("capture the volume hint"), + FileIdentifier::FileId(id), + ) + .with_desired_access(FILE_GENERIC_READ) + .with_share_mode(FILE_SHARE_READ) + .with_flags_and_attributes(FILE_FLAG_BACKUP_SEMANTICS); + + assert_eq!(request.desired_access(), FILE_GENERIC_READ); + assert_eq!(request.share_mode(), FILE_SHARE_READ); + assert_eq!(request.flags_and_attributes(), FILE_FLAG_BACKUP_SEMANTICS); + assert!( + request.security().is_none(), + "nothing was supplied, so nothing must be reported" + ); + + let configured = [ + FILE_GENERIC_READ, + FILE_SHARE_READ, + FILE_FLAG_BACKUP_SEMANTICS, + ]; + for (index, value) in configured.iter().enumerate() { + assert_ne!(*value, 0, "a zero is indistinguishable from the default"); + for other in &configured[index + 1..] { + assert_ne!( + value, other, + "two parameters share a value, so this test cannot tell their \ + accessors apart" + ); + } + } +} + +#[test] +fn supplied_security_attributes_read_back_rather_than_reporting_none() { + // `security -> None` is the one accessor whose default is already `None`, + // so it needs the opposite case: attributes that were supplied must be + // visible to a caller inspecting the request, not only to the open that + // consumes it. + let _allocating = handle_allocation() + .read() + .expect("the lock is not poisoned"); + let fixture = Fixture::new("byid-security"); + let file = fixture.open_file(); + let id = file_id_of(&file); + let hint = open_directory_for_hint(&fixture); + + let request = OpenFileByIdentifier::new( + CapturedHandle::capture(hint.as_handle()).expect("capture the volume hint"), + FileIdentifier::FileId(id), + ) + .with_security(Some(SecurityAttributes::new(None, false))); + + assert!(request.security().is_some()); +} diff --git a/crates/windows-namespace-request-sys/src/path/tests.rs b/crates/windows-namespace-request-sys/src/path/tests.rs index e8d81f34..7538862d 100644 --- a/crates/windows-namespace-request-sys/src/path/tests.rs +++ b/crates/windows-namespace-request-sys/src/path/tests.rs @@ -320,3 +320,53 @@ fn a_verbatim_root_needs_a_letter_before_its_colon_not_merely_a_colon() { let prepared = prepare_str(r"\\?\Ca\dir").expect("a colonless root is not a drive at all"); assert_eq!(text(&prepared), r"\\?\Ca\dir"); } + +#[test] +fn every_path_failure_describes_itself_distinctly() { + // `PathFailure::description -> "xyzzy"` survived: the tests above assert + // which *failure* was reported, never what it says, so a description that + // collapsed every variant onto one string would go unnoticed. + // + // Distinctness is the assertion that matters. A description exists to tell + // one failure from another, so it catches every constant substitution at + // once rather than one string at a time -- and non-emptiness alone would + // not, because a constant is non-empty too. + let cases = [ + ("EmptyPath", PathFailure::EmptyPath), + ("InteriorNul", PathFailure::InteriorNul), + ("PathTooLong", PathFailure::PathTooLong), + ("NotFullyQualified", PathFailure::NotFullyQualified), + ("PathResolution", PathFailure::PathResolution), + ]; + + for (name, failure) in cases { + assert!( + !failure.description().is_empty(), + "{name} has no description, so a reader learns nothing from it" + ); + } + for (index, (name, failure)) in cases.iter().enumerate() { + for (other_name, other) in &cases[index + 1..] { + assert_ne!( + failure.description(), + other.description(), + "{name} and {other_name} describe themselves identically, so the \ + description cannot tell them apart" + ); + } + } +} + +#[test] +fn a_failure_decided_here_carries_no_os_error_and_renders_as_its_description() { + // The half of `PathError` that has no Win32 call behind it. Asserting the + // exact rendering -- rather than merely that it is non-empty -- is what + // binds `Display` to `description`: without it the formatter could drop the + // description entirely and nothing would fail. + let error = prepare_str("").expect_err("an empty path names nothing"); + + assert_eq!(error.failure(), PathFailure::EmptyPath); + assert_eq!(error.raw_os_error(), None); + assert!(std::error::Error::source(&error).is_none()); + assert_eq!(error.to_string(), PathFailure::EmptyPath.description()); +} diff --git a/crates/windows-namespace-request-sys/src/security/tests.rs b/crates/windows-namespace-request-sys/src/security/tests.rs index a23f8f59..7e6f9f02 100644 --- a/crates/windows-namespace-request-sys/src/security/tests.rs +++ b/crates/windows-namespace-request-sys/src/security/tests.rs @@ -509,3 +509,43 @@ fn a_capture_moves_and_shares_across_threads() { assert_eq!(observed, AclState::Populated(1)); } + +#[test] +fn a_capture_failure_exposes_its_os_error_both_ways() { + // `raw_os_error` survived replacement by `None`, `Some(0)`, `Some(1)`, and + // `Some(-1)`, and `source` survived replacement by `None`. The test above + // asserts the failure stage and the rendered message, neither of which + // touches either accessor. + // + // The two routes are asserted against each other rather than against a + // literal code: which error Windows reports for a zeroed descriptor is its + // business, but whatever it is must reach a caller identically through the + // typed accessor and through the standard `source` chain. That also rules + // out every constant the sweep tried, including the plausible-looking ones. + use std::error::Error as _; + + let zeroed = AlignedBuffer::zeroed(size_of::(), SELF_RELATIVE_ALIGNMENT); + + // SAFETY: the buffer outlives the call; its contents are not a valid + // descriptor, which is what makes this fail. + let error = unsafe { SecurityDescriptor::capture(zeroed.as_ptr().cast::()) } + .expect_err("a zeroed descriptor has revision 0 and cannot be valid"); + + let code = error + .raw_os_error() + .expect("this failure came from a Win32 call, so it carries a code"); + assert_ne!( + code, 0, + "a success code would mean the capture had not failed at all" + ); + + let source = error.source().expect("the OS error is the source"); + assert_eq!( + source + .downcast_ref::() + .expect("the source is the io::Error behind the failure") + .raw_os_error(), + Some(code), + "the typed accessor and the source chain must report the same error" + ); +} diff --git a/crates/windows-namespace-request-sys/src/volume/tests.rs b/crates/windows-namespace-request-sys/src/volume/tests.rs index c5af630b..2efcfda9 100644 --- a/crates/windows-namespace-request-sys/src/volume/tests.rs +++ b/crates/windows-namespace-request-sys/src/volume/tests.rs @@ -5,9 +5,10 @@ use std::fs::File; use std::os::windows::io::{AsHandle, AsRawHandle}; -use super::QueryVolumeInformation; +use super::{QueryVolumeInformation, VolumeInformation}; use crate::CapturedHandle; use crate::handle::tests::{Fixture, handle_allocation}; +use wtf_string::Wtf16String; fn request_for(file: &File) -> QueryVolumeInformation { QueryVolumeInformation::new( @@ -189,3 +190,50 @@ fn a_query_performs_the_same_way_on_another_thread() { assert_eq!(observed, expected); } + +#[test] +fn each_accessor_reports_its_own_field() { + // Four accessors -- `label`, `serial_number`, `maximum_component_length`, + // and `flags` -- all survived replacement by constants in a mutation run. + // The tests above query a *real* volume, so they cannot assert exact + // values: whatever this machine reports is what they get, and a constant is + // as plausible as the truth. + // + // Built directly rather than queried, because what is under test is the + // wiring between four same-typed fields and their accessors, not the query. + // Three of them are `u32` and nothing but distinct values can tell a + // transposition apart -- the failure this catches is an accessor returning + // its neighbour, which every real query would hide behind plausible numbers. + let information = VolumeInformation { + label: Wtf16String::from("LABEL"), + serial_number: 0x1111_1111, + maximum_component_length: 0x2222_2222, + flags: 0x3333_3333, + filesystem_name: Wtf16String::from("NTFS"), + }; + + assert_eq!(information.label().to_string_lossy(), "LABEL"); + assert_eq!(information.serial_number(), 0x1111_1111); + assert_eq!(information.maximum_component_length(), 0x2222_2222); + assert_eq!(information.flags(), 0x3333_3333); + assert_eq!(information.filesystem_name().to_string_lossy(), "NTFS"); + + // The property the three numeric assertions rest on. Stated rather than + // eyeballed, so a later edit that reused a value would fail here instead of + // silently weakening the test into one that cannot detect a transposition. + let numeric = [ + information.serial_number(), + information.maximum_component_length(), + information.flags(), + ]; + for (index, value) in numeric.iter().enumerate() { + for other in &numeric[index + 1..] { + assert_ne!(value, other, "two numeric fields share a value"); + } + } + assert_ne!( + information.label(), + information.filesystem_name(), + "the two string fields must differ, or one accessor could serve both" + ); +} From c16845c67a1d5653c5940c4d4c6c2441a0216f97 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 03:47:16 -0400 Subject: [PATCH 195/361] test(guard-alloc): split seed parsing from the environment read so it can be tested A mutation run reported the 0x prefix arm, the truncation guard, and both of its comparisons as surviving in seed_from_environment. Every one of those was correct, and none of them was a missing assertion: the function reads a process-global environment variable, and this workspace runs tests as threads in one process, so a test that set one would be visible to every other test. The branches were unreachable, not untested. parse_seed is now separate. Everything interesting about a seed lives there -- the prefix, the radix that follows from it, and the overflow that must refuse rather than wrap -- and the impure half that remains is a single Win32 call with no branch of its own. Six tests cover it. The hex cases use values where the two radixes disagree, which is what makes a deleted prefix arm visible; the overflow cases assert refusal at exactly u64::MAX and one past it, because wrapping would accept a value and then use a different one, which is the worst of the three outcomes -- reproducible-looking and wrong. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-guard-alloc/src/lib.rs | 22 +++++++- crates/windows-guard-alloc/src/tests.rs | 71 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/crates/windows-guard-alloc/src/lib.rs b/crates/windows-guard-alloc/src/lib.rs index 3042dd64..44b31e9c 100644 --- a/crates/windows-guard-alloc/src/lib.rs +++ b/crates/windows-guard-alloc/src/lib.rs @@ -125,11 +125,31 @@ fn seed_from_environment() -> Option { return None; } + parse_seed(&value[..written as usize]) +} + +/// Parse a seed from the environment variable's text. +/// +/// # Why this is separate from reading the variable +/// +/// Everything interesting about a seed is here -- the `0x` prefix, the radix +/// that follows from it, and the overflow that must refuse rather than wrap -- +/// and none of it can be exercised through [`seed_from_environment`], which +/// reads a *process-global* variable. Setting one from a test would be visible +/// to every other test in the process, because this workspace runs tests as +/// threads rather than processes, so such a test could not be written safely +/// even once. +/// +/// A mutation run made that concrete: the prefix arm, the truncation check, and +/// both comparisons in the guard above all survived, and not one of them could +/// have been reached from a test. Splitting the pure half out is what makes +/// them reachable; the impure half that remains is a single Win32 call with no +/// branch of its own. +fn parse_seed(digits: &[u16]) -> Option { const ZERO: u16 = b'0' as u16; const LOWER_X: u16 = b'x' as u16; const UPPER_X: u16 = b'X' as u16; - let digits = &value[..written as usize]; let (digits, radix) = match digits { [ZERO, LOWER_X | UPPER_X, rest @ ..] => (rest, 16), _ => (digits, 10), diff --git a/crates/windows-guard-alloc/src/tests.rs b/crates/windows-guard-alloc/src/tests.rs index af3936f4..311ffa1c 100644 --- a/crates/windows-guard-alloc/src/tests.rs +++ b/crates/windows-guard-alloc/src/tests.rs @@ -379,3 +379,74 @@ fn alloc_zeroed_still_returns_zeros_despite_the_poison() { // SAFETY: `ptr` came from this allocator with this layout. unsafe { alloc.dealloc(ptr, layout) }; } + +// --------------------------------------------------------------------------- +// Seed parsing. +// +// None of this was reachable before `parse_seed` was split out of +// `seed_from_environment`: the seed came from a process-global environment +// variable, and this workspace runs tests as threads in one process, so setting +// it from a test would be visible to every other test. A mutation run reported +// the prefix arm, the truncation guard, and both of its comparisons as +// surviving -- all of them correctly, because no test could reach them. +// --------------------------------------------------------------------------- + +/// The wide form the environment gives us. +fn units(text: &str) -> Vec { + text.encode_utf16().collect() +} + +#[test] +fn a_decimal_seed_parses_in_base_ten() { + assert_eq!(super::parse_seed(&units("1234")), Some(1234)); + // Leading zeros are digits, not a prefix: `0755` is seven hundred and + // fifty-five here, not an octal escape. + assert_eq!(super::parse_seed(&units("0755")), Some(755)); +} + +#[test] +fn a_prefixed_seed_parses_in_base_sixteen_in_either_case() { + // The `0x` arm is what a mutation run deleted, and deleting it does not + // fail loudly: `0x10` then parses as base ten, and `0` and `x` are both + // rejected... except that `0x1` would silently become an error rather than + // 1. The values here are chosen so the two radixes disagree, which is what + // makes the arm's absence visible. + assert_eq!(super::parse_seed(&units("0x10")), Some(16)); + assert_eq!(super::parse_seed(&units("0X10")), Some(16)); + assert_eq!(super::parse_seed(&units("0xff")), Some(255)); + assert_eq!(super::parse_seed(&units("0xFF")), Some(255)); +} + +#[test] +fn a_prefix_with_no_digits_after_it_is_not_a_seed() { + // `0x` alone leaves an empty digit run. Accepting it would seed the whole + // process with zero while the caller believed they had asked for something. + assert_eq!(super::parse_seed(&units("0x")), None); + assert_eq!(super::parse_seed(&units("0X")), None); + assert_eq!(super::parse_seed(&units("")), None); +} + +#[test] +fn a_seed_that_is_not_a_number_is_refused_rather_than_partially_read() { + // Refusing beats guessing: a seed silently truncated at the first bad + // character would produce a run whose poison pattern nobody could + // reproduce from the value they set. + assert_eq!(super::parse_seed(&units("12x4")), None); + assert_eq!(super::parse_seed(&units("nonsense")), None); + // A hex digit is not a decimal one, which is the same rule read the other + // way round. + assert_eq!(super::parse_seed(&units("ff")), None); + // ...and `0x` makes it one. + assert_eq!(super::parse_seed(&units("0xff")), Some(255)); +} + +#[test] +fn a_seed_too_large_for_a_u64_is_refused_rather_than_wrapped() { + // `checked_mul`/`checked_add` are what make this a refusal. Wrapping would + // accept a value and then use a *different* one, which is the worst of the + // three outcomes: reproducible-looking and wrong. + assert_eq!(super::parse_seed(&units("18446744073709551615")), Some(u64::MAX)); + assert_eq!(super::parse_seed(&units("18446744073709551616")), None); + assert_eq!(super::parse_seed(&units("0xFFFFFFFFFFFFFFFF")), Some(u64::MAX)); + assert_eq!(super::parse_seed(&units("0x1FFFFFFFFFFFFFFFF")), None); +} From b791418cfea0101f4556c9e804c2b2014703b998 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 03:49:50 -0400 Subject: [PATCH 196/361] test(guard-alloc): pin identify's ordinal bound, and record two equivalent loop mutants identify's bound survived being loosened to <=, which would accept an ordinal equal to the number of allocations made so far -- one that has not happened yet. That direction is the dangerous one: identify is what distinguishes our poison from bytes that merely decode, every u64 decodes to some ordinal, so the bound is the entire test. Accepting one too many turns a use-after-free report into a false positive on unrelated memory. Both edges are now asserted, including the max(1) floor that lets the very first allocation identify while the count still reads zero. The two loop bounds in mul_inverse and unxor_shift_right are equivalent mutants and are documented rather than chased. Newton's iteration doubles the correct bits each round, so five covers all 64, and the step is idempotent once exact: a sixth round recomputes the same value. unxor_shift_right is the same shape -- an extra round xors in a term that cancels. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-guard-alloc/src/poison.rs | 15 ++++++ .../windows-guard-alloc/src/poison/tests.rs | 46 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/crates/windows-guard-alloc/src/poison.rs b/crates/windows-guard-alloc/src/poison.rs index 1904fb43..e49d7a98 100644 --- a/crates/windows-guard-alloc/src/poison.rs +++ b/crates/windows-guard-alloc/src/poison.rs @@ -63,6 +63,16 @@ const fn mul_inverse(x: u64) -> u64 { assert!(x % 2 == 1, "only odd values are invertible modulo 2^64"); let mut inverse = x; // correct to three bits let mut round = 0; + // Five rounds, and a mutation run reports `<= 5` as surviving because it is + // an equivalent mutant rather than a gap. Newton's iteration doubles the + // correct bits each round -- 3, 6, 12, 24, 48, 96 -- so five is the first + // count that covers all 64, and the step is idempotent once exact: + // `inverse * (2 - x * inverse)` is `inverse * (2 - 1)`. A sixth round + // returns the same value, so no test can distinguish the two bounds. + // + // Four rounds would *not* be equivalent, and + // `the_multiplicative_inverses_are_actually_inverses` is what catches that + // direction. while round < 5 { inverse = inverse.wrapping_mul(2_u64.wrapping_sub(x.wrapping_mul(inverse))); round += 1; @@ -84,6 +94,11 @@ const MIX_B_INV: u64 = mul_inverse(MIX_B); const fn unxor_shift_right(y: u64, shift: u32) -> u64 { let mut recovered = y; let mut resolved = shift; + // `<= 64` survives a mutation run for the same reason `mul_inverse`'s bound + // does: the step is idempotent once exact. With `recovered == x`, another + // round computes `y ^ (x >> shift)`, which is `x ^ (x >> shift) ^ + // (x >> shift)` -- that is, `x` again. An extra round cannot change the + // answer, so the two bounds are indistinguishable. while resolved < 64 { recovered = y ^ (recovered >> shift); resolved += shift; diff --git a/crates/windows-guard-alloc/src/poison/tests.rs b/crates/windows-guard-alloc/src/poison/tests.rs index 74759b26..358ac23a 100644 --- a/crates/windows-guard-alloc/src/poison/tests.rs +++ b/crates/windows-guard-alloc/src/poison/tests.rs @@ -201,3 +201,49 @@ fn first_mismatch_honours_a_non_zero_offset() { "the wrong phase must not verify clean, or the offset argument does nothing" ); } + +#[test] +fn an_ordinal_at_the_allocation_count_is_not_yet_plausible() { + // `identify`'s bound survived being loosened to `<=`, which would accept an + // ordinal exactly equal to the number of allocations made so far -- one + // that has not happened yet. + // + // That direction is the dangerous one. `identify` is what distinguishes + // "these bytes are our poison" from "these bytes are something else that + // happened to decode"; every `u64` decodes to *some* ordinal, so the bound + // is the entire test. Accepting one allocation too many turns a + // use-after-free report into a false positive on unrelated memory. + let seed = 0x0123_4567_89AB_CDEF; + + // Ordinals are zero-based, so after `n` allocations the largest that exists + // is `n - 1` and `n` is the first that does not. + for total in [1_u64, 2, 7, 64] { + let last = total - 1; + assert_eq!( + identify(seed, word(seed, last), total), + Some(last), + "the most recent allocation must still identify" + ); + assert_eq!( + identify(seed, word(seed, total), total), + None, + "an ordinal equal to the count names an allocation that has not \ + happened, so it must not be read as ours" + ); + } +} + +#[test] +fn the_first_allocation_identifies_before_the_count_has_caught_up() { + // The `max(1)` in the bound, which exists for exactly this moment: the + // check runs while the very first allocation is being made, when the count + // may still read zero. Without the floor, ordinal 0 would fail `0 < 0` and + // the allocator's own first poison would be unidentifiable. + let seed = 42; + assert_eq!(identify(seed, word(seed, 0), 0), Some(0)); + assert_eq!( + identify(seed, word(seed, 1), 0), + None, + "the floor admits exactly one ordinal, not every ordinal" + ); +} From 40bc19db6b9635c1fe1ae495740919feb7892e6e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 08:36:58 -0400 Subject: [PATCH 197/361] test(waitable-queues): pin the usize clamp that only matters on a 32-bit target The one genuine survivor of this crate's mutation sweep, and it hid in the gap between the two word sizes. BOUNDS_MAX clamps its packed limit against the widest power of two a usize holds with the top bit clear. A mutation run replaced that expression's - 2 with / 2, and nothing failed. On a 64-bit target the clamp is not the branch taken -- packed is 2^31, under both the real 2^62 and the mutant's 2^32 -- so no test could have caught it. On a 32-bit target it IS the branch taken, and the mutant sets this shape's maximum capacity to 65,536 instead of 2^30: a factor of 16,384, and it passes every one of the six assertions already guarding this block, because 65,536 is still a power of two, still under the crate-wide ceiling, and still above the minimum. The expression is now a named constant so it can be asserted, and the assertion is stated as a bit position rather than as an arithmetic identity -- the identity would be tautological, which is the mistake an earlier version of this block already made and documented. Verified in both directions on both word sizes: the mutant fails the build on x86_64 and on i686, and the real value compiles cleanly on both. The crate's three other survivors are equivalent mutants already documented at their sites: record_depth's idempotent fetch_max, claim_word's disjoint word halves, and slotwise push's dead branch after the negative case returns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/reserving_mpsc.rs | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 6a673ebe..d9b6fd6c 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -136,14 +136,24 @@ const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; /// capacity it could actually use. pub const BOUNDS_MAX: usize = { let packed = 1_usize << (POSITION_BITS - 1); - let widest_usize_power_of_two = 1_usize << (usize::BITS - 2); - if packed <= widest_usize_power_of_two { + if packed <= WIDEST_USIZE_POWER_OF_TWO { packed } else { - widest_usize_power_of_two + WIDEST_USIZE_POWER_OF_TWO } }; +/// The largest power of two a `usize` holds with the top bit still clear. +/// +/// Named rather than inlined so the assertion below can reach it. A mutation +/// run replaced its `- 2` with `/ 2` and nothing failed: on a 64-bit target the +/// clamp is not the branch taken -- `packed` is 2^31, which is under both 2^62 +/// and the mutant's 2^32 -- so the wrong value is selected by neither. On a +/// 32-bit target it *is* the branch taken, and the mutant would have set this +/// shape's maximum capacity to 65,536 instead of 2^30, a factor of 16,384, +/// without failing a single one of the assertions below. +const WIDEST_USIZE_POWER_OF_TWO: usize = 1_usize << (usize::BITS - 2); + /// The capacities this shape accepts. See [`BOUNDS_MAX`]. const BOUNDS: Bounds = Bounds { min: 2, @@ -197,6 +207,14 @@ const _: () = { "a shape that accepts nothing would reject every capacity with a suggestion it would also \ reject" ); + assert!( + WIDEST_USIZE_POWER_OF_TWO.leading_zeros() == 1, + "the clamp must be the widest power of two that leaves the top bit clear, on every target. \ + Stated as a bit position rather than as an arithmetic identity because the identity is \ + tautological -- and because the value only *matters* on a 32-bit target, where this shape \ + is not the one built by default, so an error here would otherwise reach a caller before it \ + reached a build" + ); }; /// Reads the position out of a claim word. From 47dfa180eb851e8ecc59ea90f0a29ff5f9fbda40 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 08:41:03 -0400 Subject: [PATCH 198/361] test(placement-probe): pin the plan's arithmetic and the median's rates Two blocks of survivors, and in both cases the existing tests had the right shape but a fixture that could not separate the operators. `every_timed_handoff_the_run_performs_is_in_the_plan` ties the count to the loops rather than to a hand-derived constant, which is the correct instinct -- but it runs on a fixture with two hops at two placements each, where the product and the sum are both four. Replacing that multiplication with an addition therefore changed nothing it could see. The new plan uses five hops at two placements, and asserts its own precondition: if a later edit made two operators agree again, that assertion fails rather than the test silently weakening. `a_longer_run_is_never_promised_as_shorter` compares successive machines with `>=` from a zero floor, which any constant satisfies -- so `estimated_seconds -> 0.0` and `-> 1.0` both survived. The estimate is now tied to `timed_runs`, and doubling the repetitions must double the promise; the empty plan must promise no time at all, which is what stops that from being satisfied by something that merely scales. The median's arithmetic was never exercised at all, though it takes its timer as a closure and has always been reachable offline. Every division in it -- the middle index, nanoseconds per item, items per second -- survived replacement by a multiplication or a remainder, because with real timings the expected value is whatever the machine did and `nanos / ITEMS` is no more plausible than `nanos * ITEMS`. A fake timer fixes that. The samples arrive in an order where the first, last, and middle values all differ and the arrival order differs from the sorted order, so a median that forgot to sort or indexed either end picks a different answer. The discarded warm-up pass is pinned with an absurd first value that must not move the result. And the two rates are asserted against each other -- their product is one billion whatever the machine did, so that holds without restating either formula. Strategy's `label` and `name` are asserted distinct across variants and distinct from each other, which is the point of their being separate: `name` keys a stored record and `label` is prose for a table, so collapsing them would let a reworded table silently rekey every collected measurement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core_affinity/tests.rs | 101 ++++++++++++ .../src/peer_index_cache/tests.rs | 146 ++++++++++++++++++ 2 files changed, 247 insertions(+) diff --git a/crates/windows-placement-probe/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs index 076446c5..0a981881 100644 --- a/crates/windows-placement-probe/src/core_affinity/tests.rs +++ b/crates/windows-placement-probe/src/core_affinity/tests.rs @@ -8,6 +8,7 @@ //! mislabel a pair, because every conclusion it prints is keyed on that label. use super::{Placement, RunPlan, classify, memory_placements, node_pairs, representative_pairs}; +use crate::peer_index_cache::ITEMS; use windows_topology_sys::Topology; use crate::fingerprint::ProcessorPlace; @@ -1156,3 +1157,103 @@ fn a_node_pair_lookup_distinguishes_rows_that_share_a_requested_node() { assert!((baseline.nanos_per_item - 30.0).abs() < f64::EPSILON); assert!((cached.nanos_per_item - 40.0).abs() < f64::EPSILON); } + +// --------------------------------------------------------------------------- +// The plan's arithmetic. +// +// `every_timed_handoff_the_run_performs_is_in_the_plan` ties the count to the +// loops, which is the right shape -- but it recomputes it on a fixture whose +// numbers happen to make the operators agree, so a mutation run replaced the +// hop multiplication with an addition and nothing failed. And +// `a_longer_run_is_never_promised_as_shorter` compares with `>=` starting from +// zero, which a constant estimate satisfies trivially. +// +// These use numbers chosen so that no two operators agree. +// --------------------------------------------------------------------------- + +/// A plan whose six fields are distinct and mutually non-commuting, so that +/// replacing any operator with another changes the answer. +fn distinguishing_plan() -> RunPlan { + RunPlan { + placements: 3, + node_hops: 5, + memory_placements_per_hop: 2, + classes: 7, + strategies: 2, + repetitions: 3, + } +} + +#[test] +fn the_handoff_count_multiplies_the_hops_by_their_placements() { + // 5 hops at 2 placements each is 10, not 7. The fixture the existing test + // uses has two hops and two placements, where the product and the sum are + // both 4 -- which is exactly why the mutant survived it. + let plan = distinguishing_plan(); + + let selections = + plan.placements + plan.classes + plan.node_hops * plan.memory_placements_per_hop; + assert_eq!(selections, 3 + 7 + 10, "the fixture must separate * from +"); + assert_eq!( + plan.timed_runs(), + selections * plan.strategies * plan.repetitions + ); + assert_eq!(plan.timed_runs(), 20 * 2 * 3); + + // Stated so a later edit to the fixture that made two operators agree again + // would fail here rather than silently weakening the test above. + assert_ne!( + plan.node_hops * plan.memory_placements_per_hop, + plan.node_hops + plan.memory_placements_per_hop, + "the hop fields must not make the product and the sum coincide" + ); + assert_ne!( + plan.strategies * plan.repetitions, + plan.strategies + plan.repetitions, + "nor the strategy and repetition fields" + ); +} + +#[test] +fn the_estimate_is_proportional_to_the_work_rather_than_a_constant() { + // `estimated_seconds -> 0.0` and `-> 1.0` both survived, because the only + // test of it compares successive machines with `>=` from a zero floor -- + // which any constant satisfies. Tying it to `timed_runs` is what makes it a + // measurement of the plan rather than a number beside it. + let plan = distinguishing_plan(); + + let seconds = plan.estimated_seconds(); + assert!(seconds > 0.0, "a plan with work in it takes time"); + assert!( + (seconds - (plan.timed_runs() * ITEMS) as f64 * 220e-9).abs() < f64::EPSILON, + "the estimate must be the run's own item count at the measured rate, \ + not an independent number that happens to look plausible: {seconds}" + ); + + // Doubling the repetitions doubles the work, so it must double the promise. + // A constant estimate fails here whatever constant it chose. + let mut doubled = plan; + doubled.repetitions = plan.repetitions * 2; + assert!( + (doubled.estimated_seconds() - seconds * 2.0).abs() < 1e-9, + "twice the work must be promised as twice the time" + ); +} + +#[test] +fn an_empty_plan_promises_no_time_at_all() { + // The other end, which is what stops the test above from being satisfied by + // an estimate that merely scales *something*. A machine that can express no + // measurement is not a machine that takes 220 nanoseconds to measure it. + let empty = RunPlan { + placements: 0, + node_hops: 0, + memory_placements_per_hop: 0, + classes: 0, + strategies: 2, + repetitions: 3, + }; + + assert_eq!(empty.timed_runs(), 0); + assert!((empty.estimated_seconds() - 0.0).abs() < f64::EPSILON); +} diff --git a/crates/windows-placement-probe/src/peer_index_cache/tests.rs b/crates/windows-placement-probe/src/peer_index_cache/tests.rs index fc66bfb7..46c511ac 100644 --- a/crates/windows-placement-probe/src/peer_index_cache/tests.rs +++ b/crates/windows-placement-probe/src/peer_index_cache/tests.rs @@ -362,3 +362,149 @@ fn a_failed_consumer_pin_stops_the_run_rather_than_hanging_it() { started.elapsed() ); } + +// --------------------------------------------------------------------------- +// The median, and the rates derived from it. +// +// `median` takes its timer as a closure, so all of this is reachable offline -- +// but nothing exercised it, and a mutation run replaced every division in it +// with a multiplication or a remainder without a single failure. +// +// A fake timer is what makes the arithmetic checkable: with real timings the +// expected value is whatever the machine did, so `nanos / ITEMS` and +// `nanos * ITEMS` are equally plausible. +// --------------------------------------------------------------------------- + +/// A sample carrying only the fields the median's arithmetic reads. +fn sample_of(nanos: f64) -> super::Sample { + super::Sample { + memory_node: None, + nanos, + consumer_refreshes: 0, + producer_refreshes: 0, + } +} + +#[test] +fn the_median_is_the_middle_sample_not_the_first_or_the_last() { + // Deliberately fed in an order where the first, last, and middle values are + // all different, and where the *arrival* order differs from the sorted + // order -- so a median that forgot to sort, or that indexed either end, + // picks a different answer than the one asserted. + // + // The untimed warm-up pass is why there are six values for five + // repetitions: the first is consumed and must not reach the result. + let mut supplied = [900.0, 500.0, 100.0, 400.0, 300.0, 200.0].into_iter(); + let run = super::median("under test", || { + sample_of( + supplied + .next() + .expect("the median takes REPETITIONS + 1 samples"), + ) + }); + + // Timed samples are 500, 100, 400, 300, 200 -> sorted 100, 200, 300, 400, + // 500 -> median 300. + let median_nanos = 300.0; + assert!( + (run.nanos_per_item - median_nanos / super::ITEMS as f64).abs() < f64::EPSILON, + "expected the middle sample, got {} ns/item", + run.nanos_per_item + ); +} + +#[test] +fn the_warm_up_pass_is_discarded_rather_than_measured() { + // The untimed first call exists because a fresh allocation's first touch + // faults pages in, which belongs to the allocator rather than to the ring. + // If it were counted, the median of six values would be taken over a set + // including one wildly slow outlier. + // + // An absurd first value makes that visible: it must not move the answer. + let mut supplied = [1e12, 100.0, 200.0, 300.0, 400.0, 500.0].into_iter(); + let run = super::median("under test", || { + sample_of(supplied.next().expect("six samples")) + }); + + assert!( + (run.nanos_per_item - 300.0 / super::ITEMS as f64).abs() < f64::EPSILON, + "the discarded warm-up leaked into the median: {} ns/item", + run.nanos_per_item + ); +} + +#[test] +fn the_two_rates_are_reciprocal_views_of_the_same_sample() { + // `nanos_per_item` divides and `items_per_second` divides the other way + // round; a mutation that multiplied either would still produce a number, + // and one that looks entirely plausible in a report. + // + // Asserting the relationship between them is what catches that: they are + // two views of one duration, so their product is fixed no matter what the + // machine did. + let nanos = 4_000_000.0; + let mut supplied = std::iter::repeat_n(nanos, super::REPETITIONS + 1); + let run = super::median("under test", || { + sample_of(supplied.next().expect("enough samples")) + }); + + assert!( + (run.nanos_per_item - nanos / super::ITEMS as f64).abs() < f64::EPSILON, + "ns/item must be the sample divided by the item count: {}", + run.nanos_per_item + ); + assert!( + (run.items_per_second - super::ITEMS as f64 / (nanos / 1e9)).abs() < 1e-6, + "items/s must be the item count divided by the sample in seconds: {}", + run.items_per_second + ); + // One item per nanosecond is one billion items per second, whatever the + // constants are -- so this holds without restating either formula. + assert!( + (run.nanos_per_item * run.items_per_second - 1e9).abs() < 1e-3, + "the two rates must describe the same duration: {} and {}", + run.nanos_per_item, + run.items_per_second + ); +} + +#[test] +fn every_strategy_names_and_labels_itself_distinctly() { + // Both `label` and `name` survived replacement by a constant. Nothing + // asserted either, and distinctness is what catches every constant at once. + // + // The two are also asserted to differ from *each other*, which is the point + // of their being separate: `name` is a token a stored record is keyed on and + // `label` is prose for a terminal table, so a refactor that collapsed them + // would let a reworded table silently rekey every collected record. + let strategies = [ + ("Baseline", super::Strategy::Baseline), + ("Cached", super::Strategy::Cached), + ("Warmed", super::Strategy::Warmed), + ]; + + for (variant, strategy) in strategies { + assert!(!strategy.label().is_empty(), "{variant} has no label"); + assert!(!strategy.name().is_empty(), "{variant} has no name"); + assert_ne!( + strategy.label(), + strategy.name(), + "{variant}'s prose label and its record key must stay separable" + ); + } + for (index, (variant, strategy)) in strategies.iter().enumerate() { + for (other_variant, other) in &strategies[index + 1..] { + assert_ne!( + strategy.label(), + other.label(), + "{variant} and {other_variant} share a label" + ); + assert_ne!( + strategy.name(), + other.name(), + "{variant} and {other_variant} share a record key, so a collector \ + would pool two different measurements" + ); + } + } +} From e198b8a0943bd1ee5ac5bc4188161388c3c2bd0f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 08:45:42 -0400 Subject: [PATCH 199/361] test(topology): exercise the deserializer's refusal paths, and record two equivalent mutants Every serde test here round-trips a value this crate serialized, so the deserializer only ever saw well-formed input and its refusal paths were never taken. Two survivors followed from that. `as_bool` survived replacement by a constant `true` because every core-domain fixture set `simultaneous_multithreading: true` -- with only that value in the suite, a deserializer that ignored its input was indistinguishable from one that read it. Both directions are now covered: a `false` flag must survive the round trip as false, and a string, a number, and a null must each be refused. The cache-type object's `map.len() == 1` guard survived being replaced with `true`, which would accept an object with extra keys and silently ignore them. That matters because the object form exists to carry a raw PROCESSOR_CACHE_TYPE this crate does not recognise -- so a reader sending a *newer* shape must be told it was not understood rather than have the parts we recognise quietly taken. Both new refusal tests assert *why* the input was refused rather than only that it was, and both are built from a fixture whose well-formed version is asserted to parse first. That is not belt-and-braces: the first drafts of both used ad-hoc JSON literals with a malformed `processors` field, so they were refused for an unrelated reason and would have passed while testing nothing about the guard under examination. The accepted-case tests exist for the same reason, so a guard that refused everything could not satisfy them. Two of this crate's five survivors are equivalent mutants and are not chased. `ProcessorSet::empty -> Default::default()` replaces the body with the code that is already there. Deleting `as_u64`'s SignedInteger arm changes only which arm produces the error, not whether one is produced: both emit the same message, and the only input that would tell them apart -- a non-negative value arriving as a signed integer -- is unreachable through serde_json, which routes non-negative numbers to visit_u64. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-topology-sys/src/domain/tests.rs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/windows-topology-sys/src/domain/tests.rs b/crates/windows-topology-sys/src/domain/tests.rs index 410d0f9a..cb971e70 100644 --- a/crates/windows-topology-sys/src/domain/tests.rs +++ b/crates/windows-topology-sys/src/domain/tests.rs @@ -541,4 +541,109 @@ mod serde_tests { "a float below i64::MIN must be refused, not saturated" ); } + // ----------------------------------------------------------------------- + // Rejection. + // + // Every test above round-trips a value this crate serialized, so the + // deserializer only ever sees well-formed input and its refusal paths are + // never taken. A mutation run replaced `as_bool` with a constant `true` and + // loosened the cache-object guard, and neither could fail against input + // that was already valid. + // + // These start from hand-written JSON instead. + // ----------------------------------------------------------------------- + + #[test] + fn a_false_flag_survives_the_round_trip_as_false() { + // `as_bool -> Ok(true)` survived because every serde test above used + // `simultaneous_multithreading: true`. With only that value in the + // suite, a deserializer that ignored its input and always answered + // `true` was indistinguishable from one that read it. + let domain = Domain { + kind: DomainKind::Core { + simultaneous_multithreading: false, + efficiency_class: 0, + }, + id: 1, + processors: ProcessorSet::from_group_mask(0, 0b1), + }; + + let restored = round_trip(&domain); + assert_eq!(restored, domain); + let DomainKind::Core { + simultaneous_multithreading, + .. + } = restored.kind + else { + panic!("a core domain must deserialize as one"); + }; + assert!( + !simultaneous_multithreading, + "a machine without SMT must not be reported as having it" + ); + } + + #[test] + fn a_non_boolean_smt_flag_is_refused_rather_than_read_as_true() { + // The other half: a constant `true` accepts input that is not a boolean + // at all. Anything that decodes to a different `AttributeValue` reaches + // the refusal, so a string and a number are both tried. + // The well-formed shape is asserted first, so a later failure is + // attributable to the flag rather than to the rest of the description. + // Written after exactly that mistake: an ad-hoc `processors` literal + // made the refusals happen for an unrelated reason. + let well_formed = r#"{"kind": "core", "id": 1, "processors": [], + "simultaneous_multithreading": false, "efficiency_class": 0}"#; + serde_json::from_str::(well_formed) + .expect("the fixture must parse when only the flag is changed"); + + for bad in [r#""yes""#, "1", "null"] { + let json = well_formed.replace("false", bad); + let error = serde_json::from_str::(&json) + .expect_err("{bad} is not a boolean and must not be read as one"); + assert!( + error.to_string().contains("boolean"), + "the refusal must say what was expected: {error}" + ); + } + } + + #[test] + fn a_cache_type_object_carrying_more_than_the_other_key_is_refused() { + // The `map.len() == 1` guard survived being replaced with `true`, which + // would accept an object with extra keys and silently ignore them. That + // matters more than it looks: the object form exists to carry a raw + // `PROCESSOR_CACHE_TYPE` this crate does not recognise, so a reader + // sending a *newer* shape must be told it was not understood rather + // than have the parts we recognise quietly taken. + // + // Built from the same helper as the accepted case below, which is what + // makes the refusal attributable: an ad-hoc JSON literal here was + // rejected for a malformed `processors` field instead, and would have + // passed this assertion while testing nothing about the guard. + let json = r#"{"kind": "cache", "id": 0, "processors": [], + "level": 2, "associativity": 8, "line_size": 64, + "size_bytes": 1024, "cache_type": {"other": 99, "extra": 1}}"#; + + let outcome: Result = serde_json::from_str(json); + let error = outcome.expect_err("an object with a second key must be refused"); + assert!( + error.to_string().contains("cache_type"), + "the refusal must name the field that was not understood, or a \ + reader cannot tell which part of their description was rejected: {error}" + ); + } + + #[test] + fn a_cache_type_object_with_exactly_the_other_key_is_accepted() { + // The positive case, so the test above cannot be satisfied by a guard + // that refuses every object -- or, as it first was, by a fixture that + // never reached the guard at all. + let domain = + cache_domain_with_other_type("99").expect("the single-key form is the one we emit"); + let DomainKind::Cache { cache_type, .. } = domain.kind else { + panic!("a cache domain must deserialize as one"); + }; + assert_eq!(cache_type, CacheKind::Other(99)); + } } From 1baea9b5eccefc8c31a01de05cbe20ae8214f746 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 08:46:39 -0400 Subject: [PATCH 200/361] style(guard-alloc): apply cargo fmt to the seed-parsing tests Reformatting a later cargo fmt run produced in the tests added by c16845c. Committed rather than left unstaged: an uncommitted fmt diff leaves the tree dirty and lets the next contributor mistake it for their own change. --- crates/windows-guard-alloc/src/tests.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/windows-guard-alloc/src/tests.rs b/crates/windows-guard-alloc/src/tests.rs index 311ffa1c..3dbd4dd7 100644 --- a/crates/windows-guard-alloc/src/tests.rs +++ b/crates/windows-guard-alloc/src/tests.rs @@ -445,8 +445,14 @@ fn a_seed_too_large_for_a_u64_is_refused_rather_than_wrapped() { // `checked_mul`/`checked_add` are what make this a refusal. Wrapping would // accept a value and then use a *different* one, which is the worst of the // three outcomes: reproducible-looking and wrong. - assert_eq!(super::parse_seed(&units("18446744073709551615")), Some(u64::MAX)); + assert_eq!( + super::parse_seed(&units("18446744073709551615")), + Some(u64::MAX) + ); assert_eq!(super::parse_seed(&units("18446744073709551616")), None); - assert_eq!(super::parse_seed(&units("0xFFFFFFFFFFFFFFFF")), Some(u64::MAX)); + assert_eq!( + super::parse_seed(&units("0xFFFFFFFFFFFFFFFF")), + Some(u64::MAX) + ); assert_eq!(super::parse_seed(&units("0x1FFFFFFFFFFFFFFFF")), None); } From 527864dab5c25ee8a3ec252eee0ae4dff31643eb Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 08:55:09 -0400 Subject: [PATCH 201/361] docs: preserve the 2026-09-02 mutation sweep and queue its remaining work The workspace-wide cargo-mutants run took roughly fourteen hours and its results lived only in git-ignored .scratch/, so they would have been lost to the next clean-up and had to be paid for again. Kept here are the survivor and timeout lists per package, grouped by source file: 90 KB, against 219 MB for the full run, almost all of which is per-mutant build logs that say nothing once the outcome is known. The README carries the part that would actually have been lost -- not the numbers, which cargo-mutants can reproduce, but how to read them. Three ways the headline table misleads: A timeout is usually a detection that lost its name rather than a gap. cargo test runs tests as threads in one process, so one test parked on a queue that will never fill stops the whole harness reporting. windows-waitable-queues appears to sit at 76% for this reason while actually having four survivors in 524 -- measured, not assumed: re-injecting one of its 120 timeouts alone fails four tests in 0.00 seconds. A low score on an executable probe crate is measuring the wrong thing. windows-platform-probes is fourteen binaries you run to answer a question about Windows, not a library with a test surface. Excluding it and the example harness, the substantive backlog is roughly 530 rather than 1,112. And three kinds of survivor are not missing tests at all -- equivalent mutants, unreachable code, and constants that want a const assertion. The README says how to tell them apart, and how to measure an equivalence claim rather than merely argue it, with the worked examples already in the tree. Saving the data is not enough on its own: this repository queues work in committed checklists, never in notes nobody is obliged to act on. So CHECKLIST-mutation-survivors.md carries the work, registered in PLANS.md, split into the shipping crates, the two crates that are not libraries and whose scope is an engineer's decision, and a final item to re-run rather than hand-edit the tool's output into a second source of truth. Eight package files note commits on this branch that already closed part of their list, and are deliberately left unpruned for that same reason. Verified rather than assumed: every file's entry count matches the tool's own lists exactly. An earlier draft grouped by line number instead of by source file -- it had already stripped the path before grouping -- which would have shipped a plausible-looking artifact that was quietly useless. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-mutation-survivors.md | 103 +++ PLANS.md | 1 + mutation-sweeps/2026-09-02/README.md | 108 +++ .../windows-file-enumeration-sys.md | 157 ++++ ...ndows-file-watcher-example-test-harness.md | 121 ++++ .../2026-09-02/windows-file-watcher.md | 212 ++++++ .../2026-09-02/windows-guard-alloc.md | 52 ++ .../windows-impersonation-token-sys.md | 21 + .../2026-09-02/windows-ioring-sys.md | 47 ++ .../windows-namespace-request-sys.md | 129 ++++ .../2026-09-02/windows-overlapped-io-sys.md | 140 ++++ .../2026-09-02/windows-placement-probe.md | 277 ++++++++ .../2026-09-02/windows-platform-probes.md | 670 ++++++++++++++++++ .../2026-09-02/windows-threadpool-sys.md | 141 ++++ .../2026-09-02/windows-topology-sys.md | 30 + .../2026-09-02/windows-waitable-queues.md | 193 +++++ 16 files changed, 2402 insertions(+) create mode 100644 CHECKLIST-mutation-survivors.md create mode 100644 mutation-sweeps/2026-09-02/README.md create mode 100644 mutation-sweeps/2026-09-02/windows-file-enumeration-sys.md create mode 100644 mutation-sweeps/2026-09-02/windows-file-watcher-example-test-harness.md create mode 100644 mutation-sweeps/2026-09-02/windows-file-watcher.md create mode 100644 mutation-sweeps/2026-09-02/windows-guard-alloc.md create mode 100644 mutation-sweeps/2026-09-02/windows-impersonation-token-sys.md create mode 100644 mutation-sweeps/2026-09-02/windows-ioring-sys.md create mode 100644 mutation-sweeps/2026-09-02/windows-namespace-request-sys.md create mode 100644 mutation-sweeps/2026-09-02/windows-overlapped-io-sys.md create mode 100644 mutation-sweeps/2026-09-02/windows-placement-probe.md create mode 100644 mutation-sweeps/2026-09-02/windows-platform-probes.md create mode 100644 mutation-sweeps/2026-09-02/windows-threadpool-sys.md create mode 100644 mutation-sweeps/2026-09-02/windows-topology-sys.md create mode 100644 mutation-sweeps/2026-09-02/windows-waitable-queues.md diff --git a/CHECKLIST-mutation-survivors.md b/CHECKLIST-mutation-survivors.md new file mode 100644 index 00000000..d1147d2a --- /dev/null +++ b/CHECKLIST-mutation-survivors.md @@ -0,0 +1,103 @@ +# Mutation survivors from the 2026-09-02 sweep + +Work queued from the workspace-wide cargo-mutants run recorded in +[mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md). +That README carries the command, the totals, and -- more importantly -- the +three ways its numbers mislead. **Read it before starting any item here.** + +The sweep cost roughly fourteen hours, so its findings are kept rather than +re-derived. Each item below points at the per-package file listing that +package's survivors grouped by source file. + +**Read a package file by shape, not line by line.** A large block of survivors +usually names one absent *kind* of test, and one new test closes all of it. The +two shapes that dominated this sweep were accessors nothing ever reads back, and +boundaries tested with comfortably-wrong values. Both are described in the +README with the fix that worked. + +**Three kinds of survivor are not missing tests**, and checking costs less than +writing a test that asserts a property the code does not have: equivalent +mutants, unreachable code, and constants that want a `const` assertion rather +than a test. The README describes how to tell each apart, and how to *measure* +an equivalence claim instead of merely arguing it. + +## M1: the shipping crates + +- [ ] **MS-1.1** -- **`windows-overlapped-io-sys`: 43 survivors, 23 timeouts.** + See [windows-overlapped-io-sys.md](mutation-sweeps/2026-09-02/windows-overlapped-io-sys.md). + Concentrated in `fs.rs` (10), `config.rs` (6), and `device.rs` (4). The 23 + timeouts are the second-largest block in the workspace after the queues, and + this crate has the same blocking-API shape -- so expect most of them to be + detections that lost their name rather than gaps, and confirm before writing + anything. + +- [ ] **MS-1.2** -- **`windows-threadpool-sys`: 44 survivors, 18 timeouts.** + See [windows-threadpool-sys.md](mutation-sweeps/2026-09-02/windows-threadpool-sys.md). + The workspace's namesake crate and the lowest-level one that ships. + +- [ ] **MS-1.3** -- **`windows-file-watcher`: 113 survivors.** + See [windows-file-watcher.md](mutation-sweeps/2026-09-02/windows-file-watcher.md). + The largest block in a shipping crate, but **71 of the 113 are in + `scenario.rs`**, which is the crate's own `scenario-tool` test tooling rather + than the watcher. Decide whether that tooling is in scope before starting: + the remaining 42 are spread across `watcher.rs` (21), `directory.rs` (8), and + the rest. + +- [ ] **MS-1.4** -- **Finish `windows-file-enumeration-sys`: about 20 of 50 remain.** + See [windows-file-enumeration-sys.md](mutation-sweeps/2026-09-02/windows-file-enumeration-sys.md). + The `error.rs` and `path.rs` blocks are closed (commits `07882f0`, `49019f2`), + along with the completion ring's reservation accounting. What remains is + spread thinly: `native.rs` (5), `session.rs` (4), `submission_ring.rs` (3), + `pattern.rs`, `admission.rs`, `engine.rs`, `registry.rs`. + +- [ ] **MS-1.5** -- **Finish `windows-namespace-request-sys`: about 21 of 49 remain.** + See [windows-namespace-request-sys.md](mutation-sweeps/2026-09-02/windows-namespace-request-sys.md). + The accessor and error-surface blocks are closed (`9a9163c`, `a07b50c`). What + remains is mostly `final_path.rs` (8) and `full_path.rs` (2), plus + `handle.rs`'s four `delete -` mutants and `buffer.rs`/`watch.rs` drop impls. + +- [ ] **MS-1.6** -- **Finish `windows-guard-alloc`: about 16 of 22 remain.** + See [windows-guard-alloc.md](mutation-sweeps/2026-09-02/windows-guard-alloc.md). + Seed parsing and `poison::identify`'s bound are closed (`c16845c`, `b791418`), + and two loop bounds are recorded as equivalent. What remains is in `lib.rs` + (`seed`, `announce_seed`, `poison_check`, `data_offset`) and `witness.rs`. + +- [ ] **MS-1.7** -- **Finish `windows-placement-probe`: about 190 of 199 remain.** + See [windows-placement-probe.md](mutation-sweeps/2026-09-02/windows-placement-probe.md). + The plan's arithmetic and the median's rates are closed (`47dfa18`). The bulk + is still `peer_index_cache.rs` (70) and `core_affinity.rs` (54), both of which + are pure selection and measurement logic that the module documentation already + says is testable offline -- so most of it should be reachable without hardware. + +## M2: the crates that are not libraries + +Both of these score badly for reasons that are not defects, so **decide whether +they are in scope at all before spending time on them.** That decision belongs +to the engineer, not to whoever picks up this checklist. + +- [ ] **MS-2.1** -- **`windows-platform-probes`: 511 survivors, 17% caught.** + See [windows-platform-probes.md](mutation-sweeps/2026-09-02/windows-platform-probes.md). + Fourteen executable probes you *run* to answer a question about Windows, not a + library with a test surface; `publish = false` at version `0.0.0`. Most of the + survivors are `main`-adjacent code no test was ever going to reach. Judging + this crate by mutation score is measuring the wrong thing. + A related, separately-tracked item: twelve of these probes still print + directly rather than through the `Report` sink, which is + [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) + `SH-13.4`. Doing that first would make some of this reachable. + +- [ ] **MS-2.2** -- **`windows-file-watcher-example-test-harness`: 69 survivors.** + See [windows-file-watcher-example-test-harness.md](mutation-sweeps/2026-09-02/windows-file-watcher-example-test-harness.md). + A deliberately legible *example* harness, published to be read and copied + rather than depended on. Its value is in being clear, and tests written purely + to raise its score would work against that. + +## M3: keeping the record honest + +- [ ] **MS-3.1** -- **Re-run the affected packages and prune what is closed.** + Eight package files note commits that already closed part of their list, and + those files are deliberately **not** pruned by hand -- editing them to match + would create a second source of truth that drifts from the tool's own output. + A re-run is the only honest way to shrink them. Do this once the M1 items are + substantially done, and replace this directory with a dated sibling rather + than editing it in place, so the two runs can be compared. diff --git a/PLANS.md b/PLANS.md index 905de44b..0fb4f1c7 100644 --- a/PLANS.md +++ b/PLANS.md @@ -17,6 +17,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| +| [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later; SH-1.5 gave `Reserving`'s associated type the bound a generic caller needs, which had to land before publication); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | diff --git a/mutation-sweeps/2026-09-02/README.md b/mutation-sweeps/2026-09-02/README.md new file mode 100644 index 00000000..95baeda2 --- /dev/null +++ b/mutation-sweeps/2026-09-02/README.md @@ -0,0 +1,108 @@ +# Mutation sweep -- 2026-09-02 + +A cargo-mutants run over every workspace member, kept so its findings can be +worked through later without paying for the sweep again. It took roughly +fourteen hours. + +## What was run + +Each package in turn, through the workspace wrapper, which is where all the +settings that matter live (WER dialog suppression, `-j 2`, features on the +correct side of `--`, a per-run output directory): + +```powershell +.\tools\run-mutants.ps1 -Package -OutputDirectory

+``` + +Only the `missed.txt` and `timeout.txt` lists are kept here. The full run +produced 219 MB across 10,624 files, almost all of it per-mutant build logs +that say nothing once the outcome is known. + +## Totals + +| Package | Caught | Survived | Timeout | +|---|---|---|---| +| wtf-string | 97 | 0 | 0 | +| windows-thread-ambient-sys | 142 | 0 | 0 | +| windows-impersonation-token-sys | 33 | 1 | 0 | +| windows-ioring-sys | 229 | 2 | 6 | +| windows-waitable-queues | 400 | 4 | 120 | +| windows-topology-sys | 127 | 5 | 0 | +| windows-guard-alloc | 88 | 22 | 0 | +| windows-overlapped-io-sys | 135 | 43 | 23 | +| windows-threadpool-sys | 102 | 44 | 18 | +| windows-namespace-request-sys | 120 | 49 | 1 | +| windows-file-enumeration-sys | 410 | 50 | 8 | +| windows-file-watcher-example-test-harness | 77 | 69 | 3 | +| windows-file-watcher | 415 | 113 | 10 | +| windows-placement-probe | 315 | 199 | 4 | +| windows-platform-probes | 102 | 511 | 5 | +| **total** | **2792** | **1112** | **198** | + +## Three ways this table misleads, and what to do instead + +**A timeout is usually a detection that lost its name, not a gap.** `cargo test` +runs tests as threads in one process, so a single test parked on a queue that +will never fill stops the whole harness reporting -- and the run is recorded as +a timeout even when other tests have already failed. This is why +`windows-waitable-queues` appears to sit at 76% while actually having four +survivors in 524. + +Measured rather than assumed: re-injecting `validate_capacity -> Ok(())`, one of +that crate's 120 timeouts, fails four tests in **0.00 seconds**. It was caught +instantly and then buried. + +So do not read a timeout as a gap. To find out what one really is, re-inject +that single mutant and run the one test that should catch it, or use +`cargo_test`'s `bisect` to name the thread that parked. + +**A low score on an executable probe is measuring the wrong thing.** +`windows-platform-probes` is fourteen binaries you *run* to answer a question +about Windows, not a library with a test surface; it is `publish = false` at +version `0.0.0`. Its 511 survivors are mostly `main`-adjacent code that no test +was ever going to reach. The same caveat applies, less strongly, to +`windows-file-watcher-example-test-harness`, which is published to be read and +copied rather than depended on. + +Excluding those two, the substantive backlog is roughly 530 survivors. + +**Not every survivor is a missing test.** Three other kinds turn up often enough +to check for before writing anything: + +- *Equivalent mutants*, which change no observable behaviour. Argue the + equivalence, then measure it: declare the sabotage `survives` in a manifest + for `tools/run-sabotage.ps1` and also inject the non-equivalent direction, so + the claim is a property of the code rather than of weak tests. Worked examples + already in the tree: `poison::mul_inverse`'s idempotent extra Newton round, + `from_filetime`'s disjoint word halves, `ProcessorSet::empty`, whose mutant + replaces the body with the code already there. +- *Unreachable code*, where no test could reach the line. Check that a test + *could* before writing one. `RegisteredBuffers::is_empty` cannot return true + because the kernel refuses an empty registration; what was worth pinning there + was the platform behaviour, not the accessor. +- *Constants*, where a `const` assertion beats a test -- it fails the build + rather than a run somebody chose to make. Verify it in both directions: the + mutation must fail to compile with the assertion and compile cleanly without. + +## Reading a per-package file + +One file per package, listing survivors grouped by source file, with the +timeouts kept separate. Eight of them note commits on +`mikegrier/deferred-namespace-ops` that already closed part of the list; those +files are **not** pruned, because pruning them by hand would be a second source +of truth. Re-run the affected package before treating any single line as +outstanding. + +The most useful way to read one is by *shape* rather than line by line. A large +block of survivors usually names one absent kind of test, and one new test can +close all of it. The two that dominated this sweep: + +- **Accessors that are never read back.** Every test builds a value with `with_*` + and then performs it, and the perform path reads the fields directly -- so a + constant accessor is indistinguishable from a truthful one. Fixed by + configuring non-zero, pairwise-distinct values and reading each back, with the + distinctness itself asserted so a later edit cannot silently weaken it. +- **Boundaries tested with comfortably-wrong values.** A 400-character path + proves a check exists but not that it sits at the right unit, so moving the + limit by one survives. Fixed by asserting the exact unit at which the answer + changes. diff --git a/mutation-sweeps/2026-09-02/windows-file-enumeration-sys.md b/mutation-sweeps/2026-09-02/windows-file-enumeration-sys.md new file mode 100644 index 00000000..daac7895 --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-file-enumeration-sys.md @@ -0,0 +1,157 @@ +# Mutation survivors -- windows-file-enumeration-sys + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 410 +- survived: 50 +- timeout: 8 + +**Partly addressed already** in commit(s) `07882f0, 49019f2` on branch +`mikegrier/deferred-namespace-ops`. The entries below are as the sweep +found them and have NOT been pruned -- re-run before treating any single +line as outstanding. + +## Survived + +### src/error.rs (16) + +``` +113:9: replace RequestFailure::describe -> &'static str with "xyzzy" +209:9: replace BeginFailure::describe -> &'static str with "" +209:9: replace BeginFailure::describe -> &'static str with "xyzzy" +279:9: replace BeginError::capture_error -> Option<&CaptureError> with None +285:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +294:9: replace ::source -> Option<&(dyn std::error::Error +'static)> with None +320:9: replace SessionFailure::describe -> &'static str with "" +320:9: replace SessionFailure::describe -> &'static str with "xyzzy" +363:9: replace SessionError::os_error -> Option<&io::Error> with None +369:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +378:9: replace ::source -> Option<&(dyn std::error::Error +'static)> with None +402:9: replace PredicateFailure::describe -> &'static str with "xyzzy" +460:9: replace MalformedRecord::describe -> &'static str with "" +460:9: replace MalformedRecord::describe -> &'static str with "xyzzy" +583:9: replace ::source -> Option<&(dyn std::error::Error +'static)> with None +584:13: delete match arm EnumerationError::Impersonation(error) in ::source +``` + +### src/path.rs (7) + +``` +43:42: replace - with + +43:42: replace - with / +77:20: replace > with == in prepare +77:20: replace > with >= in prepare +131:5: replace is_drive_designator -> bool with true +134:21: replace && with || in is_drive_designator +175:16: replace > with >= in resolve +``` + +### src/native.rs (5) + +``` +86:56: replace | with & in open_directory +86:56: replace | with ^ in open_directory +86:37: replace | with & in open_directory +86:37: replace | with ^ in open_directory +160:5: replace volume_serial -> Result with Ok(1) +``` + +### src/session.rs (4) + +``` +212:9: replace SessionShared::acquire_handle with () +222:9: replace SessionShared::release_handle with () +222:56: replace != with == in SessionShared::release_handle +797:9: replace Receiver::is_empty -> bool with true +``` + +### src/completion_ring.rs (3) + +``` +96:26: replace > with >= in RingState::can_reserve +336:26: replace -= with += in CompletionRing::release_reservation +336:26: replace -= with /= in CompletionRing::release_reservation +``` + +### src/pattern.rs (3) + +``` +80:9: replace NamePattern::empty -> Self with Default::default() +204:21: replace == with != in ordinal_equal_ignoring_case +207:21: replace == with != in ordinal_equal_ignoring_case +``` + +### src/submission_ring.rs (3) + +``` +58:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +250:24: replace -= with += in SubmissionRing::push_abandon +250:24: replace -= with /= in SubmissionRing::push_abandon +``` + +### src/admission.rs (2) + +``` +101:9: replace EnumerationHandle::cancel with () +235:17: delete match arm ControlMessage::Begin(begin) in try_begin_with_token +``` + +### src/engine.rs (2) + +``` +214:13: delete match arm Phase::Opened in advance +304:25: replace == with != in start +``` + +### src/registry.rs (2) + +``` +106:9: replace Registry::is_accepting -> bool with true +111:9: replace Registry::stop_accepting with () +``` + +### src/record.rs (1) + +``` +177:58: replace + with - in parse_record +``` + +### src/request.rs (1) + +``` +23:47: replace * with + +``` + +### src/timestamp.rs (1) + +``` +52:58: replace | with ^ in WindowsFileTimestamp::from_filetime +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/completion_ring.rs (6) + +``` +101:9: replace RingState::closed -> bool with false +101:23: replace == with != in RingState::closed +101:43: replace == with != in RingState::closed +190:9: replace CompletionRing::remove_session with () +192:28: replace -= with += in CompletionRing::remove_session +192:28: replace -= with /= in CompletionRing::remove_session +``` + +### src/pattern.rs (1) + +``` +224:5: replace code_point_width -> Option with Some(0) +``` + +### src/session.rs (1) + +``` +124:9: replace SessionWork::is_suppressed -> bool with true +``` diff --git a/mutation-sweeps/2026-09-02/windows-file-watcher-example-test-harness.md b/mutation-sweeps/2026-09-02/windows-file-watcher-example-test-harness.md new file mode 100644 index 00000000..a7972ed4 --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-file-watcher-example-test-harness.md @@ -0,0 +1,121 @@ +# Mutation survivors -- windows-file-watcher-example-test-harness + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 77 +- survived: 69 +- timeout: 3 + +## Survived + +### src/generator.rs (26) + +``` +106:16: replace ^ with | in Rng::next_u64 +106:16: replace ^ with & in Rng::next_u64 +106:21: replace >> with << in Rng::next_u64 +107:16: replace ^ with & in Rng::next_u64 +107:16: replace ^ with | in Rng::next_u64 +107:21: replace >> with << in Rng::next_u64 +108:16: replace >> with << in Rng::next_u64 +118:21: replace < with <= in Rng::below +158:23: replace - with + in Rng::weighted +158:23: replace - with / in Rng::weighted +247:9: replace Generator::config -> &GeneratorConfig with Box::leak(Box::new(Default::default())) +393:39: replace && with || in Generator::generate_watch +441:9: replace Generator::pick_index -> usize with 0 +493:9: delete match arm 0 in gen_change_kind +494:9: delete match arm 1 in gen_change_kind +496:9: delete match arm 3 in gen_change_kind +495:9: delete match arm 2 in gen_change_kind +503:5: replace gen_name -> String with String::new() +503:5: replace gen_name -> String with "xyzzy".into() +570:9: delete match arm 0 in gen_detail +578:9: delete match arm 1 in gen_detail +604:33: replace & with | in gen_volume +604:33: replace & with ^ in gen_volume +623:20: replace + with * in gen_changed_volume +623:52: replace - with + in gen_changed_volume +623:52: replace - with / in gen_changed_volume +``` + +### src/bin/replay.rs (17) + +``` +34:9: replace Output::report with () +46:5: replace main -> std::process::ExitCode with Default::default() +110:48: replace * with + +110:48: replace * with / +110:41: replace * with + +110:41: replace * with / +122:9: replace imp::load_bounded -> Option with None +135:59: replace + with - in imp::load_bounded +135:59: replace + with * in imp::load_bounded +139:30: replace > with == in imp::load_bounded +139:30: replace > with >= in imp::load_bounded +157:37: replace > with == in imp::load_bounded +157:37: replace > with < in imp::load_bounded +157:37: replace > with >= in imp::load_bounded +168:9: replace imp::main -> std::process::ExitCode with Default::default() +197:9: replace imp::replay -> bool with false +197:9: replace imp::replay -> bool with true +``` + +### src/bin/capture.rs (10) + +``` +39:9: replace Output::report with () +51:5: replace main -> std::process::ExitCode with Default::default() +73:9: replace imp::main -> std::process::ExitCode with Default::default() +89:9: replace imp::capture -> bool with true +89:9: replace imp::capture -> bool with false +98:30: replace + with - in imp::capture +98:30: replace + with * in imp::capture +118:23: replace += with *= in imp::capture +146:13: replace imp::Args::parse -> Option with None +118:23: replace += with -= in imp::capture +``` + +### src/example_handler.rs (10) + +``` +54:9: replace PresenceTracker::rescans -> u32 with 1 +65:9: replace PresenceTracker::stopped -> &BTreeSet with Box::leak(Box::new(BTreeSet::new())) +71:9: replace PresenceTracker::is_subscribed -> bool with true +77:9: replace PresenceTracker::volume_changes -> u32 with 0 +77:9: replace PresenceTracker::volume_changes -> u32 with 1 +106:13: delete match arm Notification::VolumeChanged{..} in ::on +97:54: replace match guard cause.is_terminal() with false in ::on +106:71: replace += with -= in ::on +106:71: replace += with *= in ::on +149:31: replace += with -= in ::on +``` + +### src/oracle.rs (3) + +``` +34:9: replace Outcome::is_healthy -> bool with true +201:5: replace panic_message -> String with String::new() +201:5: replace panic_message -> String with "xyzzy".into() +``` + +### src/schedule.rs (3) + +``` +229:9: replace Schedule::len -> usize with 1 +229:9: replace Schedule::len -> usize with 0 +235:9: replace Schedule::is_empty -> bool with false +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/generator.rs (3) + +``` +115:42: replace % with / in Rng::below +118:21: replace < with == in Rng::below +118:21: replace < with > in Rng::below +``` diff --git a/mutation-sweeps/2026-09-02/windows-file-watcher.md b/mutation-sweeps/2026-09-02/windows-file-watcher.md new file mode 100644 index 00000000..ff059681 --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-file-watcher.md @@ -0,0 +1,212 @@ +# Mutation survivors -- windows-file-watcher + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 415 +- survived: 113 +- timeout: 10 + +## Survived + +### src/scenario.rs (71) + +``` +60:9: replace Rng::next_u64 -> u64 with 0 +60:9: replace Rng::next_u64 -> u64 with 1 +62:16: replace ^ with | in Rng::next_u64 +62:16: replace ^ with & in Rng::next_u64 +62:21: replace >> with << in Rng::next_u64 +63:16: replace ^ with | in Rng::next_u64 +63:16: replace ^ with & in Rng::next_u64 +63:21: replace >> with << in Rng::next_u64 +64:11: replace ^ with | in Rng::next_u64 +64:11: replace ^ with & in Rng::next_u64 +64:16: replace >> with << in Rng::next_u64 +89:21: replace < with <= in Rng::range +118:5: replace seed -> u64 with 0 +118:5: replace seed -> u64 with 1 +128:5: replace env_u64 -> u64 with 0 +128:5: replace env_u64 -> u64 with 1 +161:9: replace millis::deserialize -> Result with Ok(Default::default()) +378:21: delete match arm Operation::Concurrent{branches} in Scenario::operation_count::count +437:9: replace TempDir::cleanup with () +472:9: replace HarnessParams::for_operation_count -> Self with Default::default() +473:64: replace + with - in HarnessParams::for_operation_count +473:13: delete field timeout from struct Self expression in HarnessParams::for_operation_count +473:58: replace / with % in HarnessParams::for_operation_count +473:64: replace + with * in HarnessParams::for_operation_count +473:58: replace / with * in HarnessParams::for_operation_count +638:9: replace Fleet<'m>::open_session_bounded with () +651:9: replace Fleet<'m>::close_session with () +654:45: replace == with != in Fleet<'m>::close_session +669:9: replace Fleet<'m>::subscribe with () +687:9: replace Fleet<'m>::cancel_watch with () +699:9: replace Fleet<'m>::drain_available with () +737:9: replace HarnessOutcome::record with () +739:30: replace += with -= in HarnessOutcome::record +739:30: replace += with *= in HarnessOutcome::record +740:30: replace += with *= in HarnessOutcome::record +742:57: replace += with -= in HarnessOutcome::record +742:57: replace += with *= in HarnessOutcome::record +743:64: replace += with -= in HarnessOutcome::record +743:64: replace += with *= in HarnessOutcome::record +744:62: replace += with -= in HarnessOutcome::record +744:62: replace += with *= in HarnessOutcome::record +745:69: replace += with -= in HarnessOutcome::record +745:69: replace += with *= in HarnessOutcome::record +746:65: replace += with *= in HarnessOutcome::record +747:72: replace += with -= in HarnessOutcome::record +748:71: replace += with -= in HarnessOutcome::record +747:72: replace += with *= in HarnessOutcome::record +748:71: replace += with *= in HarnessOutcome::record +763:13: replace + with - in HarnessOutcome::total +763:13: replace + with * in HarnessOutcome::total +762:13: replace + with * in HarnessOutcome::total +761:13: replace + with - in HarnessOutcome::total +761:13: replace + with * in HarnessOutcome::total +760:13: replace + with - in HarnessOutcome::total +760:13: replace + with * in HarnessOutcome::total +759:13: replace + with - in HarnessOutcome::total +759:13: replace + with * in HarnessOutcome::total +758:13: replace + with - in HarnessOutcome::total +758:13: replace + with * in HarnessOutcome::total +757:13: replace + with - in HarnessOutcome::total +757:13: replace + with * in HarnessOutcome::total +773:62: replace | with & +773:62: replace | with ^ +814:9: replace apply_operation::check_bounded_sleep with () +997:13: delete match arm Operation::Repeat{count, pattern} in count_barrier_uses +1004:13: delete match arm Operation::Concurrent{branches} in count_barrier_uses +996:59: replace += with -= in count_barrier_uses +1001:54: replace += with *= in count_barrier_uses +1130:35: replace < with == in run_scenario_keep_dir +1130:35: replace < with > in run_scenario_keep_dir +1130:35: replace < with <= in run_scenario_keep_dir +``` + +### src/watcher.rs (21) + +``` +86:5: replace | with ^ +85:5: replace | with ^ +84:5: replace | with ^ +83:5: replace | with ^ +82:5: replace | with ^ +81:5: replace | with ^ +562:47: replace match guard changes.is_empty() with false in WatcherInner::publish +629:28: replace == with != in WatcherInner::enter_fault +673:26: replace < with == in WatcherInner::answer +673:26: replace < with > in WatcherInner::answer +673:26: replace < with <= in WatcherInner::answer +759:71: replace && with || in WatcherInner::on_path_based_reopen +851:9: replace WatcherInner::remove_route_from_volume_change -> Option<(usize, Vec)> with None +854:16: delete ! in WatcherInner::remove_route_from_volume_change +1086:31: replace match guard classify(&error) == OpenFailure::Unsupported with true in WatcherInner::install +1086:31: replace match guard classify(&error) == OpenFailure::Unsupported with false in WatcherInner::install +1086:48: replace == with != in WatcherInner::install +1389:54: replace && with || in DirectoryWatcher::remove_route +1519:9: replace DirectoryWatcher::take_routes -> Vec with vec![] +1578:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +1589:9: replace ::drop with () +``` + +### src/directory.rs (8) + +``` +325:60: replace | with ^ in identify +325:53: replace << with >> in identify +388:52: replace | with ^ in DirectoryHandle::open +388:33: replace | with ^ in DirectoryHandle::open +391:44: replace | with ^ in DirectoryHandle::open +635:33: replace | with ^ in canonical_path +635:33: replace | with & in canonical_path +651:20: replace < with <= in canonical_path +``` + +### src/bin/run_scenario.rs (4) + +``` +24:9: replace Output::diagnostic with () +42:5: replace main -> std::process::ExitCode with Default::default() +77:5: replace main -> std::process::ExitCode with Default::default() +29:9: replace Output::result with () +``` + +### src/coarse.rs (2) + +``` +94:9: replace ::drop with () +83:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +``` + +### src/notify.rs (2) + +``` +274:74: replace - with + in >::next +274:74: replace - with / in >::next +``` + +### src/servicing.rs (2) + +``` +233:9: replace >::drop with () +239:9: replace >::fmt -> std::fmt::Result with Ok(Default::default()) +``` + +### src/route.rs (1) + +``` +167:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +``` + +### src/testing.rs (1) + +``` +34:9: replace TempDir::cleanup with () +``` + +### src/watch.rs (1) + +``` +208:9: replace Watch::cancel with () +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/scenario.rs (3) + +``` +86:42: replace % with / in Rng::range +89:21: replace < with == in Rng::range +89:21: replace < with > in Rng::range +``` + +### src/servicing.rs (3) + +``` +141:9: replace Servicer::submit -> Result<(), Rejected> with Ok(()) +147:36: replace == with != in Servicer::submit +258:5: replace drain with () +``` + +### src/queue.rs (2) + +``` +942:9: replace Receiver::recv_timeout -> Option with None +1125:5: replace take -> Option with None +``` + +### src/directory.rs (1) + +``` +651:20: replace < with == in canonical_path +``` + +### src/watcher.rs (1) + +``` +1629:17: replace != with == in classify_submission +``` diff --git a/mutation-sweeps/2026-09-02/windows-guard-alloc.md b/mutation-sweeps/2026-09-02/windows-guard-alloc.md new file mode 100644 index 00000000..870b5291 --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-guard-alloc.md @@ -0,0 +1,52 @@ +# Mutation survivors -- windows-guard-alloc + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 88 +- survived: 22 +- timeout: 0 + +**Partly addressed already** in commit(s) `c16845c, b791418` on branch +`mikegrier/deferred-namespace-ops`. The entries below are as the sweep +found them and have NOT been pruned -- re-run before treating any single +line as outstanding. + +## Survived + +### src/lib.rs (14) + +``` +106:5: replace seed_from_environment -> Option with Some(0) +106:5: replace seed_from_environment -> Option with Some(1) +106:5: replace seed_from_environment -> Option with None +124:16: replace == with != in seed_from_environment +124:21: replace || with && in seed_from_environment +153:5: replace seed -> u64 with 0 +134:9: delete match arm [ZERO, LOWER_X | UPPER_X, rest @..] in seed_from_environment +153:5: replace seed -> u64 with 1 +170:28: replace == with != in seed +232:9: replace GuardAlloc::announce_seed with () +255:24: replace < with == in GuardAlloc::poison_check +255:24: replace < with <= in GuardAlloc::poison_check +316:14: replace > with == in data_offset +316:14: replace > with >= in data_offset +``` + +### src/witness.rs (5) + +``` +50:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +82:9: replace Witness::ordinal -> u64 with 0 +82:9: replace Witness::ordinal -> u64 with 1 +97:18: replace < with <= in Witness::permit +158:27: replace match guard range.start <= last.end with false in merged +``` + +### src/poison.rs (3) + +``` +66:17: replace < with <= in mul_inverse +87:20: replace < with <= in unxor_shift_right +120:14: replace < with <= in identify +``` diff --git a/mutation-sweeps/2026-09-02/windows-impersonation-token-sys.md b/mutation-sweeps/2026-09-02/windows-impersonation-token-sys.md new file mode 100644 index 00000000..ba77f9c3 --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-impersonation-token-sys.md @@ -0,0 +1,21 @@ +# Mutation survivors -- windows-impersonation-token-sys + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 33 +- survived: 1 +- timeout: 0 + +**Partly addressed already** in commit(s) `9a9163c` on branch +`mikegrier/deferred-namespace-ops`. The entries below are as the sweep +found them and have NOT been pruned -- re-run before treating any single +line as outstanding. + +## Survived + +### src/lib.rs (1) + +``` +88:72: replace | with ^ +``` diff --git a/mutation-sweeps/2026-09-02/windows-ioring-sys.md b/mutation-sweeps/2026-09-02/windows-ioring-sys.md new file mode 100644 index 00000000..83aeacee --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-ioring-sys.md @@ -0,0 +1,47 @@ +# Mutation survivors -- windows-ioring-sys + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 229 +- survived: 2 +- timeout: 6 + +**Partly addressed already** in commit(s) `9a9163c` on branch +`mikegrier/deferred-namespace-ops`. The entries below are as the sweep +found them and have NOT been pruned -- re-run before treating any single +line as outstanding. + +## Survived + +### src/batch.rs (1) + +``` +656:9: replace RegisteredBuffers::is_empty -> bool with false +``` + +### src/ring.rs (1) + +``` +196:51: replace | with ^ in InjectedFailure::as_hresult +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/batch.rs (4) + +``` +2012:9: replace Batch<'ring>::do_submit -> io::Result with Ok(0) +2012:9: replace Batch<'ring>::do_submit -> io::Result with Ok(1) +2037:9: replace >::drop with () +2037:12: delete ! in >::drop +``` + +### src/ring.rs (2) + +``` +936:9: replace IoRing::drain_for_rundown -> io::Result<()> with Ok(()) +969:9: replace IoRing::try_pop -> io::Result> with Ok(None) +``` diff --git a/mutation-sweeps/2026-09-02/windows-namespace-request-sys.md b/mutation-sweeps/2026-09-02/windows-namespace-request-sys.md new file mode 100644 index 00000000..3d107d1f --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-namespace-request-sys.md @@ -0,0 +1,129 @@ +# Mutation survivors -- windows-namespace-request-sys + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 120 +- survived: 49 +- timeout: 1 + +**Partly addressed already** in commit(s) `9a9163c, a07b50c` on branch +`mikegrier/deferred-namespace-ops`. The entries below are as the sweep +found them and have NOT been pruned -- re-run before treating any single +line as outstanding. + +## Survived + +### src/path.rs (9) + +``` +66:42: replace - with + +66:42: replace - with / +104:9: replace PathFailure::description -> &'static str with "xyzzy" +152:9: replace PathError::raw_os_error -> Option with None +167:9: replace ::source -> Option<&(dyn std::error::Error +'static)> with None +298:20: replace > with == in prepare_units +298:20: replace > with >= in prepare_units +355:21: replace && with || in is_drive_designator +393:16: replace > with >= in resolve +``` + +### src/final_path.rs (8) + +``` +91:52: replace | with ^ +91:52: replace | with & +110:21: replace | with ^ in ::bitor +134:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +146:9: replace ::source -> Option<&(dyn std::error::Error +'static)> with None +217:9: replace QueryFinalPath::flags -> FinalPathFlags with Default::default() +261:24: replace < with <= in QueryFinalPath::perform +287:9: replace ::perform -> Result with Ok(Default::default()) +``` + +### src/security.rs (6) + +``` +84:9: replace SecurityCaptureError::raw_os_error -> Option with None +84:9: replace SecurityCaptureError::raw_os_error -> Option with Some(0) +84:9: replace SecurityCaptureError::raw_os_error -> Option with Some(1) +84:9: replace SecurityCaptureError::raw_os_error -> Option with Some(-1) +105:9: replace ::source -> Option<&(dyn std::error::Error +'static)> with None +308:9: replace SecurityDescriptor::is_empty -> bool with false +``` + +### src/handle.rs (5) + +``` +35:33: delete - +37:46: delete - +39:45: delete - +41:55: delete - +126:9: replace ::source -> Option<&(dyn std::error::Error +'static)> with None +``` + +### src/watch.rs (5) + +``` +122:9: replace ::drop with () +207:21: replace | with ^ in NotifyFilter::union +289:9: replace WatchDirectory::subtree -> bool with false +295:9: replace WatchDirectory::filter -> NotifyFilter with Default::default() +324:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +``` + +### src/open_by_id.rs (4) + +``` +223:9: replace OpenFileByIdentifier::desired_access -> u32 with 0 +229:9: replace OpenFileByIdentifier::share_mode -> FILE_SHARE_MODE with Default::default() +235:9: replace OpenFileByIdentifier::security -> Option<&SecurityAttributes> with None +241:9: replace OpenFileByIdentifier::flags_and_attributes -> FILE_FLAGS_AND_ATTRIBUTES with Default::default() +``` + +### src/volume.rs (4) + +``` +54:9: replace VolumeInformation::label -> &Wtf16String with Box::leak(Box::new(Default::default())) +63:9: replace VolumeInformation::serial_number -> u32 with 1 +69:9: replace VolumeInformation::maximum_component_length -> u32 with 1 +78:9: replace VolumeInformation::flags -> u32 with 1 +``` + +### src/open.rs (3) + +``` +172:9: replace OpenFile::desired_access -> u32 with 0 +178:9: replace OpenFile::share_mode -> FILE_SHARE_MODE with Default::default() +190:9: replace OpenFile::creation_disposition -> FILE_CREATION_DISPOSITION with Default::default() +``` + +### src/full_path.rs (2) + +``` +189:24: replace < with <= in ResolveFullPath::perform +222:9: replace ::perform -> Result with Ok(Default::default()) +``` + +### src/query.rs (2) + +``` +219:44: replace * with + +219:44: replace * with / +``` + +### src/buffer.rs (1) + +``` +158:9: replace ::drop with () +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/buffer.rs (1) + +``` +80:16: replace == with != in AlignedBuffer::zeroed +``` diff --git a/mutation-sweeps/2026-09-02/windows-overlapped-io-sys.md b/mutation-sweeps/2026-09-02/windows-overlapped-io-sys.md new file mode 100644 index 00000000..85120b4c --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-overlapped-io-sys.md @@ -0,0 +1,140 @@ +# Mutation survivors -- windows-overlapped-io-sys + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 135 +- survived: 43 +- timeout: 23 + +## Survived + +### src/iocp.rs (11) + +``` +315:9: replace CompletionPort::live_operations -> &OperationRegistry with Box::leak(Box::new(Default::default())) +353:9: replace CompletionPort::run_down -> io::Result<()> with Ok(()) +353:34: replace > with < in CompletionPort::run_down +445:9: replace CompletionPort::report_outstanding_at_drop with () +473:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +481:9: replace ::drop with () +482:18: replace == with != in ::drop +653:15: replace == with != in AssociatedEndpoint<'port>::cancel_all +672:31: replace > with >= in >::drop +799:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +813:9: replace ::drop with () +``` + +### src/fs.rs (10) + +``` +133:11: replace != with == in classify +133:5: replace classify -> io::Result<()> with Ok(()) +464:9: replace PageBuffers::pages -> usize with 0 +464:9: replace PageBuffers::pages -> usize with 1 +476:9: replace PageBuffers::is_empty -> bool with true +490:9: replace PageBuffers::as_bytes_mut -> &mut[u8] with Vec::leak(Vec::new()) +490:9: replace PageBuffers::as_bytes_mut -> &mut[u8] with Vec::leak(vec![0]) +490:9: replace PageBuffers::as_bytes_mut -> &mut[u8] with Vec::leak(vec![1]) +512:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +520:9: replace ::drop with () +``` + +### src/socket.rs (7) + +``` +96:9: replace AssociatedSocket<'port>::key -> usize with 0 +96:9: replace AssociatedSocket<'port>::key -> usize with 1 +160:19: replace |= with &= in AssociatedSocket<'port>::set_notification_modes +307:9: replace AssociatedSocket<'port>::cancel -> io::Result<()> with Ok(()) +312:19: replace == with != in AssociatedSocket<'port>::cancel +326:9: replace AssociatedSocket<'port>::cancel_all -> io::Result<()> with Ok(()) +327:15: replace == with != in AssociatedSocket<'port>::cancel_all +``` + +### src/config.rs (6) + +``` +22:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +40:5: replace set_source_tracking -> Result<(), SourceTrackingAlreadySet> with Ok(()) +51:5: replace source_tracking_enabled -> bool with true +55:5: replace default_from_env -> bool with true +51:5: replace source_tracking_enabled -> bool with false +55:5: replace default_from_env -> bool with false +``` + +### src/device.rs (4) + +``` +99:5: replace in_ptr -> *const c_void with Default::default() +99:12: replace == with != in in_ptr +118:5: replace classify -> io::Result<()> with Ok(()) +122:29: replace == with != in classify +``` + +### src/buf.rs (2) + +``` +197:9: replace ::stable_ptr -> *const u8 with Default::default() +209:9: replace ::stable_mut_ptr -> *mut u8 with Default::default() +``` + +### src/endpoint.rs (2) + +``` +110:48: replace | with ^ in UnassociatedEndpoint::open +214:19: replace |= with &= in UnassociatedEndpoint::set_notification_modes +``` + +### src/blocking.rs (1) + +``` +141:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/iocp.rs (13) + +``` +222:9: replace CompletionPort::get -> io::Result> with Ok(None) +291:9: replace CompletionPort::deregister_dequeued -> Option with None +304:9: replace CompletionPort::raw -> HANDLE with Default::default() +327:9: replace CompletionPort::outstanding -> usize with 1 +353:34: replace > with == in CompletionPort::run_down +533:9: replace AssociatedEndpoint<'port>::notification_modes -> crate::NotificationModes with Default::default() +549:9: replace AssociatedEndpoint<'port>::outstanding -> usize with 1 +652:9: replace AssociatedEndpoint<'port>::cancel_all -> io::Result<()> with Ok(()) +660:9: replace AssociatedEndpoint<'port>::raw_handle -> HANDLE with Default::default() +672:31: replace > with == in >::drop +672:31: replace > with < in >::drop +682:34: replace > with == in >::drop +682:34: replace > with >= in >::drop +``` + +### src/identity.rs (8) + +``` +51:5: replace try_next_generation -> Option with None +55:22: replace != with == in try_next_generation +55:51: replace + with - in try_next_generation +196:9: replace OperationId::as_ptr -> *mut OVERLAPPED with Default::default() +293:9: replace OperationRegistry::insert with () +322:9: replace OperationRegistry::remove -> Option with None +399:9: replace OperationRegistry::len -> usize with 1 +421:15: delete ! in OperationRegistry::wait_until_empty +``` + +### src/endpoint.rs (1) + +``` +230:52: replace |= with &= in UnassociatedEndpoint::set_notification_modes +``` + +### src/operation.rs (1) + +``` +299:9: replace Operation

::overlapped_ptr -> *mut OVERLAPPED with Default::default() +``` diff --git a/mutation-sweeps/2026-09-02/windows-placement-probe.md b/mutation-sweeps/2026-09-02/windows-placement-probe.md new file mode 100644 index 00000000..52e1810a --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-placement-probe.md @@ -0,0 +1,277 @@ +# Mutation survivors -- windows-placement-probe + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 315 +- survived: 199 +- timeout: 4 + +**Partly addressed already** in commit(s) `47dfa18` on branch +`mikegrier/deferred-namespace-ops`. The entries below are as the sweep +found them and have NOT been pruned -- re-run before treating any single +line as outstanding. + +## Survived + +### src/peer_index_cache.rs (70) + +``` +106:9: replace Strategy::label -> &'static str with "" +106:9: replace Strategy::label -> &'static str with "xyzzy" +122:9: replace Strategy::name -> &'static str with "" +122:9: replace Strategy::name -> &'static str with "xyzzy" +166:9: replace Observation::get -> Option with None +168:35: replace == with != in Observation::get +209:38: replace / with % in median +209:38: replace / with * in median +213:38: replace / with % in median +213:38: replace / with * in median +214:40: replace / with % in median +214:40: replace / with * in median +214:56: replace / with % in median +214:56: replace / with * in median +238:17: replace < with == in time_real_spsc +238:17: replace < with > in time_real_spsc +238:17: replace < with <= in time_real_spsc +240:19: replace += with -= in time_real_spsc +240:19: replace += with *= in time_real_spsc +288:28: replace - with / in Ring::new_on +288:28: replace - with + in Ring::new_on +334:48: replace - with + +349:51: replace - with + +349:51: replace - with / +463:29: replace | with ^ in Slots::on_numa_node +504:9: replace ::drop with () +544:5: replace observed_node_of_region -> Option with Some(0) +552:28: replace match guard first == page_node with true in observed_node_of_region +570:21: replace match guard size > 0 with true in page_size +565:5: replace page_size -> usize with 1 +570:21: replace match guard size > 0 with false in page_size +570:26: replace > with == in page_size +570:26: replace > with >= in page_size +570:26: replace > with < in page_size +586:26: replace >> with << in observed_node +696:52: replace == with != in time_model_placed +776:23: replace << with >> in pin_current_thread +887:5: replace produce -> u64 with 0 +887:5: replace produce -> u64 with 1 +897:31: replace += with -= in produce +897:31: replace += with *= in produce +898:76: replace == with != in produce +901:31: replace += with -= in produce +901:31: replace += with *= in produce +906:76: replace == with != in produce +911:55: replace == with != in produce +912:35: replace += with -= in produce +912:35: replace += with *= in produce +915:52: replace == with != in produce +928:34: replace & with | in produce +928:34: replace & with ^ in produce +941:5: replace consume -> u64 with 0 +941:5: replace consume -> u64 with 1 +945:17: replace < with == in consume +945:17: replace < with > in consume +945:17: replace < with <= in consume +950:27: replace += with -= in consume +950:27: replace += with *= in consume +951:22: replace == with != in consume +954:27: replace += with -= in consume +954:27: replace += with *= in consume +956:22: replace == with != in consume +962:25: replace == with != in consume +963:31: replace += with -= in consume +963:31: replace += with *= in consume +966:22: replace == with != in consume +977:46: replace & with | in consume +977:46: replace & with ^ in consume +980:15: replace += with -= in consume +980:15: replace += with *= in consume +``` + +### src/core_affinity.rs (54) + +``` +108:5: replace within_class_pair -> Option<(ProcessorPlace, ProcessorPlace)> with None +110:48: replace == with != in within_class_pair +116:41: replace && with || in within_class_pair +116:31: replace != with == in within_class_pair +116:59: replace == with != in within_class_pair +122:5: replace efficiency_classes -> Vec with vec![] +122:5: replace efficiency_classes -> Vec with vec![0] +122:5: replace efficiency_classes -> Vec with vec![1] +197:61: replace * with + in RunPlan::timed_runs +216:9: replace RunPlan::estimated_seconds -> f64 with 0.0 +216:9: replace RunPlan::estimated_seconds -> f64 with 1.0 +216:44: replace * with + in RunPlan::estimated_seconds +216:44: replace * with / in RunPlan::estimated_seconds +216:28: replace * with + in RunPlan::estimated_seconds +216:28: replace * with / in RunPlan::estimated_seconds +520:5: replace assert_group_support with () +645:48: replace / with % in measure +645:48: replace / with * in measure +653:46: replace / with % in measure +653:46: replace / with * in measure +654:46: replace / with % in measure +654:46: replace / with * in measure +655:46: replace / with % in measure +655:46: replace / with * in measure +679:48: replace / with % in measure +679:48: replace / with * in measure +686:46: replace / with % in measure +686:46: replace / with * in measure +687:46: replace / with % in measure +687:46: replace / with * in measure +688:46: replace / with % in measure +688:46: replace / with * in measure +715:52: replace / with % in measure +715:52: replace / with * in measure +722:50: replace / with % in measure +723:50: replace / with % in measure +722:50: replace / with * in measure +723:50: replace / with * in measure +724:50: replace / with % in measure +724:50: replace / with * in measure +749:9: replace Observation::get -> Option with None +751:48: replace && with || in Observation::get +751:35: replace == with != in Observation::get +751:62: replace == with != in Observation::get +763:9: replace Observation::node_pairs_measured -> Vec<(u32, u32)> with vec![] +763:9: replace Observation::node_pairs_measured -> Vec<(u32, u32)> with vec![(0, 1)] +763:9: replace Observation::node_pairs_measured -> Vec<(u32, u32)> with vec![(0, 0)] +763:9: replace Observation::node_pairs_measured -> Vec<(u32, u32)> with vec![(1, 0)] +763:9: replace Observation::node_pairs_measured -> Vec<(u32, u32)> with vec![(1, 1)] +786:9: replace Observation::node_pair_rows -> Vec with vec![] +789:70: replace && with || in Observation::node_pair_rows +789:62: replace == with != in Observation::node_pair_rows +789:84: replace == with != in Observation::node_pair_rows +829:9: replace Observation::placements -> Vec with vec![] +``` + +### src/fingerprint.rs (26) + +``` +207:9: replace Slice::same_cache_domain -> Option with None +207:9: replace Slice::same_cache_domain -> Option with Some(true) +207:9: replace Slice::same_cache_domain -> Option with Some(false) +212:42: replace == with != in Slice::same_cache_domain +220:9: replace Slice::same_efficiency_class -> Option with None +220:9: replace Slice::same_efficiency_class -> Option with Some(true) +220:9: replace Slice::same_efficiency_class -> Option with Some(false) +225:40: replace == with != in Slice::same_efficiency_class +231:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +367:65: replace > with == in Fingerprint::from_topology +367:65: replace > with < in Fingerprint::from_topology +367:65: replace > with >= in Fingerprint::from_topology +380:43: replace == with != in Fingerprint::from_topology +382:44: replace += with *= in Fingerprint::from_topology +412:34: replace > with == in Fingerprint::from_topology +412:34: replace > with < in Fingerprint::from_topology +412:34: replace > with >= in Fingerprint::from_topology +482:22: replace > with < in ::fmt +502:5: replace discover_places -> std::io::Result> with Ok(vec![]) +543:9: replace MissingPlacement::what -> &'static str with "" +543:9: replace MissingPlacement::what -> &'static str with "xyzzy" +694:25: replace match guard !any_core_domain with true in places_from_topology +694:67: replace | with ^ in places_from_topology +708:25: replace match guard !any_core_domain with true in places_from_topology +749:5: replace print_banner with () +772:5: replace print_banner_with with () +``` + +### src/machine.rs (14) + +``` +131:5: replace read_cpu_model -> Option with None +131:5: replace read_cpu_model -> Option with Some("xyzzy".into()) +136:21: delete ! in read_cpu_model +212:5: replace detect_virtualisation -> (VirtualisationHint, Option) with (Default::default(), None) +268:35: replace * with + in read_registry_string +268:35: replace * with / in read_registry_string +286:15: replace == with != in read_registry_string +288:31: replace * with + in read_registry_string +288:31: replace * with / in read_registry_string +308:32: replace / with * in read_registry_string +324:5: replace read_registry_u32 -> Option with None +324:5: replace read_registry_u32 -> Option with Some(0) +324:5: replace read_registry_u32 -> Option with Some(1) +346:13: replace == with != in read_registry_u32 +``` + +### src/paste_json.rs (13) + +``` +89:9: replace for NodeVisitor>::expecting -> fmt::Result with Ok(Default::default()) +245:31: replace match guard !items.is_empty() with true in write_value +236:66: replace + with - in write_value +236:66: replace + with * in write_value +236:49: replace + with * in write_value +247:32: replace + with * in write_value +284:63: replace + with - in write_filled +284:71: replace > with >= in write_filled +284:63: replace + with * in write_filled +284:49: replace + with * in write_filled +284:37: replace + with - in write_filled +284:37: replace + with * in write_filled +290:12: delete ! in write_filled +``` + +### src/bin/placement_probe/main.rs (10) + +``` +45:5: replace main -> ExitCode with Default::default() +53:5: replace run -> ExitCode with Default::default() +190:8: delete ! in run +162:25: replace != with == in run +339:5: replace write_backup with () +435:27: replace match guard error.kind() == std::io::ErrorKind::AlreadyExists with true in write_backup_with +478:36: replace == with != in write_temporary +491:27: replace match guard error.kind() == std::io::ErrorKind::AlreadyExists with true in write_temporary +564:5: replace help -> String with String::new() +564:5: replace help -> String with "xyzzy".into() +``` + +### src/build_identity.rs (8) + +``` +30:9: replace BuildSource::label -> &'static str with "" +30:9: replace BuildSource::label -> &'static str with "xyzzy" +43:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +91:17: delete match arm "0" in BuildIdentity::current +90:17: delete match arm "1" in BuildIdentity::current +95:17: delete match arm "ci" in BuildIdentity::current +96:17: delete match arm "local" in BuildIdentity::current +140:5: replace non_empty -> Option<&'static str> with None +``` + +### src/bin/placement_probe/sink.rs (2) + +``` +51:9: replace ::line with () +55:9: replace ::problem with () +``` + +### src/record.rs (1) + +``` +336:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +``` + +### src/submission.rs (1) + +``` +54:14: replace ^= with |= in checksum +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/peer_index_cache.rs (4) + +``` +557:16: replace += with *= in observed_node_of_region +565:5: replace page_size -> usize with 0 +703:74: replace == with != in time_model_placed +738:9: replace >::drop with () +``` diff --git a/mutation-sweeps/2026-09-02/windows-platform-probes.md b/mutation-sweeps/2026-09-02/windows-platform-probes.md new file mode 100644 index 00000000..43ba78d1 --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-platform-probes.md @@ -0,0 +1,670 @@ +# Mutation survivors -- windows-platform-probes + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 102 +- survived: 511 +- timeout: 5 + +## Survived + +### src/ioring.rs (81) + +``` +98:9: replace IoRingSupport::measured -> Option with None +141:9: replace Ring::submit_and_wait -> (i32, u32) with (0, 0) +141:9: replace Ring::submit_and_wait -> (i32, u32) with (0, 1) +141:9: replace Ring::submit_and_wait -> (i32, u32) with (1, 0) +141:9: replace Ring::submit_and_wait -> (i32, u32) with (1, 1) +141:9: replace Ring::submit_and_wait -> (i32, u32) with (-1, 0) +141:9: replace Ring::submit_and_wait -> (i32, u32) with (-1, 1) +164:9: replace Ring::collect -> Option with None +164:9: replace Ring::collect -> Option with Some(Default::default()) +167:25: replace > with == in Ring::collect +167:25: replace > with < in Ring::collect +167:25: replace > with >= in Ring::collect +169:27: replace -= with += in Ring::collect +169:27: replace -= with /= in Ring::collect +201:9: replace Ring::pop -> Option with Some(Default::default()) +201:9: replace Ring::pop -> Option with None +205:17: replace == with != in Ring::pop +212:9: replace ::drop with () +219:5: replace is_available -> bool with true +219:5: replace is_available -> bool with false +268:9: replace ::drop with () +340:9: replace PipePair::server_handle -> *mut c_void with Default::default() +345:9: replace PipePair::fill with () +377:9: replace RegistrationObservation::replaces -> bool with true +377:9: replace RegistrationObservation::replaces -> bool with false +377:45: replace && with || in RegistrationObservation::replaces +377:48: delete ! in RegistrationObservation::replaces +383:9: replace RegistrationObservation::appends -> bool with true +383:9: replace RegistrationObservation::appends -> bool with false +383:45: replace && with || in RegistrationObservation::appends +391:5: replace register -> i32 with 0 +391:5: replace register -> i32 with 1 +391:5: replace register -> i32 with -1 +399:14: replace < with == in register +399:14: replace < with > in register +399:14: replace < with <= in register +404:18: replace == with != in register +406:22: replace < with == in register +406:22: replace < with > in register +406:22: replace < with <= in register +415:5: replace read_through_index -> i32 with 0 +415:5: replace read_through_index -> i32 with 1 +415:5: replace read_through_index -> i32 with -1 +431:14: replace < with == in read_through_index +431:14: replace < with > in read_through_index +431:14: replace < with <= in read_through_index +436:18: replace == with != in read_through_index +438:22: replace < with == in read_through_index +438:22: replace < with > in read_through_index +438:22: replace < with <= in read_through_index +453:5: replace read_raw_handle -> (i32, usize, u8) with (0, 0, 0) +453:5: replace read_raw_handle -> (i32, usize, u8) with (0, 0, 1) +453:5: replace read_raw_handle -> (i32, usize, u8) with (0, 1, 0) +453:5: replace read_raw_handle -> (i32, usize, u8) with (0, 1, 1) +453:5: replace read_raw_handle -> (i32, usize, u8) with (1, 0, 0) +453:5: replace read_raw_handle -> (i32, usize, u8) with (1, 0, 1) +453:5: replace read_raw_handle -> (i32, usize, u8) with (1, 1, 0) +453:5: replace read_raw_handle -> (i32, usize, u8) with (1, 1, 1) +453:5: replace read_raw_handle -> (i32, usize, u8) with (-1, 0, 0) +453:5: replace read_raw_handle -> (i32, usize, u8) with (-1, 0, 1) +453:5: replace read_raw_handle -> (i32, usize, u8) with (-1, 1, 0) +453:5: replace read_raw_handle -> (i32, usize, u8) with (-1, 1, 1) +482:14: replace < with == in read_raw_handle +482:14: replace < with > in read_raw_handle +482:14: replace < with <= in read_raw_handle +487:18: replace == with != in read_raw_handle +489:23: replace < with == in read_raw_handle +489:23: replace < with > in read_raw_handle +489:23: replace < with <= in read_raw_handle +539:70: replace >= with < in measure_registration +540:69: replace >= with < in measure_registration +579:9: replace ThreadAgnosticism::survives_submitter_exit -> bool with true +579:9: replace ThreadAgnosticism::survives_submitter_exit -> bool with false +579:65: replace && with || in ThreadAgnosticism::survives_submitter_exit +579:40: replace && with || in ThreadAgnosticism::survives_submitter_exit +579:60: replace >= with < in ThreadAgnosticism::survives_submitter_exit +683:34: replace && with || in measure_thread_agnosticism +683:30: replace > with == in measure_thread_agnosticism +683:30: replace > with < in measure_thread_agnosticism +683:30: replace > with >= in measure_thread_agnosticism +683:83: replace == with != in measure_thread_agnosticism +``` + +### src/queue_contention.rs (55) + +``` +125:9: replace Observation::find -> Option with None +127:44: replace && with || in Observation::find +127:35: replace == with != in Observation::find +127:61: replace == with != in Observation::find +138:9: replace Observation::scaling -> Option with None +138:9: replace Observation::scaling -> Option with Some(0.0) +138:9: replace Observation::scaling -> Option with Some(1.0) +138:9: replace Observation::scaling -> Option with Some(-1.0) +140:37: replace / with % in Observation::scaling +140:37: replace / with * in Observation::scaling +195:57: replace / with % in median_run +195:57: replace / with * in median_run +197:29: replace * with + in median_run +197:29: replace * with / in median_run +201:39: replace / with % in median_run +201:39: replace / with * in median_run +202:35: replace / with * in median_run +202:35: replace / with % in median_run +202:52: replace / with * in median_run +202:52: replace / with % in median_run +214:5: replace time_contended_atomic -> Repetition with Default::default() +219:48: replace + with - in time_contended_atomic +219:48: replace + with * in time_contended_atomic +239:5: replace capacity_for -> usize with 0 +239:5: replace capacity_for -> usize with 1 +239:16: replace * with + in capacity_for +239:16: replace * with / in capacity_for +256:40: replace + with - in start_barrier +256:40: replace + with * in start_barrier +260:5: replace time_isolated_mpsc -> Repetition with Default::default() +270:61: replace + with - in time_isolated_mpsc +270:61: replace + with * in time_isolated_mpsc +270:39: replace * with + in time_isolated_mpsc +270:39: replace * with / in time_isolated_mpsc +287:5: replace time_isolated_reserving -> Repetition with Default::default() +297:61: replace + with - in time_isolated_reserving +297:61: replace + with * in time_isolated_reserving +297:39: replace * with + in time_isolated_reserving +297:39: replace * with / in time_isolated_reserving +316:5: replace time_drained_mpsc -> Repetition with Default::default() +323:40: replace + with - in time_drained_mpsc +323:40: replace + with * in time_drained_mpsc +330:15: delete ! in time_drained_mpsc +345:68: replace + with - in time_drained_mpsc +345:68: replace + with * in time_drained_mpsc +345:46: replace * with + in time_drained_mpsc +345:46: replace * with / in time_drained_mpsc +380:5: replace time_drained_reserving -> Repetition with Default::default() +385:40: replace + with - in time_drained_reserving +385:40: replace + with * in time_drained_reserving +390:15: delete ! in time_drained_reserving +405:68: replace + with - in time_drained_reserving +405:68: replace + with * in time_drained_reserving +405:46: replace * with + in time_drained_reserving +405:46: replace * with / in time_drained_reserving +``` + +### src/device_map.rs (52) + +``` +77:9: replace MapObservation::is_found -> bool with true +77:9: replace MapObservation::is_found -> bool with false +88:9: replace MapObservation::entries -> Vec<&str> with vec![] +88:9: replace MapObservation::entries -> Vec<&str> with vec![""] +88:9: replace MapObservation::entries -> Vec<&str> with vec!["xyzzy"] +89:45: delete ! in MapObservation::entries +96:9: replace MapObservation::is_exactly -> bool with true +96:24: replace == with != in MapObservation::is_exactly +96:9: replace MapObservation::is_exactly -> bool with false +121:9: replace DeviceMapFinding::impersonation_changes_the_map -> bool with true +121:9: replace DeviceMapFinding::impersonation_changes_the_map -> bool with false +121:37: replace && with || in DeviceMapFinding::impersonation_changes_the_map +121:40: delete ! in DeviceMapFinding::impersonation_changes_the_map +131:9: replace DeviceMapFinding::claim_is_exclusive -> bool with true +131:9: replace DeviceMapFinding::claim_is_exclusive -> bool with false +140:9: replace DeviceMapFinding::sessions_differ -> bool with true +140:9: replace DeviceMapFinding::sessions_differ -> bool with false +144:13: delete match arm (Some(own), Some(anonymous)) in DeviceMapFinding::sessions_differ +144:49: replace != with == in DeviceMapFinding::sessions_differ +158:5: replace effective_logon_session -> Option<(u32, i32)> with None +158:5: replace effective_logon_session -> Option<(u32, i32)> with Some((0, 0)) +158:5: replace effective_logon_session -> Option<(u32, i32)> with Some((0, 1)) +158:5: replace effective_logon_session -> Option<(u32, i32)> with Some((0, -1)) +158:5: replace effective_logon_session -> Option<(u32, i32)> with Some((1, 0)) +158:5: replace effective_logon_session -> Option<(u32, i32)> with Some((1, 1)) +158:5: replace effective_logon_session -> Option<(u32, i32)> with Some((1, -1)) +162:15: replace == with != in effective_logon_session +165:19: replace == with != in effective_logon_session +185:13: replace == with != in effective_logon_session +207:29: replace == with != in query +261:9: replace SubstDrive::claim -> Option with None +282:53: replace != with == in SubstDrive::claim +282:21: replace & with | in SubstDrive::claim +282:21: replace & with ^ in SubstDrive::claim +282:26: replace << with >> in SubstDrive::claim +282:44: replace - with / in SubstDrive::claim +282:44: replace - with + in SubstDrive::claim +319:9: replace SubstDrive::letter -> &str with "" +292:24: replace == with != in SubstDrive::claim +325:9: replace SubstDrive::target -> &str with "" +319:9: replace SubstDrive::letter -> &str with "xyzzy" +331:9: replace ::drop with () +325:9: replace SubstDrive::target -> &str with "xyzzy" +342:5: replace remove with () +344:63: replace | with & in remove +344:35: replace | with & in remove +344:63: replace | with ^ in remove +344:35: replace | with ^ in remove +353:5: replace wide -> Vec with vec![] +353:5: replace wide -> Vec with vec![0] +353:5: replace wide -> Vec with vec![1] +368:45: replace == with != in measure_with_subst +``` + +### src/bin/core_affinity.rs (48) + +``` +14:5: replace main -> std::io::Result<()> with Ok(()) +20:5: replace render -> String with String::new() +20:5: replace render -> String with "xyzzy".into() +82:8: delete ! in render +103:64: replace && with || in render +103:55: replace == with != in render +103:78: replace == with != in render +107:64: replace && with || in render +107:55: replace == with != in render +107:78: replace == with != in render +198:26: replace < with == in render +198:26: replace < with > in render +198:26: replace < with <= in render +220:9: replace && with || in render +219:22: delete ! in render +220:12: delete ! in render +280:50: replace / with % in render +280:50: replace / with * in render +312:18: replace > with < in render +312:18: replace > with == in render +312:18: replace > with >= in render +312:25: replace * with + in render +312:25: replace * with / in render +329:24: replace > with == in render +329:24: replace > with < in render +329:24: replace > with >= in render +329:32: replace * with + in render +329:32: replace * with / in render +426:43: replace / with % in render +426:43: replace / with * in render +427:34: replace >= with < in render +429:27: replace <= with > in render +445:23: replace > with == in render +445:23: replace > with < in render +445:23: replace > with >= in render +478:5: replace render_node_distances with () +546:55: replace > with == in render_node_distances +546:55: replace > with < in render_node_distances +546:55: replace > with >= in render_node_distances +549:54: replace < with == in render_node_distances +549:54: replace < with > in render_node_distances +549:54: replace < with <= in render_node_distances +555:20: replace == with != in render_node_distances +581:21: replace < with == in render_node_distances +581:21: replace < with > in render_node_distances +581:21: replace < with <= in render_node_distances +581:14: replace / with % in render_node_distances +581:14: replace / with * in render_node_distances +``` + +### src/pool_growth.rs (46) + +``` +69:9: replace Gate::wait with () +75:9: replace Gate::open with () +82:9: replace ::drop with () +108:9: replace GrowthObservation::saturated -> bool with true +108:9: replace GrowthObservation::saturated -> bool with false +108:36: replace >= with < in GrowthObservation::saturated +117:9: replace GrowthObservation::one_thread_each -> bool with false +117:9: replace GrowthObservation::one_thread_each -> bool with true +117:31: replace == with != in GrowthObservation::one_thread_each +124:9: replace GrowthObservation::slowest_arrival -> Duration with Default::default() +136:9: replace GrowthObservation::largest_gap -> Duration with Default::default() +159:9: replace GrowthObservation::throttles_after -> Option with None +159:9: replace GrowthObservation::throttles_after -> Option with Some(0) +159:9: replace GrowthObservation::throttles_after -> Option with Some(1) +163:62: replace >= with < in GrowthObservation::throttles_after +164:32: replace + with - in GrowthObservation::throttles_after +164:32: replace + with * in GrowthObservation::throttles_after +190:9: replace ::drop with () +284:35: replace + with - in measure_growth +285:37: replace && with || in measure_growth +285:26: replace < with == in measure_growth +285:26: replace < with > in measure_growth +285:26: replace < with <= in measure_growth +285:75: replace < with == in measure_growth +285:75: replace < with > in measure_growth +285:75: replace < with <= in measure_growth +344:9: replace RaiseObservation::saturated_before_raise -> bool with true +344:35: replace == with != in RaiseObservation::saturated_before_raise +344:9: replace RaiseObservation::saturated_before_raise -> bool with false +401:35: replace + with - in measure_raise_while_saturated +402:37: replace && with || in measure_raise_while_saturated +402:26: replace < with == in measure_raise_while_saturated +402:26: replace < with > in measure_raise_while_saturated +402:26: replace < with <= in measure_raise_while_saturated +402:75: replace < with == in measure_raise_while_saturated +402:75: replace < with > in measure_raise_while_saturated +402:75: replace < with <= in measure_raise_while_saturated +414:35: replace + with - in measure_raise_while_saturated +415:37: replace && with || in measure_raise_while_saturated +415:26: replace < with == in measure_raise_while_saturated +415:26: replace < with > in measure_raise_while_saturated +415:26: replace < with <= in measure_raise_while_saturated +415:75: replace <= with > in measure_raise_while_saturated +418:58: replace > with == in measure_raise_while_saturated +418:58: replace > with < in measure_raise_while_saturated +418:58: replace > with >= in measure_raise_while_saturated +``` + +### src/bin/peer_index_cache.rs (34) + +``` +12:5: replace main with () +49:9: replace / with % in main +48:42: replace - with + in main +49:9: replace / with * in main +58:14: replace > with == in main +48:42: replace - with / in main +58:14: replace > with < in main +58:14: replace > with >= in main +75:47: replace / with * in main +75:47: replace / with % in main +107:39: replace / with % in main +107:39: replace / with * in main +108:39: replace / with % in main +108:39: replace / with * in main +110:44: replace / with % in main +110:44: replace / with * in main +112:44: replace / with % in main +112:44: replace / with * in main +113:43: replace / with % in main +113:43: replace / with * in main +125:38: replace > with == in main +125:38: replace > with < in main +125:38: replace > with >= in main +126:8: delete ! in main +130:23: replace >= with < in main +135:23: replace <= with > in main +141:31: replace < with == in main +141:31: replace < with > in main +141:31: replace < with <= in main +165:44: replace / with % in main +165:44: replace / with * in main +172:23: replace < with == in main +172:23: replace < with > in main +172:23: replace < with <= in main +``` + +### src/completion_port.rs (33) + +``` +91:9: replace ReadAttempt::succeeded -> bool with true +91:9: replace ReadAttempt::succeeded -> bool with false +91:60: replace && with || in ReadAttempt::succeeded +91:31: replace && with || in ReadAttempt::succeeded +91:26: replace >= with < in ReadAttempt::succeeded +91:45: replace == with != in ReadAttempt::succeeded +91:79: replace == with != in ReadAttempt::succeeded +125:9: replace CompletionPortFinding::is_valid -> bool with true +125:9: replace CompletionPortFinding::is_valid -> bool with false +127:13: replace && with || in CompletionPortFinding::is_valid +126:13: replace && with || in CompletionPortFinding::is_valid +135:9: replace CompletionPortFinding::association_forecloses_ioring -> bool with true +135:9: replace CompletionPortFinding::association_forecloses_ioring -> bool with false +137:13: replace && with || in CompletionPortFinding::association_forecloses_ioring +136:13: replace && with || in CompletionPortFinding::association_forecloses_ioring +136:16: delete ! in CompletionPortFinding::association_forecloses_ioring +137:16: delete ! in CompletionPortFinding::association_forecloses_ioring +145:9: replace CompletionPortFinding::threadpool_io_forecloses_ioring -> bool with true +145:9: replace CompletionPortFinding::threadpool_io_forecloses_ioring -> bool with false +145:25: replace && with || in CompletionPortFinding::threadpool_io_forecloses_ioring +145:28: delete ! in CompletionPortFinding::threadpool_io_forecloses_ioring +186:9: replace ::drop with () +295:5: replace read_through_port -> bool with true +295:5: replace read_through_port -> bool with false +314:16: replace == with != in read_through_port +317:18: replace != with == in read_through_port +363:77: replace && with || in read_through_port +363:52: replace && with || in read_through_port +363:19: replace && with || in read_through_port +363:14: replace != with == in read_through_port +363:59: replace == with != in read_through_port +363:37: replace == with != in read_through_port +363:90: replace == with != in read_through_port +``` + +### src/doorbell_cost.rs (26) + +``` +95:9: replace Observation::get -> Option with None +95:9: replace Observation::get -> Option with Some(0.0) +95:9: replace Observation::get -> Option with Some(1.0) +95:9: replace Observation::get -> Option with Some(-1.0) +97:31: replace == with != in Observation::get +111:9: replace Observation::doorbell_share_of_submit -> Option with None +111:9: replace Observation::doorbell_share_of_submit -> Option with Some(0.0) +111:9: replace Observation::doorbell_share_of_submit -> Option with Some(1.0) +111:9: replace Observation::doorbell_share_of_submit -> Option with Some(-1.0) +113:17: replace > with == in Observation::doorbell_share_of_submit +113:17: replace > with < in Observation::doorbell_share_of_submit +113:17: replace > with >= in Observation::doorbell_share_of_submit +113:43: replace / with % in Observation::doorbell_share_of_submit +113:43: replace / with * in Observation::doorbell_share_of_submit +131:49: replace / with % in time_loop +131:49: replace / with * in time_loop +222:5: replace measure_park_and_wake -> Option with None +222:5: replace measure_park_and_wake -> Option with Some(0.0) +222:5: replace measure_park_and_wake -> Option with Some(1.0) +222:5: replace measure_park_and_wake -> Option with Some(-1.0) +244:23: replace != with == in measure_park_and_wake +229:15: replace == with != in measure_park_and_wake +257:66: replace != with == in measure_park_and_wake +271:9: replace && with || in measure_park_and_wake +271:55: replace / with % in measure_park_and_wake +271:55: replace / with * in measure_park_and_wake +``` + +### src/error_mode.rs (25) + +``` +58:21: replace && with || in BitOutcome::is_settable +58:39: replace & with | in BitOutcome::is_settable +64:9: replace BitOutcome::is_silently_dropped -> bool with false +64:50: replace != with == in BitOutcome::is_silently_dropped +64:39: replace & with | in BitOutcome::is_silently_dropped +64:39: replace & with ^ in BitOutcome::is_silently_dropped +120:9: replace ::drop with () +141:28: replace == with != in with_thread_mode +149:11: replace != with == in with_thread_mode +185:31: replace | with ^ in settable_bits +196:5: replace combined_invalid_installs_nothing -> (bool, u32) with (true, 0) +196:5: replace combined_invalid_installs_nothing -> (bool, u32) with (true, 1) +196:44: replace | with & in combined_invalid_installs_nothing +196:44: replace | with ^ in combined_invalid_installs_nothing +197:26: replace | with & in combined_invalid_installs_nothing +197:26: replace | with ^ in combined_invalid_installs_nothing +226:9: replace ProcessVersusThread::is_independent -> bool with true +227:13: replace && with || in ProcessVersusThread::is_independent +226:27: replace & with | in ProcessVersusThread::is_independent +282:5: replace alignment_bit_is_sticky_at_process_scope -> (u32, u32) with (0, 0) +282:5: replace alignment_bit_is_sticky_at_process_scope -> (u32, u32) with (0, 1) +282:5: replace alignment_bit_is_sticky_at_process_scope -> (u32, u32) with (1, 0) +282:5: replace alignment_bit_is_sticky_at_process_scope -> (u32, u32) with (1, 1) +283:34: replace | with ^ in alignment_bit_is_sticky_at_process_scope +283:34: replace | with & in alignment_bit_is_sticky_at_process_scope +``` + +### src/cancel_io.rs (14) + +``` +86:9: replace CancelOutcome::returned -> bool with true +86:9: replace CancelOutcome::returned -> bool with false +136:9: replace ::drop with () +86:9: delete ! in CancelOutcome::returned +170:35: replace + with - in cancel_under_watchdog +156:40: replace != with == in cancel_under_watchdog +171:37: replace && with || in cancel_under_watchdog +171:26: replace < with == in cancel_under_watchdog +171:26: replace < with > in cancel_under_watchdog +171:26: replace < with <= in cancel_under_watchdog +171:40: delete ! in cancel_under_watchdog +226:19: delete ! in cancel_against_busy_thread +206:5: replace cancel_against_busy_thread -> Vec with vec![] +235:35: replace + with - in cancel_against_busy_thread +``` + +### src/bin/doorbell_cost.rs (11) + +``` +31:5: replace render -> String with String::new() +23:5: replace main with () +59:19: replace > with == in render +31:5: replace render -> String with "xyzzy".into() +59:19: replace > with < in render +59:19: replace > with >= in render +115:19: replace > with == in render +115:19: replace > with < in render +115:19: replace > with >= in render +130:36: replace / with % in render +130:36: replace / with * in render +``` + +### src/bin/queue_contention.rs (11) + +``` +16:5: replace main with () +68:47: replace match guard plain.nanos_per_push > 0.0 with false in main +68:47: replace match guard plain.nanos_per_push > 0.0 with true in main +68:68: replace > with == in main +68:68: replace > with < in main +68:68: replace > with >= in main +93:5: replace print_table with () +106:5: replace format_scaling -> String with String::new() +106:5: replace format_scaling -> String with "xyzzy".into() +110:5: replace format_nanos -> String with String::new() +110:5: replace format_nanos -> String with "xyzzy".into() +``` + +### src/handle_state.rs (11) + +``` +81:9: replace ::drop with () +107:52: replace | with & in DirHandle::open +107:52: replace | with ^ in DirHandle::open +107:33: replace | with ^ in DirHandle::open +184:56: replace / with % in DirHandle::enumerate +212:9: replace ::drop with () +230:9: replace SingleShot::run -> bool with true +299:9: replace CursorObservation::continued -> bool with true +321:9: replace CursorObservation::restarted -> bool with true +377:5: replace closing_duplicate_preserves_source -> bool with true +397:5: replace query_disturbs_cursor -> (bool, bool) with (true, false) +``` + +### src/request_cost.rs (11) + +``` +101:9: replace Observation::get -> Option with None +101:9: replace Observation::get -> Option with Some(0.0) +101:9: replace Observation::get -> Option with Some(1.0) +101:9: replace Observation::get -> Option with Some(-1.0) +103:31: replace == with != in Observation::get +122:49: replace / with % in time_loop +122:49: replace / with * in time_loop +197:5: replace system_directory -> std::path::PathBuf with Default::default() +207:21: replace || with && in system_directory +207:16: replace == with != in system_directory +207:32: replace >= with < in system_directory +``` + +### src/worker_context.rs (8) + +``` +60:9: replace WorkerContext::is_unimpersonated -> bool with true +60:32: replace && with || in WorkerContext::is_unimpersonated +70:9: replace WorkerContext::critical_error_handler_enabled -> bool with true +95:9: replace IdentityAsymmetry::disagree -> bool with true +95:41: replace && with || in IdentityAsymmetry::disagree +127:20: replace && with || in observe_here +127:15: replace != with == in observe_here +127:23: delete ! in observe_here +``` + +### src/bin/error_mode.rs (7) + +``` +22:5: replace name -> &'static str with "" +22:5: replace name -> &'static str with "xyzzy" +23:9: delete match arm bits::FAIL_CRITICAL_ERRORS in name +24:9: delete match arm bits::NO_GP_FAULT_ERROR_BOX in name +25:9: delete match arm bits::NO_ALIGNMENT_FAULT_EXCEPT in name +26:9: delete match arm bits::NO_OPEN_FILE_ERROR_BOX in name +32:5: replace main with () +``` + +### src/bin/request_cost.rs (7) + +``` +22:5: replace main with () +90:24: replace > with == in main +90:24: replace > with < in main +90:24: replace > with >= in main +111:18: replace > with == in main +111:18: replace > with < in main +111:18: replace > with >= in main +``` + +### src/topology.rs (7) + +``` +137:9: replace Observation::domain_counts -> Vec<(&'static str, usize)> with vec![] +137:9: replace Observation::domain_counts -> Vec<(&'static str, usize)> with vec![("", 1)] +137:9: replace Observation::domain_counts -> Vec<(&'static str, usize)> with vec![("xyzzy", 1)] +226:30: replace += with *= in measure +230:45: replace += with -= in measure +230:45: replace += with *= in measure +279:90: replace != with == in measure +``` + +### src/bin/topology.rs (6) + +``` +19:5: replace main with () +54:22: replace > with == in main +54:22: replace > with < in main +54:22: replace > with >= in main +77:8: delete ! in main +77:51: replace == with != in main +``` + +### src/bin/cancel_io.rs (4) + +``` +19:5: replace describe -> String with String::new() +19:5: replace describe -> String with "xyzzy".into() +45:44: delete ! in main +29:5: replace main with () +``` + +### src/bin/completion_port.rs (4) + +``` +20:5: replace describe with () +30:5: replace report with () +67:8: delete ! in report +99:5: replace main with () +``` + +### src/bin/ioring.rs (3) + +``` +19:5: replace main with () +59:16: delete ! in main +21:8: delete ! in main +``` + +### src/bin/pool_growth.rs (3) + +``` +37:5: replace main with () +17:5: replace report with () +61:8: delete ! in main +``` + +### src/bin/device_map.rs (1) + +``` +19:5: replace main with () +``` + +### src/bin/handle_state.rs (1) + +``` +20:5: replace main with () +``` + +### src/bin/worker_context.rs (1) + +``` +20:5: replace main with () +``` + +### src/report.rs (1) + +``` +50:9: replace ::line with () +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/handle_state.rs (5) + +``` +154:9: replace DirHandle::enumerate -> Result, u32> with Ok(vec![]) +154:9: replace DirHandle::enumerate -> Result, u32> with Ok(vec![String::new()]) +154:9: replace DirHandle::enumerate -> Result, u32> with Ok(vec!["xyzzy".into()]) +190:21: replace == with != in DirHandle::enumerate +193:20: replace += with *= in DirHandle::enumerate +``` diff --git a/mutation-sweeps/2026-09-02/windows-threadpool-sys.md b/mutation-sweeps/2026-09-02/windows-threadpool-sys.md new file mode 100644 index 00000000..89b95a02 --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-threadpool-sys.md @@ -0,0 +1,141 @@ +# Mutation survivors -- windows-threadpool-sys + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 102 +- survived: 44 +- timeout: 18 + +## Survived + +### src/cleanup_group.rs (16) + +``` +433:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +460:9: replace WorkMember<'_>::wait with () +466:9: replace WorkMember<'_>::cancel_pending with () +490:9: replace TimerMember<'_>::set_at with () +496:9: replace TimerMember<'_>::disarm with () +506:9: replace TimerMember<'_>::is_set -> bool with true +512:9: replace TimerMember<'_>::wait with () +518:9: replace TimerMember<'_>::cancel_pending with () +543:9: replace PeriodicTimerMember<'_>::start with () +562:9: replace PeriodicTimerMember<'_>::stop with () +569:9: replace PeriodicTimerMember<'_>::is_running -> bool with true +575:9: replace PeriodicTimerMember<'_>::wait with () +585:9: replace PeriodicTimerMember<'_>::stop_and_drain with () +625:9: replace WaitMember<'_>::disarm with () +631:9: replace WaitMember<'_>::wait with () +637:9: replace WaitMember<'_>::cancel_pending with () +``` + +### src/wait.rs (10) + +``` +50:5: replace relative_filetime -> FILETIME with Default::default() +53:58: replace / with % in relative_filetime +55:17: delete - in relative_filetime +58:31: replace >> with << in relative_filetime +133:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +357:9: replace WaitContext::release_suppression with () +348:17: replace != with == in WaitContext::suppress_and_disarm +380:9: replace >::fmt -> std::fmt::Result with Ok(Default::default()) +396:9: replace WaitActivation<'_>::is_signalled -> bool with false +735:9: replace ThreadpoolWait::wait with () +``` + +### src/io.rs (6) + +``` +458:9: replace ThreadpoolIo::wait with () +468:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +531:9: replace ::fmt -> fmt::Result with Ok(Default::default()) +477:18: replace > with >= in ::drop +558:9: replace IoCompletion::error -> Option with None +558:27: replace == with != in IoCompletion::error +``` + +### src/timer.rs (6) + +``` +100:39: replace / with % in absolute_filetime +119:5: replace millis_u32 -> u32 with 1 +199:9: replace TimerContext::release_suppression with () +190:18: replace != with == in TimerContext::suppress_and_disarm +232:9: replace >::fmt -> std::fmt::Result with Ok(Default::default()) +680:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +``` + +### src/timer/periodic.rs (3) + +``` +37:9: replace >::fmt -> std::fmt::Result with Ok(Default::default()) +378:9: replace ThreadpoolPeriodicTimer::wait with () +442:9: replace ::fmt -> std::fmt::Result with Ok(Default::default()) +``` + +### src/callback_env.rs (2) + +``` +62:45: replace << with >> +239:9: replace CallbackEnviron<'pool>::from_inner -> Self with Default::default() +``` + +### src/pool.rs (1) + +``` +239:9: replace ::drop with () +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/io.rs (6) + +``` +275:9: replace ThreadpoolIo::outstanding -> usize with 0 +398:9: replace ThreadpoolIo::cancel -> io::Result<()> with Ok(()) +418:9: replace ThreadpoolIo::cancel_all -> io::Result<()> with Ok(()) +462:9: replace ThreadpoolIo::raw_handle -> HANDLE with Default::default() +477:18: replace > with < in ::drop +477:18: replace > with == in ::drop +``` + +### src/timer.rs (4) + +``` +83:56: replace / with * in relative_filetime +478:9: replace ThreadpoolTimer::set_after with () +557:9: replace ThreadpoolTimer::cancel_pending with () +613:9: replace ThreadpoolTimer::into_parts -> (PTP_TIMER, *mut core::ffi::c_void) with (Default::default(), Default::default()) +``` + +### src/wait.rs (3) + +``` +113:9: replace WaitTarget::raw -> HANDLE with Default::default() +477:9: replace WaitActivation<'_>::rearm_reporting -> bool with true +714:9: replace ThreadpoolWait::arm with () +``` + +### src/cleanup_group.rs (2) + +``` +484:9: replace TimerMember<'_>::set_after with () +619:9: replace WaitMember<'_>::arm with () +``` + +### src/timer/periodic.rs (2) + +``` +358:9: replace ThreadpoolPeriodicTimer::stop with () +409:9: replace ThreadpoolPeriodicTimer::into_parts -> (PTP_TIMER, *mut core::ffi::c_void, Duration) with (Default::default(), Default::default(), Default::default()) +``` + +### src/work.rs (1) + +``` +139:9: replace ThreadpoolWork::into_parts -> (PTP_WORK, *mut c_void) with (Default::default(), Default::default()) +``` diff --git a/mutation-sweeps/2026-09-02/windows-topology-sys.md b/mutation-sweeps/2026-09-02/windows-topology-sys.md new file mode 100644 index 00000000..73971b0b --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-topology-sys.md @@ -0,0 +1,30 @@ +# Mutation survivors -- windows-topology-sys + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 127 +- survived: 5 +- timeout: 0 + +**Partly addressed already** in commit(s) `e198b8a` on branch +`mikegrier/deferred-namespace-ops`. The entries below are as the sweep +found them and have NOT been pruned -- re-run before treating any single +line as outstanding. + +## Survived + +### src/domain.rs (4) + +``` +250:21: replace serde_impl:: for AttributeValue>::deserialize:: for ValueVisitor>::expecting -> fmt::Result with Ok(Default::default()) +297:9: replace serde_impl::as_bool -> Result with Ok(true) +313:13: delete match arm AttributeValue::SignedInteger(n) in serde_impl::as_u64 +375:48: replace match guard map.len() == 1 with true in serde_impl::cache_kind_from_value +``` + +### src/processor_set.rs (1) + +``` +30:9: replace ProcessorSet::empty -> Self with Default::default() +``` diff --git a/mutation-sweeps/2026-09-02/windows-waitable-queues.md b/mutation-sweeps/2026-09-02/windows-waitable-queues.md new file mode 100644 index 00000000..6a592abf --- /dev/null +++ b/mutation-sweeps/2026-09-02/windows-waitable-queues.md @@ -0,0 +1,193 @@ +# Mutation survivors -- windows-waitable-queues + +Sweep of 2026-09-02. See [README.md](README.md) for the command, the +workspace-wide totals, and how to read a timeout. + +- caught: 400 +- survived: 4 +- timeout: 120 + +**Partly addressed already** in commit(s) `0599a5d, 40bc19d` on branch +`mikegrier/deferred-namespace-ops`. The entries below are as the sweep +found them and have NOT been pruned -- re-run before treating any single +line as outstanding. + +## Survived + +### src/reserving_mpsc.rs (2) + +``` +139:61: replace - with / +221:42: replace | with ^ in claim_word +``` + +### src/metrics.rs (1) + +``` +118:18: replace > with >= in Metrics::record_depth +``` + +### src/slotwise_mpsc.rs (1) + +``` +475:27: replace > with < in Producer::push +``` + +## Timed out + +Not survivors. Read the README's note before treating these as gaps. + +### src/reserving_mpsc.rs (48) + +``` +115:49: replace - with + +115:49: replace - with / +204:5: replace position_of -> u32 with 0 +204:5: replace position_of -> u32 with 1 +204:11: replace & with | in position_of +204:11: replace & with ^ in position_of +412:9: replace Shared::capacity_u32 -> u32 with 0 +433:9: replace Shared::has_room_beyond_reservations -> bool with true +433:9: replace Shared::has_room_beyond_reservations -> bool with false +439:18: replace < with == in Shared::has_room_beyond_reservations +439:18: replace < with > in Shared::has_room_beyond_reservations +439:18: replace < with <= in Shared::has_room_beyond_reservations +454:9: replace Shared::len -> usize with 0 +454:9: replace Shared::len -> usize with 1 +475:9: replace Shared::remaining -> usize with 1 +490:9: replace Shared::has_ready_item -> bool with true +490:9: replace Shared::has_ready_item -> bool with false +491:50: replace & with ^ in Shared::has_ready_item +492:47: replace == with != in Shared::has_ready_item +508:9: replace Shared::release_producer with () +508:58: replace != with == in Shared::release_producer +649:9: replace Producer::push -> Result<(), PushError> with Ok(()) +656:16: delete ! in Producer::push +665:28: replace != with == in Producer::push +735:28: replace != with == in Producer::reserve +772:9: replace Producer::capacity -> usize with 0 +772:9: replace Producer::capacity -> usize with 1 +778:9: replace Producer::len -> usize with 0 +778:9: replace Producer::len -> usize with 1 +801:9: replace Producer::is_full -> bool with false +801:26: replace == with != in Producer::is_full +813:9: replace Producer::remaining -> usize with 1 +852:9: replace >::drop with () +969:9: replace >::drop with () +1010:9: replace Consumer::pop -> Option with None +1011:57: replace & with | in Consumer::pop +1011:57: replace & with ^ in Consumer::pop +1014:50: replace != with == in Consumer::pop +1088:9: replace Consumer::is_disconnected -> bool with true +1088:9: replace Consumer::is_disconnected -> bool with false +1088:55: replace == with != in Consumer::is_disconnected +1142:9: replace Consumer::arm -> io::Result with Ok(true) +1142:9: replace Consumer::arm -> io::Result with Ok(false) +1149:12: delete ! in Consumer::arm +1188:9: replace >::pop -> Option with None +1196:9: replace >::arm -> io::Result with Ok(false) +1200:9: replace >::is_disconnected -> bool with true +1200:9: replace >::is_disconnected -> bool with false +``` + +### src/slotwise_mpsc.rs (38) + +``` +226:24: replace - with + in build +226:24: replace - with / in build +331:9: replace Shared::slot_index -> usize with 1 +331:9: replace Shared::slot_index -> usize with 0 +331:29: replace & with | in Shared::slot_index +331:29: replace & with ^ in Shared::slot_index +356:9: replace Shared::len -> usize with 0 +356:9: replace Shared::len -> usize with 1 +378:9: replace Shared::has_ready_item -> bool with false +378:9: replace Shared::has_ready_item -> bool with true +380:47: replace == with != in Shared::has_ready_item +449:9: replace Producer::push -> Result<(), PushError> with Ok(()) +460:27: replace < with == in Producer::push +460:27: replace < with <= in Producer::push +460:27: replace < with > in Producer::push +467:20: delete ! in Producer::push +475:27: replace > with == in Producer::push +475:27: replace > with >= in Producer::push +606:9: replace Producer::capacity -> usize with 0 +498:16: delete ! in Producer::push +606:9: replace Producer::capacity -> usize with 1 +615:9: replace Producer::len -> usize with 0 +615:9: replace Producer::len -> usize with 1 +681:9: replace >::drop with () +681:65: replace != with == in >::drop +722:9: replace Consumer::pop -> Option with None +732:21: replace != with == in Consumer::pop +796:9: replace Consumer::is_disconnected -> bool with true +796:9: replace Consumer::is_disconnected -> bool with false +796:55: replace == with != in Consumer::is_disconnected +917:9: replace Consumer::arm -> io::Result with Ok(false) +924:12: delete ! in Consumer::arm +978:9: replace >::pop -> Option with None +986:9: replace >::arm -> io::Result with Ok(false) +990:9: replace >::is_disconnected -> bool with true +990:9: replace >::is_disconnected -> bool with false +1046:9: replace >::len -> usize with 0 +1046:9: replace >::len -> usize with 1 +``` + +### src/spsc.rs (26) + +``` +269:9: replace Shared::len -> usize with 0 +269:9: replace Shared::len -> usize with 1 +282:9: replace Shared::remaining -> usize with 1 +384:9: replace Producer::push -> Result<(), PushError> with Ok(()) +395:47: replace >= with < in Producer::push +399:16: delete ! in Producer::push +408:12: delete ! in Producer::push +423:9: replace Producer::capacity -> usize with 0 +423:9: replace Producer::capacity -> usize with 1 +429:9: replace Producer::len -> usize with 0 +429:9: replace Producer::len -> usize with 1 +460:9: replace Producer::remaining -> usize with 1 +547:9: replace >::drop with () +582:9: replace Reservation<'_, T>::send -> Result<(), Disconnected> with Ok(()) +663:9: replace Consumer::pop -> Option with None +697:9: replace Consumer::len -> usize with 0 +697:9: replace Consumer::len -> usize with 1 +703:9: replace Consumer::is_empty -> bool with true +703:9: replace Consumer::is_empty -> bool with false +703:20: replace == with != in Consumer::is_empty +716:9: replace Consumer::is_disconnected -> bool with false +716:9: delete ! in Consumer::is_disconnected +830:9: replace Consumer::arm -> io::Result with Ok(false) +887:9: replace >::pop -> Option with None +895:9: replace >::arm -> io::Result with Ok(false) +899:9: replace >::is_disconnected -> bool with false +``` + +### src/blocking.rs (3) + +``` +90:12: delete ! in recv +128:12: delete ! in recv_timeout +204:9: delete match arm WAIT_OBJECT_0 | WAIT_TIMEOUT in wait +``` + +### src/capacity.rs (3) + +``` +79:5: replace validate_capacity -> Result<(), CapacityError> with Ok(()) +102:17: replace > with == in validate_capacity +102:17: replace > with < in validate_capacity +``` + +### src/doorbell.rs (1) + +``` +278:9: replace Doorbell::signal with () +``` + +### src/traits.rs (1) + +``` +156:9: replace Bounded::remaining -> usize with 1 +``` From 590a94059d4cd7df474b5891ddd352116ad3bf37 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 09:07:39 -0400 Subject: [PATCH 202/361] fix(waitable-queues)!: make the capacity ceiling a capacity that is accepted `CapacityError::max_valid` documents its value as "the largest capacity the shape that rejected this request will accept". For `spsc` and `slotwise_mpsc` that was false: both carried `WRAPPING_MAX_CAPACITY`, which is `usize::MAX / 2` -- an odd number, so not a power of two, so refused by the very function that suggested it. A caller correcting a refusal by taking the ceiling would have been refused again, with the same suggestion. The crate already knew. `reserving_mpsc` clamps against the largest admissible power of two rather than the wrapping bound, with a const assertion saying why; `capacity/tests.rs` asserts outright that the wrapping bound is not a power of two; and `error.rs` has said so correctly in prose the whole time. What was missing was applying it to the two shapes that had not adopted it. MAX_ADMISSIBLE_CAPACITY is now defined once in capacity.rs and all three shapes use it, which also collapses the duplicate definition `reserving_mpsc` had grown. Its `const` block asserts it is a power of two, sits inside the wrapping bound, and is the *largest* such -- the last stated as a bit position, because the arithmetic identity would be tautological and because a mutation run showed `usize::BITS - 2` surviving replacement by `usize::BITS / 2` on a 64-bit host. The root cause was an asymmetry in `validate_capacity`: it debug-asserted that `bounds.min` is a power of two, because the minimum is suggested to callers verbatim, but made no such check on `bounds.max` -- which is suggested to callers in exactly the same way. Both ends are now guarded, so a shape cannot declare an unusable ceiling again. Also corrects the public capacity comparison, which was stated for 64-bit only. On a 32-bit target the ceiling is 2^30 and BOTH shapes land there -- `reserving_mpsc`'s packed 2^31 is clamped down to it as well -- so the difference the docs invited a reader to weigh does not exist at all. That correction was itself wrong in first draft: it claimed `reserving_mpsc` keeps its 2^31 on 32-bit. The new test caught it, run against i686-pc-windows-msvc as well as the host, which is the only way a claim about 32-bit gets checked here. Swept for restatements and fixed all four: lib.rs, README.md, DESIGN-NOTES.md, and a stale comment in capacity/tests.rs. Marked breaking: `max_valid()` returns a different number for two shapes, and their maximum accepted capacity narrows from an unusable bound to a usable one. Two stray `///` markers in the public `arm` docs are fixed as well. They were mine, from the round that added the four-step wait protocol: a PowerShell concatenation dropped a newline, so the marker rendered literally in the published documentation. Nothing in the gate catches that -- `///` inside a doc comment is just text -- which is why it took a reader to find. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-waitable-queues/DESIGN-NOTES.md | 10 +- crates/windows-waitable-queues/README.md | 9 +- .../windows-waitable-queues/src/capacity.rs | 53 +++++++ .../src/capacity/tests.rs | 143 ++++++++++++++++-- crates/windows-waitable-queues/src/lib.rs | 14 +- .../src/reserving_mpsc.rs | 32 ++-- .../src/slotwise_mpsc.rs | 7 +- crates/windows-waitable-queues/src/spsc.rs | 4 +- 8 files changed, 220 insertions(+), 52 deletions(-) diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 4ef9aa87..86125151 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -1097,9 +1097,13 @@ What the crate owes a caller instead is honesty and equipment: Two justifications are available and both are refused, because a rationale that evaporates on inspection is worse than none: -- **Not capacity.** `slotwise_mpsc` reaches 2^62 slots and `reserving_mpsc` 2^31, and that difference is - unreachable: it counts slots allocated at construction, not items ever pushed, and 2^31 slots is tens - of gigabytes before the ring holds anything useful. See [D-17](#d-17) for why the packing forces it. +- **Not capacity.** On a 64-bit target `slotwise_mpsc` reaches 2^62 slots and `reserving_mpsc` 2^31, and + that difference is unreachable: it counts slots allocated at construction, not items ever pushed, and + 2^31 slots is tens of gigabytes before the ring holds anything useful. See [D-17](#d-17) for why the + packing forces it. **On a 32-bit target the difference does not exist at all** -- the crate-wide + ceiling is 2^30 and `reserving_mpsc`'s packed 2^31 is clamped down to it, so both shapes stop in the + same place. Pinned by `the_shapes_ceilings_are_what_the_public_documentation_claims`, which is run + against `i686-pc-windows-msvc` as well as the host. - **Not `slotwise_mpsc` being faster somewhere.** Its one measured advantage is a single producer with a live consumer -- and at one producer the right shape is [`spsc`](#d-1), which is faster still and which this crate also ships. A shape kept for a regime already better served elsewhere is kept on diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 19296994..1ee611f3 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -220,9 +220,12 @@ can run that measurement on your hardware instead of inheriting ours. Two things that look like reasons to choose and are not: -- **Capacity.** `slotwise_mpsc` reaches 2^62 slots and `reserving_mpsc` 2^31, but that - counts slots allocated up front, not items ever pushed. A ring of 2^31 slots - is tens of gigabytes before it holds anything useful. +- **Capacity.** On a 64-bit target `slotwise_mpsc` reaches 2^62 slots and + `reserving_mpsc` 2^31. On a 32-bit one the crate-wide ceiling is 2^30 and + **both** shapes land there -- `reserving_mpsc`'s packed 2^31 is clamped down + to it as well -- so the difference disappears and the comparison means + nothing. Either way it counts slots allocated up front, not items ever pushed: + a ring of 2^31 slots is tens of gigabytes before it holds anything useful. - **`slotwise_mpsc` winning at one producer.** True in one regime, and at one producer you want `spsc` anyway. diff --git a/crates/windows-waitable-queues/src/capacity.rs b/crates/windows-waitable-queues/src/capacity.rs index fed138b6..ae72c33a 100644 --- a/crates/windows-waitable-queues/src/capacity.rs +++ b/crates/windows-waitable-queues/src/capacity.rs @@ -47,6 +47,49 @@ use crate::error::CapacityError; /// shape may be. pub(crate) const WRAPPING_MAX_CAPACITY: usize = usize::MAX / 2; +/// The largest capacity a [`usize`]-positioned shape actually accepts. +/// +/// # Why this exists rather than [`WRAPPING_MAX_CAPACITY`] being used directly +/// +/// The wrapping bound is `usize::MAX / 2`, which is `2^(BITS-1) - 1` -- odd, +/// and therefore not a power of two, and therefore **not a capacity any shape +/// in this crate accepts**. A shape whose `Bounds::max` was the wrapping bound +/// was reporting a ceiling it would itself refuse. +/// +/// That is not a cosmetic difference, because the number is handed to callers: +/// [`CapacityError::max_valid`](crate::CapacityError::max_valid) documents it +/// as "the largest capacity the shape that rejected this request will accept", +/// and a caller correcting a refusal by using it would have been refused again. +/// +/// `reserving_mpsc` had already noticed the same trap from the other side -- +/// its bounds clamp against this value rather than the wrapping one, with a +/// `const` assertion saying why -- so this is that reasoning applied to the two +/// shapes that had not adopted it, and hoisted to one definition rather than +/// two. +pub(crate) const MAX_ADMISSIBLE_CAPACITY: usize = 1_usize << (usize::BITS - 2); + +// Facts about constants, checked by the compiler rather than by a test. +const _: () = { + assert!( + MAX_ADMISSIBLE_CAPACITY.is_power_of_two(), + "a ceiling offered to a caller as a correction must itself be a capacity \ + this crate accepts" + ); + assert!( + MAX_ADMISSIBLE_CAPACITY <= WRAPPING_MAX_CAPACITY, + "the admissible ceiling must stay inside the range where a wrapping \ + position difference is still unambiguous" + ); + assert!( + MAX_ADMISSIBLE_CAPACITY.leading_zeros() == 1, + "it must be the *largest* such power of two, not merely one of them -- \ + stated as a bit position because the arithmetic identity would be \ + tautological, and because a mutation run showed `usize::BITS - 2` \ + surviving replacement by `usize::BITS / 2` on a 64-bit host, where the \ + value is not the one selected" + ); +}; + #[cfg(test)] mod tests; @@ -80,6 +123,16 @@ pub(crate) fn validate_capacity(capacity: usize, bounds: Bounds) -> Result<(), C bounds.min.is_power_of_two(), "a shape's minimum is suggested to callers verbatim, so it must itself be valid" ); + // The maximum needs the same guard as the minimum, and its absence is what + // let two shapes report `usize::MAX / 2` -- an odd number this function + // rejects -- as the ceiling a caller should retry with. The asymmetry was + // the defect: both ends of the pair are handed to callers through + // `CapacityError`, so both have to be capacities this function accepts. + debug_assert!( + bounds.max.is_power_of_two(), + "a shape's maximum is suggested to callers verbatim as the value to retry with, so it \ + must itself be valid" + ); debug_assert!( bounds.max <= WRAPPING_MAX_CAPACITY, "no shape may exceed the width at which a wrapping position difference is unambiguous" diff --git a/crates/windows-waitable-queues/src/capacity/tests.rs b/crates/windows-waitable-queues/src/capacity/tests.rs index 89fab43e..d81ca487 100644 --- a/crates/windows-waitable-queues/src/capacity/tests.rs +++ b/crates/windows-waitable-queues/src/capacity/tests.rs @@ -13,13 +13,21 @@ //! and the bounds, so the ceiling can be checked without asking an allocator //! for exabytes. -use super::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; +use super::{Bounds, MAX_ADMISSIBLE_CAPACITY, WRAPPING_MAX_CAPACITY, validate_capacity}; -/// The bounds of a shape that stops where wrapping positions stop being -/// unambiguous, which is the widest any shape in this crate goes. +/// The bounds of the widest shape in this crate. +/// +/// **`MAX_ADMISSIBLE_CAPACITY`, not `WRAPPING_MAX_CAPACITY`, and that is a +/// correction.** This fixture used to carry the wrapping bound, which is +/// `usize::MAX / 2` -- odd, and so not a capacity `validate_capacity` accepts. +/// The tests below already knew that (see +/// `the_wrapping_ceiling_is_one_below_a_power_of_two`, which asserts exactly +/// it), so the fixture was encoding a `Bounds` no real shape should ever have +/// held -- and two shapes did hold it, reporting through +/// `CapacityError::max_valid` a ceiling they would themselves refuse. const WIDEST: Bounds = Bounds { min: 1, - max: WRAPPING_MAX_CAPACITY, + max: MAX_ADMISSIBLE_CAPACITY, }; /// The largest power-of-two capacity the wrapping bound admits, as a shift. @@ -93,16 +101,18 @@ fn a_capacity_that_is_not_a_power_of_two_is_refused_whatever_its_size() { #[test] fn a_capacity_exactly_at_the_ceiling_is_accepted() { - // **The boundary the other tests here cannot reach.** They all use - // `WIDEST`, whose `max` is `usize::MAX / 2` -- not a power of two, so the - // power-of-two rule refuses every capacity near it and the `>` in - // `validate_capacity` is never asked about equality. Widening `>` to `>=` - // therefore changed nothing observable, and a mutation run found that - // comparison unguarded. + // **A small explicit ceiling, kept after `WIDEST` was corrected.** This was + // originally the only test here that could reach the equality boundary at + // all: `WIDEST` carried `usize::MAX / 2`, which is not a power of two, so + // the power-of-two rule refused every capacity near it and the `>` in + // `validate_capacity` was never asked about equality -- widening it to `>=` + // changed nothing observable, and a mutation run found the comparison + // unguarded. // - // With a ceiling that is itself a legal capacity, the two differ: the - // largest capacity a shape offers must be constructible, and off by one - // here would refuse it. + // `WIDEST` now carries a ceiling that *is* a legal capacity, so it reaches + // the boundary too. This stays because a bound of 8 states the property + // without depending on the word size, and because the two tests fail for + // different reasons if the rule breaks. let bounds = Bounds { min: 2, max: 8 }; validate_capacity(8, bounds).expect("the ceiling itself must be accepted"); @@ -112,3 +122,110 @@ fn a_capacity_exactly_at_the_ceiling_is_accepted() { validate_capacity(2, bounds).expect("the floor itself must be accepted"); validate_capacity(1, bounds).expect_err("below the floor must not be"); } + +#[test] +fn the_ceiling_a_refusal_reports_is_itself_a_capacity_that_would_be_accepted() { + // The contract `CapacityError::max_valid` states -- "the largest capacity + // the shape that rejected this request will accept" -- was false for two + // shapes, which reported `usize::MAX / 2`. A caller correcting a refusal by + // using it would have been refused again, with the same suggestion. + // + // Asserted as a round trip rather than against a literal, so it holds for + // whatever bound each shape declares: take the ceiling out of a real + // refusal and feed it straight back. + let too_large = validate_capacity(MAX_ADMISSIBLE_CAPACITY * 2, WIDEST) + .expect_err("one past the ceiling must be refused"); + + validate_capacity(too_large.max_valid(), WIDEST).expect( + "the ceiling a refusal suggests must be one the same bounds accept, or the \ + suggestion sends a caller straight back into the error they just had", + ); + assert_eq!(too_large.max_valid(), MAX_ADMISSIBLE_CAPACITY); + + // The same for the other end, which was already correct -- included so the + // pair is stated together and a later edit cannot break one while the other + // still passes. + let too_small = validate_capacity(0, Bounds { min: 4, max: 64 }) + .expect_err("zero is refused whatever the bounds"); + validate_capacity(too_small.min_valid(), Bounds { min: 4, max: 64 }) + .expect("the floor a refusal suggests must also be acceptable"); +} + +#[test] +fn the_admissible_ceiling_is_the_largest_power_of_two_the_wrapping_bound_allows() { + // Only the relationships that are *not* already compile-time facts. That + // the ceiling is a power of two and sits inside the wrapping bound is + // asserted in `capacity.rs`'s `const` block, which is the stronger place -- + // it fails the build rather than a run somebody chose to make -- and clippy + // rightly rejects restating them here as constant-valued assertions. + // + // What is left is the tie between the ceiling and the shifts these tests + // reason in, so a bound moved for some other reason cannot pass by + // coincidence. + assert_eq!(MAX_ADMISSIBLE_CAPACITY, 1_usize << LARGEST_ACCEPTED_SHIFT); + assert_eq!( + WRAPPING_MAX_CAPACITY, + (1_usize << SMALLEST_REFUSED_SHIFT) - 1, + "the next power of two up is one past the wrapping bound, which is what \ + makes the admissible ceiling the largest one that fits" + ); +} + +#[test] +fn the_shapes_ceilings_are_what_the_public_documentation_claims() { + // The crate docs and the README compare the shapes by capacity, and that + // comparison is target-dependent -- which they did not say until a review + // round pointed it out. Asserted here so the claim is checked on whatever + // target the suite runs on rather than believed from a 64-bit reading. + // + // Written while correcting exactly that: a first draft of the corrected + // prose said `reserving_mpsc` keeps its 2^31 packed ceiling on a 32-bit + // target. It does not -- the clamp applies to it too -- and this assertion + // is what caught it. + // Asked through the public surface rather than by reaching for each + // shape's private `BOUNDS`: what the documentation describes is what a + // caller can observe, and a caller observes the ceiling by being refused. + // A capacity of 3 is refused by every shape for a reason that has nothing + // to do with the ceiling, so the error it carries reports the real one. + let spsc_ceiling = crate::spsc::bounded::(3) + .expect_err("3 is not a power of two") + .max_valid(); + let slotwise_ceiling = crate::slotwise_mpsc::bounded::(3) + .expect_err("3 is not a power of two") + .max_valid(); + + assert_eq!( + spsc_ceiling, MAX_ADMISSIBLE_CAPACITY, + "spsc's positions are full-width, so it goes as wide as any shape may" + ); + assert_eq!( + slotwise_ceiling, MAX_ADMISSIBLE_CAPACITY, + "slotwise_mpsc is bounded by allocation rather than by its own positions" + ); + + // `reserving_mpsc` packs a 32-bit position beside a reservation count, so + // its own ceiling is 2^31 -- but it is *also* clamped, and on a 32-bit + // target the clamp is the binding constraint. + let packed = 1_usize << 31; + let expected = if packed <= MAX_ADMISSIBLE_CAPACITY { + packed + } else { + MAX_ADMISSIBLE_CAPACITY + }; + assert_eq!(crate::reserving_mpsc::BOUNDS_MAX, expected); + + #[cfg(target_pointer_width = "64")] + { + assert_eq!(MAX_ADMISSIBLE_CAPACITY, 1_usize << 62); + assert_eq!(crate::reserving_mpsc::BOUNDS_MAX, 1_usize << 31); + } + #[cfg(target_pointer_width = "32")] + { + assert_eq!(MAX_ADMISSIBLE_CAPACITY, 1_usize << 30); + assert_eq!( + crate::reserving_mpsc::BOUNDS_MAX, + 1_usize << 30, + "the clamp binds here, so both shapes land on the same ceiling" + ); + } +} diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 43e8a0b8..92e439e4 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -199,11 +199,15 @@ //! move the answer -- placement alone moved an SPSC handoff by 5.6x on one of //! these hosts. //! -//! Two things that look like reasons to choose and are not. **Capacity**: -//! `slotwise_mpsc` reaches 2^62 slots and `reserving_mpsc` 2^31, but that counts slots -//! allocated up front rather than items ever pushed, and 2^31 slots is tens of -//! gigabytes before the ring holds anything useful. **`slotwise_mpsc` winning at one -//! producer**: true in one regime, and at one producer you want [`spsc`]. +//! Two things that look like reasons to choose and are not. **Capacity**: on a +//! 64-bit target `slotwise_mpsc` reaches 2^62 slots and `reserving_mpsc` 2^31. +//! On a 32-bit one the crate-wide ceiling is 2^30 and **both** shapes land +//! there -- `reserving_mpsc`'s packed 2^31 is clamped down to it too -- so the +//! difference disappears entirely and the comparison means nothing at all. +//! Either way it counts slots allocated up front rather than items ever pushed, +//! and 2^31 slots is tens of gigabytes before the ring holds anything useful. +//! **`slotwise_mpsc` winning at one producer**: true in one regime, and at one +//! producer you want [`spsc`]. //! //! # Shutting down //! diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index d9b6fd6c..30ce4107 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -96,7 +96,7 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; -use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; +use crate::capacity::{Bounds, MAX_ADMISSIBLE_CAPACITY, WRAPPING_MAX_CAPACITY, validate_capacity}; use crate::disposal::Teardown; use crate::doorbell::Doorbell; use crate::error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; @@ -136,24 +136,13 @@ const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; /// capacity it could actually use. pub const BOUNDS_MAX: usize = { let packed = 1_usize << (POSITION_BITS - 1); - if packed <= WIDEST_USIZE_POWER_OF_TWO { + if packed <= MAX_ADMISSIBLE_CAPACITY { packed } else { - WIDEST_USIZE_POWER_OF_TWO + MAX_ADMISSIBLE_CAPACITY } }; -/// The largest power of two a `usize` holds with the top bit still clear. -/// -/// Named rather than inlined so the assertion below can reach it. A mutation -/// run replaced its `- 2` with `/ 2` and nothing failed: on a 64-bit target the -/// clamp is not the branch taken -- `packed` is 2^31, which is under both 2^62 -/// and the mutant's 2^32 -- so the wrong value is selected by neither. On a -/// 32-bit target it *is* the branch taken, and the mutant would have set this -/// shape's maximum capacity to 65,536 instead of 2^30, a factor of 16,384, -/// without failing a single one of the assertions below. -const WIDEST_USIZE_POWER_OF_TWO: usize = 1_usize << (usize::BITS - 2); - /// The capacities this shape accepts. See [`BOUNDS_MAX`]. const BOUNDS: Bounds = Bounds { min: 2, @@ -207,14 +196,10 @@ const _: () = { "a shape that accepts nothing would reject every capacity with a suggestion it would also \ reject" ); - assert!( - WIDEST_USIZE_POWER_OF_TWO.leading_zeros() == 1, - "the clamp must be the widest power of two that leaves the top bit clear, on every target. \ - Stated as a bit position rather than as an arithmetic identity because the identity is \ - tautological -- and because the value only *matters* on a 32-bit target, where this shape \ - is not the one built by default, so an error here would otherwise reach a caller before it \ - reached a build" - ); + // The clamp's own shape -- that it is the *widest* such power of two -- is + // asserted where it is defined, in `capacity::MAX_ADMISSIBLE_CAPACITY`, so + // every shape that clamps against it inherits the check rather than + // restating it. }; /// Reads the position out of a claim word. @@ -1138,7 +1123,8 @@ impl Consumer { /// /// `true` means the queue had nothing takeable after the doorbell was /// cleared, so any later push is guaranteed to signal. `false` means - /// something arrived in the meantime. /// + /// something arrived in the meantime. + /// /// **`true` is not by itself permission to wait indefinitely.** It answers /// only whether a later *push* can be missed, and says nothing about the /// end of the stream: with every producer gone it still returns `true`, diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index fdac04ba..f06fc795 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -103,7 +103,7 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; -use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; +use crate::capacity::{Bounds, MAX_ADMISSIBLE_CAPACITY, validate_capacity}; use crate::disposal::Teardown; use crate::doorbell::Doorbell; use crate::error::{CapacityError, PushError, RecvError, RecvTimeoutError}; @@ -140,7 +140,7 @@ use crate::options::Options; /// and practically unreachable. const BOUNDS: Bounds = Bounds { min: 2, - max: WRAPPING_MAX_CAPACITY, + max: MAX_ADMISSIBLE_CAPACITY, }; /// Creates a multi-producer, single-consumer bounded array queue. @@ -871,7 +871,8 @@ impl Consumer { /// /// `true` means the queue had nothing takeable after the doorbell was /// cleared, so any later push is guaranteed to signal. `false` means - /// something arrived in the meantime: take it instead of waiting. /// + /// something arrived in the meantime: take it instead of waiting. + /// /// **`true` is not by itself permission to wait indefinitely.** It answers /// only whether a later *push* can be missed, and says nothing about the /// end of the stream: with every producer gone it still returns `true`, diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index f5dc8cc6..e243ab61 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -75,7 +75,7 @@ use std::time::Duration; use crate::CacheAligned; use crate::blocking::{self, Parked}; -use crate::capacity::{Bounds, WRAPPING_MAX_CAPACITY, validate_capacity}; +use crate::capacity::{Bounds, MAX_ADMISSIBLE_CAPACITY, validate_capacity}; use crate::disposal::Teardown; use crate::doorbell::Doorbell; use crate::error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeoutError}; @@ -94,7 +94,7 @@ use crate::options::Options; /// full-width [`usize`] values with nothing packed beside them. const BOUNDS: Bounds = Bounds { min: 1, - max: WRAPPING_MAX_CAPACITY, + max: MAX_ADMISSIBLE_CAPACITY, }; /// Creates a single-producer, single-consumer bounded ring. From 166d7c1b5086304908c6efab2c4601ba2dd6250f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 09:20:48 -0400 Subject: [PATCH 203/361] docs: repair four intra-doc links broken by this branch's own edits CI denies broken intra-doc links, and three of its jobs went red on links this branch introduced. All four are the same mistake in different guises -- a link written from memory of what an item is called rather than from what it is called: - `discover_places` was left unqualified in `core_affinity::measure` when that function stopped calling it directly; it needed the crate path, and the `Errors` section needed to say what it now actually returns. - `ErrorKind::InvalidData` was then written with a redundant explicit target, which `rustdoc::redundant_explicit_links` warns on and the workspace job's `-D warnings` promotes. - `Disconnectable::is_disconnected` in the `Waitable::arm` wait protocol names a trait that does not exist. Every `Waitable` implementor is a `Consumer` (`Claim` sits on the reservation, not the consumer), so `Consumer::is_disconnected` is the item the protocol step means. - `Captured` in the placement-probe sink is `#[cfg(test)]`, so it is genuinely absent from the configuration rustdoc documents. A link there cannot resolve in principle; the two references become plain prose naming it as test-only. Verified against CI's exact three invocations rather than a plain `cargo doc`, which is what let the first two hide: the workspace job passes `--all-features` and adds `-D rustdoc::invalid_rust_codeblocks`, while the two per-crate jobs pass `--no-default-features`. A link can resolve under one and not the other. No behavior change; doc comments only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/bin/placement_probe/sink.rs | 10 +++++----- crates/windows-placement-probe/src/core_affinity.rs | 5 ++++- crates/windows-waitable-queues/src/traits.rs | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/windows-placement-probe/src/bin/placement_probe/sink.rs b/crates/windows-placement-probe/src/bin/placement_probe/sink.rs index 46d54396..1c05ff54 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/sink.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/sink.rs @@ -27,8 +27,8 @@ //! Content is composed by `render_*` functions that append to a `&mut String` //! and never touch a stream, matching the idiom the record report already uses. //! A test calls those directly and asserts on the result; only `main` holds a -//! [`Stdio`], and [`Captured`] stands in for it where a test needs to observe -//! what a whole path emitted rather than what one renderer returned. +//! [`Stdio`], and the test-only `Captured` stands in for it where a test needs +//! to observe what a whole path emitted rather than what one renderer returned. /// Somewhere this tool's output can go. pub trait Sink { @@ -100,9 +100,9 @@ impl Captured { /// /// The `render_*` functions produce a whole block with embedded newlines and a /// [`Sink`] speaks in lines, so this is the join between them. Splitting rather -/// than passing the block through keeps [`Captured`] line-addressable, which is -/// what lets a test say "the third line is the topology" instead of matching a -/// substring against the whole document. +/// than passing the block through keeps the capturing sink line-addressable, +/// which is what lets a test say "the third line is the topology" instead of +/// matching a substring against the whole document. /// /// A trailing newline on `block` does not produce an extra empty line, because /// `str::lines` does not yield one -- so a renderer may end its block either way diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index 5cda73fd..91340b94 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -621,7 +621,10 @@ pub fn memory_placements(producer: ProcessorPlace, consumer: ProcessorPlace) -> /// /// # Errors /// -/// Returns whatever [`discover_places`] failed with. +/// Returns whatever [`Topology::discover`] failed with, or +/// [`std::io::ErrorKind::InvalidData`] if the discovered topology leaves an +/// online processor unplaced -- the same refusal +/// [`crate::fingerprint::discover_places`] reports, reached the same way. pub fn measure() -> std::io::Result { // One discovery, two derivations, so the shape reported alongside the rows // is the shape the rows were measured on. Calling `discover_places()` and diff --git a/crates/windows-waitable-queues/src/traits.rs b/crates/windows-waitable-queues/src/traits.rs index f18c0fa3..d7937f98 100644 --- a/crates/windows-waitable-queues/src/traits.rs +++ b/crates/windows-waitable-queues/src/traits.rs @@ -398,7 +398,7 @@ pub trait Waitable { /// /// 1. take everything available; /// 2. `arm`, and if it returns `false`, start again -- something arrived; - /// 3. **check [`Disconnectable::is_disconnected`], and if the producers are + /// 3. **check [`Consumer::is_disconnected`], and if the producers are /// gone, take one last time before reporting the end of the stream.** /// That last take is not belt-and-braces: a producer may push *and then* /// drop in the window between step 1 and this check, and skipping it From 1cedcbeff23b7122f8f585e399e3a82d38c34b76 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 10:47:32 -0400 Subject: [PATCH 204/361] docs: plan the claim-protocol prototype (M15), parking the non-blocking arm SH-14.3 asked for a decision between four ways out of SH-14.1's ABA hole, all of which either lengthen the lap or narrow the platform. Prior-art research found a fifth shape that removes the hazard by construction, which makes this a measurement rather than a judgement -- so it needs a plan, not a note. M15 adds the prototype work in dependency order: record the survey (it exists nowhere in the repository today), amend D-18, build the central-permit claim as a duplicated path, measure it against the shipping shape, then decide merge-or-delete. Duplicated rather than in-place per the platform-integrity rule, with the merge-or-delete decision queued as its own item so the duplication cannot become permanent by inattention. Two findings from the research are recorded in the items themselves because they are checkable facts that would otherwise be re-derived: - D-18's supporting reasons disagree with the pinned toolchain. `rustc 1.98.0 --print cfg` emits `target_feature="cmpxchg16b"` for x86_64-pc-windows-msvc, contradicting "does not enable the target feature by default"; `AtomicU128` is confirmed still unstable (rust-lang/rust#99069), so that reason stands. The fact D-18 never had is decisive: i686-pc-windows-msvc reports no `target_has_atomic="128"`, so a 128-bit claim word is not "widen the word" but "widen the word and drop 32-bit", collapsing option 1 into option 4. - The exposure figure in SH-6.1/SH-14.1 is understated. At the crate's own measured 8.6 ns/push the wrap is 37 seconds, not "about two minutes"; two minutes is the two-producer figure. The SCQ-style per-cell claim moves to M-inf rather than being a second arm. In-order delivery, inline item storage, and non-blocking progress are over-constrained together -- storing T in the ring forces claim-then-write, so a preemption between them stalls in-order consumption. SCQ escapes that only by queueing indices, which is a different data structure. Parking it is a constraint, not a deferral for want of time, and it is recorded as such. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 110 ++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index d1de8819..184c8b0b 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -757,3 +757,113 @@ counter; nothing protects the decision. The README is now compiled as a doctest (`cfg(doctest)`, matching three sibling crates). It carries no code today, so this compiles nothing -- it is there so the first example somebody adds is compiled rather than trusted, this round being the demonstration that prose nothing executes rots. + +## M15: the claim protocol, prototyped rather than argued (SH-14.3's decision) + +**This milestone exists to answer SH-14.3 with a measurement.** SH-14.1 is a real correctness hole +and SH-14.3 lists four ways out, all of which either lengthen the lap or narrow the platform. Prior-art +research (recorded in the design note item below) found a fifth shape that removes the hazard by +construction, and a sixth that additionally changes the queue's progress condition. Neither can be +chosen on reasoning alone, because [D-26](crates/windows-waitable-queues/DESIGN-NOTES.md#d-26) +already measured that the single shared line is what collapses under contention -- so an "obviously +cheaper" claim protocol that touches two shared lines instead of one may well be slower. + +**Built as duplicated paths, per the repository's platform-integrity rule.** Neither arm modifies +`reserving_mpsc`. The shipping shape keeps working and keeps its tests green while the speculative +ones are proven or discarded, and the merge-or-delete decision is SH-15.6 rather than something that +happens by drift. + +**The principle both arms are instances of**, stated once so it is not re-derived: *the atomic +operation that authorizes the write must cover everything the decision depended on.* Today's protocol +decides "there is room" from a separately-read `head` and then compare-exchanges only the claim word, +so a full recurrence of the 32-bit position field revalidates nothing. Every fix below closes that +gap; the options in SH-14.3 instead make the recurrence harder to reach. + +- [ ] **SH-15.1** -- **Record the prior-art research as a design note before it is lost.** The survey + is the reason this milestone exists and none of it is currently written down. It must capture: that + `crossbeam-queue::ArrayQueue`, `concurrent-queue` and `thingbuf` all use our protocol shape and none + re-validates after its compare-exchange; that all three are saved only by putting the whole counter + in one `usize`, so all three carry the identical exposure on a 32-bit target; that Nikolaev's SCQ + (DISC 2019, open access, DOI `10.4230/LIPIcs.DISC.2019.28`, section 3 "ABA safety") states the width + assumption the field relies on and states it for **CPU-word width**, which a 32-bit subfield does not + satisfy; that DPDK's `rte_ring` is the closest published twin of our exact protocol and its published + justification (Programmer's Guide 6.5.4) covers modular *arithmetic* only, not lap recurrence; and + that SCQ and CRQ both fix it structurally by making the counter an unconditional fetch-and-add and + moving the authorizing compare-exchange onto the cell. + Also correct the exposure figure, which is currently wrong in both SH-6.1 and SH-14.1: at the crate's + own measured 8.6 ns/push the wrap is **37 seconds**, not "about two minutes". Two minutes is the + two-producer figure and roughly four the 32-producer one; since the hazard needs at least two + producers the headline is defensible, but the range and its basis belong in the text. + +- [ ] **SH-15.2** -- **Amend [D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18), whose stated + rationale no longer holds.** It refuses a 128-bit compare-and-swap because it "would lift the 2^31 cap + and nothing else", which was written before SH-14.1 was known -- a 64-bit position would also collapse + the recurrence, so the decision denies the existence of its main benefit. + **Checked against the pinned toolchain rather than against documentation**, because two of its three + supporting costs turned out to disagree with it. `rustc 1.98.0 --print cfg` reports, per target: + `x86_64-pc-windows-msvc` emits `target_feature="cmpxchg16b"` **and** `target_has_atomic="128"`; + `aarch64-pc-windows-msvc` emits `target_has_atomic="128"` with no target feature required; + `i686-pc-windows-msvc` emits `target_has_atomic="64"` and **no** `"128"`. + So: the claim "`x86_64-pc-windows-msvc` does not enable the target feature by default" is **false** + on 1.98 -- there is no floor to raise and no runtime detection to pay. The claim "there is no usable + `AtomicU128`" is **true** and verified (still unstable, rust-lang/rust#99069), so the dependency cost + stands. And the decisive new fact D-18 never had: **a 128-bit claim word cannot work on `i686` at + all**, so that option is not "widen the word", it is "widen the word *and* drop 32-bit support" -- + which collapses SH-14.3 option 1 into option 4 and makes it the engineer's call under the + platform-integrity rule. Amend rather than reverse: the refusal may well stand, but every reason + currently given for it is either wrong or incomplete. + +- [ ] **SH-15.3** -- **Arm A: the central-permit claim, as a duplicated shape.** Admission becomes a + single atomic on one `permits` counter initialised to the capacity, and the position degrades to a + pure ticket (`fetch_add`, which has no predicate and therefore cannot be revalidated wrongly). A + producer holding a permit and taking ticket `p` has `p - head <= capacity - 1` by counting, so its + slot is provably free and the position may wrap freely. This satisfies `reserving_mpsc`'s own stated + requirement -- "two independent claimants on one resource must synchronise on one location" -- with + the permit counter as that location, so it strengthens the existing argument rather than contradicting + it. Reservations map directly: a reservation is a permit held across time, still taking no position, + so an outstanding one reduces capacity without head-of-line blocking the stream. + **Not claimed to be non-blocking.** A preempted ticket-holder still stalls the consumer at its + position; this arm fixes the ABA hole and nothing about the progress condition. + +- [ ] **SH-15.5** -- **Measure arm A against the shipping shape in `probe-queue-contention`.** The + probe deliberately measures the real shapes rather than stand-ins ("a stand-in would only measure + itself"), so the arm must be a real module in the queue crate for this to mean anything. Report both + regimes: isolated for the claim cost alone, drained for what the shared line costs when a consumer is + writing it. The question this answers is narrow -- does removing the room-decision race cost + throughput, given that arm A touches two shared lines on the push path where today's shape touches + one plus a read. + +- [ ] **SH-15.6** -- **Decide: merge, or delete.** The duplicated path exists so the speculative work + could proceed without disturbing a working shape; leaving it to become permanent by inattention is + the failure mode the duplication rule warns about. On the evidence from SH-15.5, either adopt arm A + into `reserving_mpsc` (closing SH-14.1 and SH-14.3) or delete it and take one of SH-14.3's original + options, recording why. Whichever way it goes, SH-14.1's hazard must be either fixed or documented as + an accepted limitation with its exposure stated -- it may not simply stay open. + +- [ ] **SH-15.7** -- **Build the stall seam that can actually witness the bug.** SH-14.3 already notes + the property is invisible to a test that merely crosses the wrap: it needs a producer *held* between + its room decision and its exchange. The crate's existing race hooks (`ARM`, `CLEAR`, `CLAIM`) are the + right shape. Without this, every arm above is argued rather than demonstrated, and the fix that is + adopted has no regression test that would go red if it were reverted. + +## M-inf: parked, ungated + +- [ ] **SH-inf.1** -- **The per-cell cycle claim (SCQ's shape), which is the non-blocking one.** The + shared counter becomes an unconditional fetch-and-add authorizing nothing, and the reuse decision plus + the write are validated together by a compare-exchange on the slot's own `{cycle, safe}` word -- + removing the shared decision rather than moving it, and making the queue genuinely lock-free rather + than merely ABA-free. + **Parked deliberately, and the reason is a real constraint rather than scheduling.** In-order delivery, + inline item storage, and non-blocking progress are over-constrained together: storing `T` in the ring + forces a producer to claim a slot and then write it, so a preemption between the two necessarily stalls + in-order consumption. SCQ escapes this only because it queues *indices* -- the payload is written + outside the queue protocol -- which is a different data structure from the one this crate offers. So + this is not an improvement to today's shapes but a fourth shape with different semantics, and it is + worth having only alongside a decision that some caller wants non-blocking progress more than it wants + inline storage. + Related: the whole family this crate belongs to is **technically blocking**, which is worth stating + plainly somewhere public. wCQ (Nikolaev and Ravindran, SPAA 2022) says so of exactly this shape -- + queues that "require a thread to reserve a ring buffer slot prior to writing new data ... are + technically blocking since one stalled (e.g., preempted) thread in the middle of an operation can + adversely affect other threads" -- and names DPDK's ring as a case "erroneously dubbed as 'lock-free'". + This crate should not repeat that error by implication; see SH-15.1, which records the survey. From 2f862014955a5b26c3c8d536afa22ba945f44a8e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 10:53:27 -0400 Subject: [PATCH 205/361] docs(waitable-queues): record the claim-protocol prior art as D-34 SH-14.1 has been open since the ninth review round with four unsatisfying ways out. Before choosing among them, survey what everyone else does. The finding reframes the defect: this is not an unusual protocol with a bug in it, it is the standard protocol used below the width at which the standard correctness argument holds. Read at their push paths, crossbeam-queue's ArrayQueue, concurrent-queue and thingbuf all load a counter, load a second value, decide from the pair, then compare-exchange only the counter and write -- with no re-validation after the exchange. That is our protocol exactly. None of the three mentions ABA, wraparound or overflow anywhere: the assumption is load-bearing in all of them and documented in none. On a 32-bit target all three carry the same exposure we do. Two mechanisms make the surveyed designs safe, and neither covers us. By width: the counter is a whole machine word. Nikolaev (DISC 2019, section 3, open access) states this explicitly and states it for CPU-word width, which our 32-bit subfield does not satisfy -- the strongest available evidence that SH-14.1 is a real defect rather than a curiosity. By structure: CRQ and SCQ advance the counter with a fetch-and-add that authorizes nothing and put the authorizing compare-exchange on the cell, so the decision and the write are validated together. DPDK's rte_ring is the closest published twin -- 32-bit indexes, room computed against a separately loaded counterpart, compare-exchange to claim -- and its published justification covers modular arithmetic of the difference only, not recurrence. So the nearest thing to a defence of this design defends the wrong property, and the gap is undocumented industry-wide. Records the generalisation as ours, since no source phrases it this way: the atomic operation that authorizes the write must cover everything the decision depended on. That is the criterion future claim protocols here are judged against, and it explains why the identical window is harmless in crossbeam and dangerous here. Also corrects the exposure figure, which was wrong in two places. "About two minutes" is the two-producer number; D-26's own table gives 37 s at one producer and 244 s at thirty-two. Both sites now carry the range and its derivation, and note that two producers is the relevant figure because the hazard needs a second one to advance the counter while the first is held. Tier 1 gets D-34 and its detail section; the full survey with citations, licenses, and an explicit list of what could not be verified goes to a Tier 3 design session. Nothing here decides the fix -- that is M15. No code changed. Completed item: SH-15.1: Record the prior-art research as a design note before it is lost. The survey is the reason this milestone exists and none of it is currently written down. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 18 +- .../windows-waitable-queues/DESIGN-NOTES.md | 65 +++++ ...ION-2026-09-02-claim-protocol-prior-art.md | 266 ++++++++++++++++++ 3 files changed, 344 insertions(+), 5 deletions(-) create mode 100644 crates/windows-waitable-queues/design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 184c8b0b..de418a8e 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -287,8 +287,13 @@ stress says nothing about memory orderings. That is measured, not cautious: weak D-31 says cannot be supported. - [ ] **SH-6.1** -- **The wraparound scenario, which is the one reachable correctness gap.** - `reserving_mpsc` packs its position into 32 bits, so it wraps after 2^32 pushes -- about two minutes - at measured rates, and reachable in production within hours. + `reserving_mpsc` packs its position into 32 bits, so it wraps after 2^32 pushes -- **between 37 + seconds and about four minutes** at this crate's own measured rates, and reachable in production + within hours. The range is [D-26](crates/windows-waitable-queues/DESIGN-NOTES.md#d-26)'s isolated + table read as a wrap time: 8.6 ns/push with one producer is 116M/s, so 2^32 is 37 s; 28.0 ns with + two is 35.7M/s, so 120 s; 56.9 ns with thirty-two is 17.6M/s, so 244 s. An earlier version of this + item said only "about two minutes", which is the two-producer figure quoted as though it were the + whole story. **CORRECTION (review round nine).** An earlier version of this item said `spsc` and `slotwise_mpsc` "use `usize` positions and cannot be driven there at all", and that is **false on a 32-bit target**, where `usize` *is* 32 bits. The claim was written from a 64-bit reading and never @@ -698,8 +703,11 @@ counter; nothing protects the decision. not taken. The SAFETY comment above `publish` ("no other producer can also have claimed [this position]") remains true and is not the property that fails; the failing property is that the slot was free. - 2^32 pushes is **about two minutes at this crate's measured rates** (SH-6.1's own figure), so the - window is not exotic -- it needs an unlucky stall, not an unreachable one. + 2^32 pushes is **37 seconds to about four minutes at this crate's measured rates** (SH-6.1 carries + the derivation), so the window is not exotic -- it needs an unlucky stall, not an unreachable one. + Two producers is the relevant figure at two minutes, since the hazard needs a second producer to + advance the counter while the first is held; the 37-second single-producer number is the ceiling on + how fast this counter can be driven at all, not a rate at which the bug can fire. - [x] **SH-14.2** -- **`slotwise_mpsc` had the same hole on a 32-bit target.** Its positions were `AtomicUsize`, which is 32 bits there. A producer that has observed `sequence == position` -- the @@ -779,7 +787,7 @@ decides "there is room" from a separately-read `head` and then compare-exchanges so a full recurrence of the 32-bit position field revalidates nothing. Every fix below closes that gap; the options in SH-14.3 instead make the recurrence harder to reach. -- [ ] **SH-15.1** -- **Record the prior-art research as a design note before it is lost.** The survey +- [x] **SH-15.1** -- **Record the prior-art research as a design note before it is lost.** The survey is the reason this milestone exists and none of it is currently written down. It must capture: that `crossbeam-queue::ArrayQueue`, `concurrent-queue` and `thingbuf` all use our protocol shape and none re-validates after its compare-exchange; that all three are saved only by putting the whole counter diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 86125151..e73ae91e 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -57,6 +57,7 @@ preferred. | D-31 | **0.1.0 ships without machine-checked memory orderings, and says so in its own documentation.** Model-checking gates 1.0, not 0.1.0. It would close the *demonstrated* gap -- a weakened `Acquire` survives the whole suite -- but not the dangerous one: it cannot model `SetEvent`/`ResetEvent`, so it cannot cover the doorbell, and [D-15](#d-15)'s lost wakeup, the only ordering bug this crate has had, was found by sabotage instead. The risk it addresses is mostly regression risk, which is lowest before there are consumers. The disclosure, not the deferral, is the decision. | | D-32 | **`Reserving::Reservation<'a>` gains a bound, before the crate publishes.** The associated type is currently unbounded, so a caller generic over the trait can claim a slot and drop it but never redeem it -- the trait cannot express the operation it exists for. Both implementors already have identical `send` and `is_disconnected` signatures, so the bound is additive; adding it after publication is a breaking change to every implementor. Done as SH-1.5: the [`Claim`](src/traits.rs) trait carries `send` and `is_disconnected`, and both reservation types implement it as forwarders. `Claim` must be in scope to call those methods on a claim whose concrete type the caller has not named, which is why it is re-exported at the crate root. | | D-33 | **`PushError` is `#[non_exhaustive]`, and the one-directional doorbell is disclosed rather than fixed before 0.1.0.** The receive-side errors already carried the attribute and the send side lacked it by omission; adding it after publication is itself breaking, so it is taken now while the crate has no external consumers. Whether a producer can *wait* for room stays open as [M32.3](../../CHECKLIST-io-domains.md) -- it is additive, so it does not gate the release -- but the absence is stated in both the crate docs and the README, because `crossbeam-channel`'s `send` blocks and a reader arriving from it will assume this one does too. | +| D-34 | **Every bounded queue surveyed is ABA-safe for one of two reasons, and this crate's `reserving_mpsc` has neither.** Either the claim counter is a whole machine word, so recurrence is unreachable -- crossbeam, concurrent-queue, thingbuf, Vyukov, SCQ's `Head`/`Tail` -- or the authorizing compare-exchange is moved onto the cell, so the decision and the write are validated together (CRQ, SCQ). Ours packs the position into a 32-bit *subfield* and authorizes with an exchange that does not cover the separately-read `head`. Nikolaev (DISC 2019, section 3) states the width assumption the field relies on and states it for **CPU-word** width, which a subfield does not satisfy; DPDK's `rte_ring` is the same protocol as ours and its published justification covers modular arithmetic only. The generalisation -- ours, unstated in any source -- is that **the atomic operation authorizing the write must cover everything the decision depended on.** Survey in [DESIGN-SESSION-2026-09-02](design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md); the fix is M15 in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). | ## D-2: capabilities are sliced, not gathered @@ -1120,3 +1121,67 @@ rather than discovered later. `M31.6`'s loom verification covers both shapes or `M-inf.4`'s peer-index policy is decided for both or the crate ships two different answers to one question. **A shape kept for others' benefit is still a shape this crate maintains**, and the moment that maintenance is skipped for one of them, the argument above stops being true. + +## D-34: what the prior art actually protects, and why this crate is outside it + +[SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) is not a bug in an unusual design. It is the +standard design, used below the width at which the standard correctness argument holds. That +distinction is the whole content of this decision, and it took a survey to establish -- the full +record, with citations and with the gaps flagged, is in +[DESIGN-SESSION-2026-09-02](design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md). + +### The protocol is mainstream + +`crossbeam-queue::ArrayQueue`, `concurrent-queue` and `thingbuf` were each read at their push path. +All three load a counter, load a second value, decide from the pair, then compare-exchange **only +the counter** and write. None re-validates after the exchange. That is our protocol. + +Searching all three for `ABA`, `wraparound`, `overflow` or any statement of a counter-width bound +returns nothing. The assumption is load-bearing in every one of them and written down in none. + +### What actually makes them safe + +Two mechanisms, and it is worth being blunt that neither is "the protocol is careful": + +- **By width.** The counter is a whole `usize`, so returning to a given bit pattern needs on the + order of 2^64 pushes. Nikolaev states this explicitly (DISC 2019, section 3, "ABA safety"): the + counters "will not wrap around until after the number of operations exceeds **the CPU word's + largest value**, a reasonable assumption made by other ABA-safe designs as well." +- **By structure.** CRQ and SCQ advance the shared counter with an unconditional fetch-and-add that + authorizes nothing, and put the authorizing compare-exchange on the *cell*, where the reuse + decision and the write live in one word. No observation survives the exchange unvalidated. + +`reserving_mpsc` has neither. Its position is a 32-bit half of a packed word, not a machine word, so +the width argument does not reach it -- and its exchange covers the claim word but not the `head` +its room decision was computed from. + +### The nearest published twin defends a different property + +DPDK's `rte_ring` is our protocol almost exactly: 32-bit indexes, room computed against a separately +loaded counterpart, compare-exchange to claim. Its *Programmer's Guide* (6.5.4) justifies it as "we +can do subtractions between 2 index values in a modulo-32bit base: that's why the overflow of the +indexes is not a problem." + +That defends **modular arithmetic of the difference**. It says nothing about a producer stalled +across a full recurrence, which is the hazard. Searching DPDK's ring library for "ABA" returns +nothing either. So the closest thing to a published defence of this design defends the wrong +property, and the gap is undocumented industry-wide rather than something we alone missed. + +### The generalisation + +Stated once, so it is not re-derived, and flagged as **ours**: no source phrases it this way, though +SCQ's cell compare-exchange and CRQ's double-width `CAS2` are both instances of it. + +> The atomic operation that authorizes the write must cover everything the decision depended on. +> Where it does not, correctness rests entirely on the counter being too wide to recur. + +This is the criterion any future claim protocol in this crate is judged against, and it is more +useful than the narrower "avoid ABA": it says *what to check*, and it explains why the same +structural window is harmless in crossbeam (word-width counter) and dangerous here (subfield). + +### What this decision does not decide + +Which fix to adopt. That is M15, which prototypes the central-permit claim and measures it rather +than arguing it -- necessary because [D-26](#d-26) already measured that the single shared line is +what collapses under contention, so a protocol that touches two shared lines instead of one is not +obviously cheaper. This decision records only the landscape and the criterion. diff --git a/crates/windows-waitable-queues/design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md b/crates/windows-waitable-queues/design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md new file mode 100644 index 00000000..b6853223 --- /dev/null +++ b/crates/windows-waitable-queues/design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md @@ -0,0 +1,266 @@ +# Design session 2026-09-02: what other bounded queues do about claim-protocol ABA + +Resulted in [D-34](../DESIGN-NOTES.md#d-34), an amendment to +[D-18](../DESIGN-NOTES.md#d-18), and milestone M15 of +[CHECKLIST-ship-topology-and-queues.md](../../../CHECKLIST-ship-topology-and-queues.md). + +Prompted by SH-14.1: `reserving_mpsc` can overwrite a live slot after 2^32 pushes, on every +target, because a producer's room decision is made against a separately-read `head` and the +compare-exchange that acts on it covers only the claim word. The question put to this session +was deliberately narrow -- *what does everyone else do* -- rather than *how should we fix it*, +because SH-14.3 had already enumerated four ways out and all four were unsatisfying. + +Sources were restricted to permissively licensed implementations (MIT, Apache-2.0, BSD, +public domain) and open-access papers, so that anything found could be cited and reasoned +from. **No code was copied.** What follows is a description of algorithms and a record of +citations; the mechanisms described are ideas, and any implementation this workspace adopts +is written here. + +## 1. The exact hazard, restated so the survey has something to match against + +1. Load the shared claim word `W`; extract `position`. +2. Decide "there is room" by comparing `position` against a separately-loaded `head`. +3. `compare_exchange(W, W with position + 1)` to claim the slot. +4. On success, write `slot[position % capacity]` and publish. + +A producer stalled between (2) and (3) resumes after other producers have driven the position +field through a complete wrap. The word recurs bit-for-bit, the exchange succeeds, and the +write proceeds on a room decision that is now generations stale. + +The essential point, and the thing to match implementations against: **the exchange protects +the counter; nothing protects the earlier observation.** + +## 2. Rust implementations read directly + +All three were read at their push path. All three are the same algorithm. + +### crossbeam-queue `ArrayQueue` (MIT OR Apache-2.0) + +`crossbeam-rs/crossbeam:crossbeam-queue/src/array_queue.rs`. The file's own header credits +Vyukov's page. `tail` is one `usize` packing `{lap, index}`; each slot carries a `stamp: +AtomicUsize`. The push path: + +- load `tail`; split into `index` and `lap` +- load `slot.stamp` +- **require `tail == stamp`** +- `compare_exchange_weak(tail, new_tail)` +- on success, write the value, then `slot.stamp.store(tail + 1, Release)` + +There is no re-check of `stamp` after the exchange. The predicate depends on `stamp`, which is +read separately and is not covered by the exchange -- structurally the same window as ours. +What makes it safe in practice is width: `tail` is a whole `usize`, so recurrence needs on the +order of 2^64 pushes on a 64-bit target. + +**On a 32-bit target `tail` is 32 bits, and the exposure is the same as ours.** No comment in +the file discusses this bound. + +### concurrent-queue (Apache-2.0 OR MIT) + +`smol-rs/concurrent-queue:src/bounded.rs`. The same algorithm, derived from crossbeam. Worth +noting for a different reason: it steals a `mark_bit` from the top of the tail word for the +"closed" flag, which narrows the recurrence space by a bit. A precedent for taking bits out of +a position word, and evidence that doing so is not treated as dangerous. + +### thingbuf (MIT) + +`hawkw/thingbuf:src/lib.rs`. `tail` is one `usize` packing `{gen, closed, idx}`; each slot has +a `state`. The push path requires `state == tail`, then compare-exchanges `tail`. Again no +post-exchange re-validation. + +### What none of them do + +Searched all three for `overflow`, `wrap around`, `wraparound`, `ABA`, `2^64`, `64-bit`: +**zero matches.** The assumption that the counter cannot recur is load-bearing in all three +and documented in none. + +## 3. The literature + +### Vyukov's bounded MPMC queue + +`1024cores.net/home/lock-free-algorithms/queues/bounded-mpmc-queue` (read via Internet Archive +snapshot 2024-01-12; the live domain is intermittently unreachable). Per-cell `sequence_` +initialised to the cell index; enqueue computes `dif = seq - pos` and compare-exchanges +`enqueue_pos_` when `dif == 0`. + +One genuine structural difference from ours worth recording: **Vyukov's enqueue never reads the +consumer's position at all.** The decision is made against the destination cell's sequence, +keyed to the same `pos` the exchange validates. That removes one stale input relative to our +design -- though the cell sequence is still read separately and still not covered by the +exchange. + +Vyukov does not discuss ABA, wraparound, or counter width anywhere on the page (verified +negative). He also explicitly disclaims lock-freedom for this queue. + +### Morrison and Afek, CRQ / LCRQ + +*Fast Concurrent Queues for x86 Processors*, PPoPP '13, pp. 103-112, DOI +`10.1145/2442516.2442527`. + +**The paper's own text could not be extracted** (the PDF returned raw compressed streams). +Everything below is from the MIT-licensed reference implementation +`chaoran/fast-wait-free-queue:lcrq.c` and from peer-reviewed secondary description in +Nikolaev's papers. Anyone quoting CRQ's prose must read the PDF first. + +From the source: a cell is `{uint64_t val; uint64_t idx;}` updated by a genuine double-width +`CAS2`. `idx` is a full 64-bit monotonically increasing position with bit 63 stolen as an +`unsafe` flag. Enqueue does `t = FAA(&rq->tail, 1)` -- **no compare-exchange on the tail at +all** -- and then validates the *cell*. + +Cycle recurrence is not a concern for CRQ because the epoch stored in the cell is the +full-width absolute position, compared with `<=`; there is no narrow modular subfield to recur. + +**"Closing" is a livelock escape, not an ABA device.** `close_crq()` sets bit 63 of the tail +when an enqueuer cannot find a usable cell; the producer then allocates a fresh ring. +Corroborated by Nikolaev DISC'19 section 5: CRQ "is not standalone due to its inherent +susceptibility to livelocks ... a slow path is taken, where the current CRQ instance is +'closed'." + +### Nikolaev, SCQ -- the citation that matters most + +*A Scalable, Portable, and Memory-Efficient Lock-Free FIFO Queue*, DISC 2019, LIPIcs vol. 146, +pp. 28:1-28:16, DOI `10.4230/LIPIcs.DISC.2019.28`. **Open access, CC-BY.** Extended version +arXiv:1908.04511. Reference implementation `rusnikola/lfqueue` (dual BSD-2-Clause / MIT). + +Section 3, under the heading "ABA safety", verbatim: + +> "The ABA problem is prevented by comparing cycles. As both `Head` and `Tail` are incremented +> sequentially, regardless of queue size, they will not wrap around until after the number of +> operations exceeds the CPU word's largest value, a reasonable assumption made by other +> ABA-safe designs as well." + +This appears to be the only explicit, citable statement in this literature of the assumption +everyone relies on. Note exactly what it licenses: **CPU-word width**. A 32-bit subfield of a +64-bit word does not satisfy it. That single sentence is the strongest available statement +that SH-14.1 is a real defect rather than a theoretical curiosity -- we are doing the standard +thing below the width at which the standard argument holds. + +The structural mechanism, Fig. 6 line 15: + +``` +if ( Cycle(Ent) < Cycle(T) and Index(Ent) = () and (IsSafe(Ent) or Load(&Head) <= T) ) + New = { Cycle(T), 1, index }; + if ( !CAS(&Entries[j], Ent, New) ) goto retry +``` + +Two things to take from it: the **compare-exchange is on the cell, not the counter** (the +counter is advanced by an unconditional fetch-and-add that authorizes nothing), and the reuse +decision plus the write are validated **together**, because both live in the word the exchange +covers. No observation survives across the exchange unvalidated. + +Cycle width is `word width - log2(slots) - 1` (the `-1` is the `IsSafe` bit), derived from +`lfring_cas1.h`. With their benchmark's 2^16 slots on 64-bit that is a 47-bit cycle. + +**The `threshold` is not an ABA device.** It is `2n - 1` (infinite array) or `3n - 1` (SCQ) and +its stated purpose is livelock-freedom and empty detection: "Livelocks occur when dequeuers +incessantly invalidate slots that enqueuers are about to use." A web summary claimed it was a +2^32 anti-aliasing constant; that is false. Recorded because the misreading is plausible. + +### Nikolaev and Ravindran, wCQ + +*wCQ: A Fast Wait-Free Queue with Bounded Memory Usage*, SPAA '22, DOI +`10.1145/3490148.3538572`; preprint arXiv:2201.02179. + +The passage that matters to this crate is not about ABA at all. On the family our shapes belong +to, section 1: + +> "such queues require a thread to reserve a ring buffer slot prior to writing new data. These +> approaches ... are technically blocking since one stalled (e.g., preempted) thread in the +> middle of an operation can adversely affect other threads." + +and it names DPDK's ring as a "straight-forward implementation ... erroneously dubbed as +'lock-free'". `reserving_mpsc` is squarely in that family. This is the citation behind +SH-inf.1's note that the crate should not repeat the error by implication. + +Also: "wCQ requires double-width CAS, which is nowadays widespread (i.e., x86 and +ARM/AArch64)", with a separate LL/SC construction for architectures lacking it. + +## 4. DPDK `rte_ring` -- the closest published twin of our exact protocol + +*DPDK Programmer's Guide*, section 6.5.4 "Modulo 32-bit Indexes"; code +`DPDK/dpdk:lib/ring/rte_ring_c11_pvt.h` (BSD-3-Clause). 32-bit indexes; room computed against a +separately loaded counterpart index; compare-exchange to claim. That is our protocol, in +production, at very large scale. + +Its published justification, verbatim: + +> "we can do subtractions between 2 index values in a modulo-32bit base: that's why the +> overflow of the indexes is not a problem." + +**That argument covers modular arithmetic of the difference and nothing else.** It does not +address a producer stalled across a full 2^32 recurrence. A search of `DPDK/dpdk path:lib/ring` +for "ABA" returns zero hits. + +So the closest thing to a published defence of our design defends a different property than +the one SH-14.1 attacks. This is the single most useful citation from the session: it shows the +shape is mainstream, shows the standard justification for it is insufficient for our hazard, +and shows nobody has written the gap down. + +Incidentally: DPDK's RTS and HTS modes pair head and tail into a single 64-bit compare-exchange. +That is a double-width fix in effect, but it is motivated by lock-waiter preemption, not ABA. + +## 5. Double-width compare-and-swap on this workspace's targets + +Checked against the pinned toolchain rather than against documentation, because the +documentation and D-18 disagreed. `rustc 1.98.0 --print cfg --target ...`: + +| target | `cmpxchg16b` feature | `target_has_atomic="128"` | +|---|---|---| +| `x86_64-pc-windows-msvc` | **set by default** | yes | +| `aarch64-pc-windows-msvc` | n/a (`ldxp`/`stxp` is ARMv8-A baseline) | yes | +| `i686-pc-windows-msvc` | n/a | **no** | + +And `core::sync::atomic::AtomicU128` was test-compiled: **still unstable** +(rust-lang/rust#99069), so reaching a 128-bit exchange from stable means a dependency such as +`taiki-e/portable-atomic` (Apache-2.0 OR MIT), whose own table records `cmpxchg16b` as "enabled +by default on Apple, Windows (except Windows 7, since Rust 1.78)". + +Consequences for [D-18](../DESIGN-NOTES.md#d-18), which refuses the 128-bit exchange: + +- "It is not in the x86-64 baseline ... does not enable the target feature by default" is + **false** on 1.98 for our target. No floor to raise, no runtime detection to pay. +- "There is no usable `AtomicU128`" is **true and verified**. The dependency cost stands. +- The fact D-18 never had, and the decisive one: **`i686` has no 128-bit atomic at all**, so a + 128-bit claim word is not "widen the word" but "widen the word *and* drop 32-bit support" -- + which collapses SH-14.3's option 1 into its option 4, an engineer's decision under the + platform-integrity rule. +- And the premise: D-18 says the exchange "would lift the 2^31 cap and nothing else", written + before SH-14.1 existed. It would also collapse the recurrence. + +## 6. What the survey concluded + +**Every design surveyed is safe for exactly one of two reasons**, and it is worth being blunt +that neither is "the protocol is careful": + +- **By width** -- Vyukov, crossbeam, concurrent-queue, thingbuf, SCQ's `Head`/`Tail`. The + counter is a whole machine word, so recurrence is unreachable. This is what Nikolaev states + explicitly and what the others rely on silently. +- **By structure** -- CRQ and SCQ. The counter is a fetch-and-add authorizing nothing, and the + authorizing compare-exchange is on the cell, where the decision and the write are validated + together. + +Ours is safe for neither reason. The position is a 32-bit subfield, not a machine word, and the +authorizing exchange does not cover `head`. + +The principle, stated once so it need not be re-derived -- **this is our inference, and no +source phrases it this way**, though SCQ's Fig. 6 and CRQ's `CAS2` are both instances: + +> The atomic operation that authorizes the write must cover everything the decision depended +> on. Where it does not, correctness rests entirely on the counter being too wide to recur. + +That is what makes the central-permit shape (M15 arm A) worth prototyping: admission becomes a +single atomic on one counter, so the predicate is a function solely of the word being modified, +and the position degrades to a ticket with no predicate at all. + +## 7. Gaps -- do not cite these without checking + +- **The LCRQ paper's own text.** Not extracted; all CRQ claims here are from MIT-licensed + reference source plus Nikolaev's secondary description. +- **Michael, *ABA Prevention Using Single-Word Instructions*, IBM RC23089 (2004).** Existence + well attested, full text not retrieved. It is the usual citation for tag-width reasoning. +- **Herlihy and Shavit, *The Art of Multiprocessor Programming*.** Not consulted. Whether it + treats bounded-counter ABA is unknown; section 10.6 and the `AtomicStampedReference` material + are the places to look. +- **wCQ section 5 (Correctness).** Only sections 1-3 were read; if wCQ restates a counter-width + assumption formally it would be there. +- **No published counter-argument was found** demonstrating a 64-bit monotonic counter being + wrapped in practice, and no paper states a stall-duration-versus-wrap-rate inequality. From 7c76ad89939042350b52d78fc3d7d5db2a05b9d7 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 10:56:46 -0400 Subject: [PATCH 206/361] docs(waitable-queues): amend D-18 -- outcome stands, reasoning replaced D-18 refuses a 128-bit compare-and-swap. The refusal is still right. Three of the four reasons it gives are not, and the reason that actually decides it was missing, so a reader relying on the decision would have been misinformed on every point. What was wrong: - "It would lift the 2^31 cap and nothing else." Written before SH-14.1 was found. A 64-bit position would also collapse that ABA recurrence, so the decision denied the existence of the option's main benefit. ("No performance benefit" was never the same claim as "no benefit".) - "It is not in the x86-64 baseline ... does not enable the target feature by default." False on the pinned toolchain: `rustc 1.98.0 --print cfg --target x86_64-pc-windows-msvc` emits `target_feature="cmpxchg16b"` and `target_has_atomic="128"`. The original reasoned from the generic x86-64 baseline and never checked the Windows target. - "Not even the same instruction on aarch64." True, but not a cost: `aarch64-pc-windows-msvc` reports `target_has_atomic="128"` with no target feature, because ldxp/stxp is ARMv8-A baseline. What survives: `AtomicU128` is still unstable (rust-lang/rust#99069), verified by test compile, so the dependency cost is real. What was missing, and is now the operative reason: `i686-pc-windows-msvc` reports no `target_has_atomic="128"` at all. Adopting a 128-bit claim word is therefore not "widen the word" but "widen the word and drop 32-bit support", collapsing SH-14.3's option 1 into its option 4 -- an engineer's decision under the platform-integrity rule, not one a correctness fix takes in passing. That is simpler and stronger than the three claims it replaces. Swept `128-bit|cmpxchg16b|CASP|AtomicU128|D-18` across the repository: 59 matches in 28 files, 3 restating this decision, all 3 updated. The other 56 are unrelated -- other crates' own D-18, and 128-bit file identifiers in the enumeration crate. The two restatements beyond the decision itself were CHECKLIST-io-domains.md, which reproduced the superseded reasoning in full, and reserving_mpsc's module documentation, which led with "would lift that cap". Fixing only the decision would have left both teaching the old answer. Correction kept visible rather than silently rewritten, because the shape of the error is the instructive part: a decision can reach the right outcome and still leave every reason a reader would rely on wrong. Two of the three faults were assertions about the toolchain that were never run. Completed item: SH-15.2: Amend D-18, whose stated rationale no longer holds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 13 +-- CHECKLIST-ship-topology-and-queues.md | 2 +- .../windows-waitable-queues/DESIGN-NOTES.md | 81 ++++++++++++------- .../src/reserving_mpsc.rs | 10 ++- 4 files changed, 69 insertions(+), 37 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 818472fe..c446e095 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -262,11 +262,14 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m `occupied + reserved` is never momentarily wrong; and the producer stops needing the slot sequence for the "free" direction, so this shape's `pop` is one store *shorter* than `slotwise_mpsc`'s. **A 128-bit compare-and-swap was raised and refused** - ([D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18)): it lifts the cap and nothing else, since - the consumer's position still has to be read, and it costs a new dependency, a target-feature floor not - in the x86-64 baseline, and a different instruction on the ARM64 machine every measurement here is - taken on. Recorded with the case that would revive it -- a tagged pointer, which is what M-inf.1's - linked and sharded shapes would need. + ([D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18)), and **that decision's reasoning was + amended on 2026-09-02 while its outcome stood** -- read it there rather than from this paragraph, + which previously restated the superseded version. In short: it does not merely lift the cap (a + 64-bit position would also close SH-14.1's ABA hole, unknown when D-18 was written), it *is* in the + x86-64 baseline on this workspace's Windows target, and the reason it is refused is that + `i686-pc-windows-msvc` has no 128-bit atomic at all -- so adopting it would mean dropping 32-bit + support. Revived by a tagged pointer, which is what M-inf.1's linked and sharded shapes would need, + or by dropping 32-bit for unrelated reasons. **`spsc` reserves too, nearly free**, since one producer means `reserve` and `push` are the same thread. Its reservation *borrows* the producer where `reserving_mpsc`'s is owned and `Send`, because there the handle **is** the single-producer guarantee and an owned reservation could outlive it on diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index de418a8e..ee91191c 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -803,7 +803,7 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. two-producer figure and roughly four the 32-producer one; since the hazard needs at least two producers the headline is defensible, but the range and its basis belong in the text. -- [ ] **SH-15.2** -- **Amend [D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18), whose stated +- [x] **SH-15.2** -- **Amend [D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18), whose stated rationale no longer holds.** It refuses a 128-bit compare-and-swap because it "would lift the 2^31 cap and nothing else", which was written before SH-14.1 was known -- a 64-bit position would also collapse the recurrence, so the decision denies the existence of its main benefit. diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index e73ae91e..82caf326 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -41,7 +41,7 @@ preferred. | D-15 | **`Doorbell::clear` resets the event *before* clearing the flag that mirrors it, and the original order was a lost wakeup.** A producer signalling between the two lines set the flag and issued a real `SetEvent`; the `ResetEvent` that followed erased the signal and left the flag set, wedging the doorbell dark while it claimed to be lit. **Amends [D-9](#d-9)**, whose "there is no third case" holds only for a queue whose emptiness is one position comparison. | | D-16 | **Its cost premise is falsified by [D-26](#d-26); the conclusion stands on capability instead -- see [D-29](#d-29).** Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `slotwise_mpsc` rather than replacing it. Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `slotwise_mpsc`'s push deliberately never reads. The original rationale added that this made reserving the *more expensive* shape and that both should ship rather than charge every caller for it; measurement reversed that, and the split is now justified by the capability alone. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. | | D-17 | **The reservation count and the claim position live in one word, because a check-and-claim over both must be a single atomic operation.** Two atomics cannot be made correct with any amount of fencing: the pushing producer is load-then-store and the reserving one store-then-load, so the Dekker argument does not apply and both can miss each other. The 32/32 split is forced by the arithmetic, and caps this shape at 2^31 items. | -| D-18 | **A 128-bit compare-and-swap is refused.** It would lift the 2^31 cap and nothing else -- the consumer's position still has to be read -- at the cost of a dependency, a target-feature floor not in the x86-64 baseline, and a different instruction on the ARM64 machine this workspace measures on. Revisit only for a tagged pointer, which is what [M-inf.1](../../CHECKLIST-io-domains.md)'s linked and sharded shapes would need. | +| D-18 | **A 128-bit compare-and-swap is refused -- outcome unchanged, reasoning replaced.** **Amended: three of the four reasons originally given were wrong or incomplete, and the decisive one was missing.** It would *not* lift the cap "and nothing else": a 64-bit position also collapses [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md)'s ABA recurrence, which was unknown when this was written. It is *not* outside the x86-64 baseline -- `rustc 1.98.0` emits `target_feature="cmpxchg16b"` for `x86_64-pc-windows-msvc`, so there is no floor to raise and no runtime detection to pay. What stands is the dependency (`AtomicU128` is still unstable, rust-lang/rust#99069) and, decisively, that **`i686-pc-windows-msvc` has no 128-bit atomic at all**: adopting this is not "widen the word" but "widen the word *and* drop 32-bit support". Revisit for a tagged pointer, or if 32-bit support is dropped for other reasons -- not before. | | D-19 | **The coalesced loss latch is deliberately not generalised from the file watcher.** Coalescing there is sound because a desync is *idempotent* -- two mean the same as one, and the answer to both is a re-scan. A queue of arbitrary `T` has no such property, so what generalises is a loss *count*, which is [M31.4](../../CHECKLIST-io-domains.md)'s observability rather than a policy. | | D-20 | **Undrained items are handed to a caller-supplied sink at teardown, and the sink is chosen at construction because `Drop` has nowhere to hand them back to.** Without one they are destroyed on whichever thread released the last handle -- which may be a pool callback that must not block, and closing a handle to a dead network path can block for a long time. The default is unchanged; what changes is that it is now a named choice. | | D-21 | **A panicking disposal sink is caught and the teardown walk continues.** The sink is caller code inside a destructor: a panic escaping it abandons every item behind it -- the exact handles the mechanism exists to account for -- and during an unwind aborts the process. Catching declines to turn a caller's bug into a much larger one. | @@ -571,33 +571,58 @@ constraint that binds: the count's half must be wide enough to hold the whole ca ## D-18: a 128-bit compare-and-swap is refused -The natural question about [D-17](#d-17)'s packing is why not use `cmpxchg16b` (or `CASP` on aarch64) and -keep both halves full width. The answer has one decisive part and three supporting ones. - -**It does not remove the cost that matters.** The expense in this shape is the shared read of the -consumer's position, and free space is `capacity - (position - head) - reserved`. `head` belongs to the -consumer; no width of *producer-side* compare-and-swap makes it appear in the producer's word. So a -double-width exchange buys exactly one thing: lifting the ceiling from 2^31 to 2^62, on a ring that is -allocated in full at construction. - -The supporting reasons: - -- **It is not reachable from stable Rust without a new dependency.** There is no usable `AtomicU128`, and - `core::arch::x86_64::cmpxchg16b` is an unstable intrinsic; the toolchain is pinned to 1.98.0 stable. It - would mean adding `portable-atomic` to a workspace whose only third-party dependency is `windows-sys`, - on a crate that is [published](#d-8). -- **It is not in the x86-64 baseline.** `x86_64-pc-windows-msvc` does not enable the target feature by - default. Windows 8.1 and later require the instruction in hardware, so it is *present*, but the - compiler still will not emit it unless told -- so it is either raise the target-feature floor, which - narrows the platform, or pay runtime detection on the push path. -- **It is not even the same instruction on the machine this workspace measures on.** The reference machine - is `aarch64-pc-windows-msvc`; CI is x86-64. The measuring platform and the CI platform would exercise - different instructions with different cost profiles, and - [windows-platform-probes](../windows-platform-probes/DESIGN-NOTES.md) records what ARM64-only - measurement has already cost once. - -**Where it would genuinely earn its place is a tagged pointer**, which is what the linked and sharded -shapes parked in `M-inf.1` would need. Recorded there so the question does not have to be re-derived. +**Amended 2026-09-02. The refusal stands; almost none of its original reasoning does.** The first +version of this decision was written before [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) +existed, and it asserted target facts that were never checked against the toolchain. Both faults are +corrected below, and the correction is kept rather than silently rewritten because the *shape* of the +error is instructive: a decision can reach the right outcome and still leave every reason a reader +would rely on wrong. + +The natural question about [D-17](#d-17)'s packing is why not use `cmpxchg16b` (or `CASP` on aarch64) +and keep both halves full width. + +### What was claimed, and what is actually true + +**"It would lift the 2^31 cap and nothing else" -- wrong, and this is the substantive correction.** +A 64-bit position field would also collapse SH-14.1's ABA recurrence, which is a correctness hole and +not a capacity limit. The original decision denied the existence of what is now the option's main +benefit, purely because the hole had not yet been found. It remains true that a wider producer word +does nothing about *the cost that matters* -- free space is `capacity - (position - head) - reserved`, +`head` belongs to the consumer, and no width of producer-side exchange brings it into the producer's +word -- but "no performance benefit" was never the same claim as "no benefit". + +**"It is not in the x86-64 baseline" -- false on the pinned toolchain.** Checked rather than assumed: +`rustc 1.98.0 --print cfg --target x86_64-pc-windows-msvc` emits `target_feature="cmpxchg16b"` and +`target_has_atomic="128"`. There is no target-feature floor to raise and no runtime detection to pay +on the push path. The original text reasoned from the generic x86-64 baseline and never checked the +*Windows* target, which enables the feature by default. + +**"It is not even the same instruction on aarch64" -- true but not a cost.** `aarch64-pc-windows-msvc` +reports `target_has_atomic="128"` with no target feature required, because `ldxp`/`stxp` is ARMv8-A +baseline. That the instruction differs from x86-64's is what an atomics abstraction is for. + +**"There is no usable `AtomicU128`" -- true, verified.** Still unstable (rust-lang/rust#99069); a +test compile on 1.98.0 fails. Reaching a double-width exchange from stable means adding +`portable-atomic` to a workspace whose only third-party dependency is `windows-sys`, on a crate that +is [published](#d-8). **This is the one original reason that survives.** + +### The reason the decision actually rests on now + +**`i686-pc-windows-msvc` has no 128-bit atomic at all** -- `rustc --print cfg` reports +`target_has_atomic="64"` and no `"128"`. So this option is not "widen the claim word"; it is "widen +the claim word **and** drop 32-bit support", which collapses +[SH-14.3](../../CHECKLIST-ship-topology-and-queues.md)'s option 1 into its option 4. Narrowing the +platform is the engineer's decision under the repository's platform-integrity rule, not something a +correctness fix may take in passing. + +That is a stronger and simpler reason than the three it replaces, and it is the one to quote. + +### When to revisit + +For a **tagged pointer**, which is what the linked and sharded shapes parked in `M-inf.1` would need +-- or if 32-bit support is dropped for unrelated reasons, at which point the option becomes a live +candidate for SH-14.1 rather than a non-starter. Recorded so the question does not have to be +re-derived a third time. ## D-19: the coalesced loss latch does not generalise diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 30ce4107..b05397d7 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -79,10 +79,14 @@ //! `2^(b-1)`, and the count needs `b` bits because it can reach the capacity, so //! `b + b = 64` gives `b = 32`. There is no cleverer division of the word. //! -//! **A 128-bit compare-and-swap would lift that cap and is deliberately not -//! used** ([D-18](../DESIGN-NOTES.md#d-18)). It would not remove the cost that +//! **A 128-bit compare-and-swap is deliberately not used** +//! ([D-18](../DESIGN-NOTES.md#d-18)). It would not remove the cost that //! matters -- the consumer's position still has to be read -- and 2^31 slots is -//! a ring this shape allocates in full at construction. +//! a ring this shape allocates in full at construction. The operative reason, +//! though, is that `i686-pc-windows-msvc` has no 128-bit atomic at all, so +//! adopting one would mean dropping 32-bit support rather than merely widening +//! a word. See the decision for the full reasoning, which was amended in +//! 2026-09-02 after three of its original four reasons turned out not to hold. use core::cell::{Cell, UnsafeCell}; use core::fmt; From 67edf36d1027db117f148d4526ed69fe7e815421 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 11:10:36 -0400 Subject: [PATCH 207/361] feat(waitable-queues): add the experimental permit-claiming MPSC (arm A) A duplicate of `reserving_mpsc` differing only in its claim protocol, behind the non-default `experimental-permit-claim` feature. Built to be measured against the shipping shape so SH-14.3 is decided on evidence; it will be merged or deleted at SH-15.6, which is queued so the duplication cannot become permanent by inattention. The shipping shape is untouched. The protocol. `reserving_mpsc` decides "there is room" by reading the consumer's `head`, then compare-exchanges a claim word that does not contain `head` -- the decision and the operation acting on it are separate, which is SH-14.1. Here the decision IS the operation: a producer takes a permit from a count of unspoken-for slots with one atomic, so the predicate is a function solely of the word being modified and no other value's recurrence can invalidate it. The position stops carrying any decision and becomes a ticket handed out by fetch_add -- an operation with no predicate to be wrong about -- so it may wrap freely. Consequences worth noting: no producer reads `head` at all any more, which is both the cost D-26 measured and the stale input SH-14.1 exploits; and the release that frees a slot moves from advancing `head` to returning the permit. This does not contradict D-17. That decision argues two atomics cannot be made correct with any fencing, because a pusher reads the count then writes the position while a reserver writes the count then reads the position. That argument is sound and is not evaded: it concludes "two independent claimants on one resource must synchronise on one location", and this shape agrees and picks a different single location. Both claimants perform the same modification on `permits`; there is no load-then-store paired with a store-then-load. The permit count is signed, and that is load-bearing rather than stylistic. The claim is an optimistic decrement undone on overdraw, which costs one atomic on the success path where a compare-exchange loop costs one plus a retry per losing race. Unsigned, an overdraw would wrap to a huge value and a concurrent producer reading it would conclude there was room and proceed -- admitting more claimants than there are slots, which is precisely what the shape exists to prevent. Reservations keep today's semantics exactly: a reservation takes a permit and no ticket, so it withholds capacity without occupying a position and cannot stall the consumer however long it is held. Tested directly rather than assumed. 23 tests, and the ones that matter are about admission rather than enqueueing. Verified by sabotage rather than trusted for being green: making `take_permit` always succeed fails six tests in 0.00s, including the reserve-versus-push race; removing the overdraw undo fails three, including the concurrent refusal storm. Both reverted and re-confirmed at 308 passing. No test asserts that BOUNDS_MAX is itself an acceptable capacity: the module's const block already asserts both halves, and a const assertion fails the build rather than a run somebody chose to make. Clippy flagged the first draft's test for exactly this and it was deleted rather than silenced. Completed item: SH-15.3: Arm A -- the central-permit claim, as a duplicated shape. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 2 +- crates/windows-waitable-queues/Cargo.toml | 8 + crates/windows-waitable-queues/src/lib.rs | 8 + .../src/permit_mpsc.rs | 497 ++++++++++++++++++ .../src/permit_mpsc/tests.rs | 389 ++++++++++++++ 5 files changed, 903 insertions(+), 1 deletion(-) create mode 100644 crates/windows-waitable-queues/src/permit_mpsc.rs create mode 100644 crates/windows-waitable-queues/src/permit_mpsc/tests.rs diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index ee91191c..51abb0e7 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -821,7 +821,7 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. platform-integrity rule. Amend rather than reverse: the refusal may well stand, but every reason currently given for it is either wrong or incomplete. -- [ ] **SH-15.3** -- **Arm A: the central-permit claim, as a duplicated shape.** Admission becomes a +- [x] **SH-15.3** -- **Arm A: the central-permit claim, as a duplicated shape.** Admission becomes a single atomic on one `permits` counter initialised to the capacity, and the position degrades to a pure ticket (`fetch_add`, which has no predicate and therefore cannot be revalidated wrongly). A producer holding a permit and taking ticket `p` has `p - head <= capacity - 1` by counting, so its diff --git a/crates/windows-waitable-queues/Cargo.toml b/crates/windows-waitable-queues/Cargo.toml index aeb5c6ca..2ffecaea 100644 --- a/crates/windows-waitable-queues/Cargo.toml +++ b/crates/windows-waitable-queues/Cargo.toml @@ -29,6 +29,14 @@ publish = true default-target = "x86_64-pc-windows-msvc" targets = ["x86_64-pc-windows-msvc"] +[features] +# An experimental claim protocol, measured against the shipping one by +# `probe-queue-contention` so that SH-14.3 can be decided on evidence. Not +# covered by this crate's semver promise; it will either be merged into +# `reserving_mpsc` or deleted (SH-15.6). Non-default so nothing depends on it +# by accident. +experimental-permit-claim = [] + [lib] path = "src/lib.rs" diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 92e439e4..00031753 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -258,6 +258,14 @@ mod error; mod metrics; #[cfg(windows)] mod options; +/// **Experimental, and not covered by this crate's semver promise.** +/// +/// A duplicate of [`reserving_mpsc`] differing only in its claim protocol, +/// built to be measured against it so that the ABA hole recorded as `SH-14.1` +/// can be closed on evidence rather than on judgement. It will either be merged +/// into `reserving_mpsc` or deleted. +#[cfg(all(windows, feature = "experimental-permit-claim"))] +pub mod permit_mpsc; #[cfg(all(windows, test))] mod race_hooks; #[cfg(windows)] diff --git a/crates/windows-waitable-queues/src/permit_mpsc.rs b/crates/windows-waitable-queues/src/permit_mpsc.rs new file mode 100644 index 00000000..fd0bfb36 --- /dev/null +++ b/crates/windows-waitable-queues/src/permit_mpsc.rs @@ -0,0 +1,497 @@ +// Copyright (c) Mike Grier. + +//! **Experimental.** A reserving MPSC whose admission is a permit, not a room check. +//! +//! Not a shipping shape. This exists to be measured against +//! [`reserving_mpsc`](crate::reserving_mpsc) so that SH-14.3 can be decided on +//! evidence, and it is gated behind the non-default `experimental-permit-claim` +//! feature so that nothing depends on it by accident. It is exempt from this +//! crate's semver promise and will either be merged into `reserving_mpsc` or +//! deleted; see `SH-15.6`. +//! +//! # The one thing that differs +//! +//! Everything here -- the ring, the slot sequence, the publication order, the +//! doorbell ring, the reservation semantics -- is `reserving_mpsc`'s. **Only the +//! claim protocol changes**, because that is the variable under test and +//! anything else that differed would confound the measurement. +//! +//! `reserving_mpsc` decides "there is room" by reading the consumer's `head`, +//! and then compare-exchanges a claim word that does not contain `head`. The +//! decision and the operation that acts on it are separate, which is +//! [SH-14.1](../../../CHECKLIST-ship-topology-and-queues.md): a producer stalled +//! between them resumes after the 32-bit position field has recurred, its +//! exchange succeeds against a numerically equal but generations-later value, +//! and it writes a slot whose freedom was decided long ago. +//! +//! Here the decision *is* the operation. A producer takes a permit from a count +//! of unspoken-for slots with one atomic, and that single modification both +//! decides and claims. The predicate is a function solely of the word being +//! modified, so recurrence of any *other* value cannot invalidate it -- which is +//! the criterion [D-34](../DESIGN-NOTES.md#d-34) records. The position stops +//! carrying any decision at all and becomes a ticket handed out by `fetch_add`, +//! an operation with no predicate to be wrong about. It may wrap freely. +//! +//! # Why this does not contradict D-17 +//! +//! [D-17](../DESIGN-NOTES.md#d-17) packs the reservation count and the position +//! into one word, and argues that two atomics cannot be made correct with any +//! amount of fencing: a pusher reads the count then writes the position while a +//! reserver writes the count then reads the position, each missing the other, +//! and no fence forbids it. That argument is sound and it is not evaded here. +//! +//! It concludes that "two independent claimants on one resource must synchronise +//! on one location". This shape agrees and picks a *different* single location. +//! Both claimants perform the same modification on `permits`; neither reads a +//! value the other writes elsewhere and then acts on it. The hazard D-17 +//! describes needs a load-then-store on one side and a store-then-load on the +//! other, and there is no such pair here. +//! +//! # What this does not change +//! +//! **It is still technically blocking**, and no rearrangement of the claim can +//! make it otherwise while items live in the ring: a producer holding ticket `p` +//! that is preempted before publishing stalls a consumer that must deliver `p` +//! in order. In-order delivery, inline storage, and non-blocking progress are +//! over-constrained together. See `SH-inf.1`. + +use core::cell::{Cell, UnsafeCell}; +use core::marker::PhantomData; +use core::mem::MaybeUninit; +use core::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicUsize, Ordering}; +use std::sync::Arc; + +use crate::CacheAligned; +use crate::capacity::{Bounds, MAX_ADMISSIBLE_CAPACITY, validate_capacity}; +use crate::doorbell::Doorbell; +use crate::error::{CapacityError, PushError}; +use crate::metrics::Metrics; + +/// What this shape accepts as a capacity. See [`BOUNDS_MAX`]. +const BOUNDS: Bounds = Bounds { + min: 2, + max: BOUNDS_MAX, +}; + +/// The largest capacity this shape accepts. +/// +/// The same ceiling as [`reserving_mpsc`](crate::reserving_mpsc), so the two are +/// measured over the same range rather than over ranges that happen to differ. +/// +/// Note what is *not* the reason for it here. In `reserving_mpsc` the ceiling is +/// forced by the packing -- half a word for the position, half for the count. +/// This shape has no packed word and could take the crate-wide bound directly; +/// it takes the narrower one anyway so that a measurement at a given capacity is +/// a measurement of the claim protocol and not of two different capacities. +pub const BOUNDS_MAX: usize = { + let packed = 1_usize << 31; + if packed <= MAX_ADMISSIBLE_CAPACITY { + packed + } else { + MAX_ADMISSIBLE_CAPACITY + } +}; + +const _: () = { + assert!( + BOUNDS.max.is_power_of_two(), + "the maximum is offered to a caller as a capacity it could use, so it must itself be one \ + this shape would accept" + ); + assert!( + BOUNDS.min <= BOUNDS.max, + "a shape that accepts nothing would reject every capacity with a suggestion it would also \ + reject" + ); + // The permit count is signed and may go transiently negative by at most one + // per concurrent claimant (see `take_permit`), so the capacity must leave + // room below `i64::MAX` for every thread that could be in flight. A 2^31 + // ceiling against a 2^63 counter leaves 2^32 threads of headroom, which is + // more than the process can create. + assert!( + BOUNDS.max as i64 <= i64::MAX / 2, + "the permit count must hold the whole capacity with room for transient overdraft" + ); +}; + +/// Creates an experimental permit-claiming MPSC queue. +/// +/// `capacity` must be a power of two between two and [`BOUNDS_MAX`]. +/// +/// # Errors +/// +/// [`CapacityError`] when the capacity is outside what this shape accepts. +pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { + validate_capacity(capacity, BOUNDS)?; + + let mut slots = Vec::with_capacity(capacity); + for index in 0..capacity { + slots.push(Slot { + // Anything that is not `position + 1` for the position this slot + // first serves. Matches `reserving_mpsc`'s initialisation exactly. + sequence: AtomicU32::new(index as u32), + value: UnsafeCell::new(MaybeUninit::uninit()), + }); + } + + let shared = Arc::new(Shared { + metrics: Metrics::new(false), + slots: slots.into_boxed_slice(), + mask: capacity - 1, + capacity, + head: CacheAligned(AtomicU32::new(0)), + tail: CacheAligned(AtomicU32::new(0)), + permits: CacheAligned(AtomicI64::new(capacity as i64)), + producers: AtomicUsize::new(1), + consumer_live: AtomicBool::new(true), + doorbell: Doorbell::new(), + }); + + Ok(( + Producer { + shared: Arc::clone(&shared), + not_sync: PhantomData, + }, + Consumer { + shared, + not_sync: PhantomData, + }, + )) +} + +/// One cell of the ring. +struct Slot { + /// `position + 1` once the producer holding `position` has finished writing. + sequence: AtomicU32, + value: UnsafeCell>, +} + +struct Shared { + metrics: Metrics, + slots: Box<[Slot]>, + mask: usize, + capacity: usize, + /// The consumer's position. + /// + /// **No producer reads this**, which is the structural change: in + /// `reserving_mpsc` every push loads it to count free slots, and that load + /// is both the cost D-26 measured and the stale input SH-14.1 exploits. + /// Here it is consumer-private, kept atomic only so `len` can sample it. + head: CacheAligned, + /// The ticket dispenser. Only ever `fetch_add`. + /// + /// Carries no decision, so it has no predicate that a recurrence could + /// invalidate, and it is free to wrap. + tail: CacheAligned, + /// Slots not currently spoken for, as a signed count. + /// + /// Signed because the claim is an optimistic decrement that may overshoot; + /// see [`Shared::take_permit`]. + permits: CacheAligned, + producers: AtomicUsize, + consumer_live: AtomicBool, + doorbell: Doorbell, +} + +// SAFETY: as `reserving_mpsc`'s -- a slot is written by exactly one producer, +// the one whose ticket named that position, and read by exactly one consumer +// after it observes the release store that publishes it. +unsafe impl Sync for Shared {} +// SAFETY: as above. +unsafe impl Send for Shared {} + +impl Shared { + /// Takes one permit, or reports that none was available. + /// + /// **This single modification both decides and claims**, which is the whole + /// point of the shape. A permit in hand is a guarantee that a slot exists + /// for its holder; nothing observed before the modification has to still be + /// true afterwards, because nothing observed before the modification was + /// used. + /// + /// Optimistic rather than a compare-exchange loop: the decrement is + /// unconditional and is undone when it turns out to have overdrawn. That + /// costs one atomic on the success path where a loop costs one *plus* a + /// retry per losing race, and contention is the regime under test. + /// + /// **Signed, and that is load-bearing.** An unsigned count would wrap to a + /// huge value on overdraw, and a concurrent producer reading it would + /// conclude there was room and proceed -- admitting more claimants than + /// there are slots, which is precisely the failure this shape exists to + /// prevent. Signed, an overdraw is visibly negative to everyone: each + /// overdrawing thread sees its own non-positive result and undoes. + /// + /// The count can go no lower than `-(concurrent claimants)`, since each + /// subtracts one before undoing. + fn take_permit(&self) -> bool { + // Acquire: pairs with the consumer's release in `release_permit`, so a + // slot freed there is safe to overwrite by the time this returns. RMWs + // on one location form a release sequence, so this synchronizes with + // every earlier release on it, not merely the latest. + if self.permits.0.fetch_sub(1, Ordering::Acquire) > 0 { + return true; + } + // Overdrawn. Put it back; a concurrent claimant that saw the negative + // value is doing the same. + self.permits.0.fetch_add(1, Ordering::Relaxed); + false + } + + /// Returns one permit, freeing the slot it stood for. + /// + /// Release: the consumer's read of the slot must not become visible after + /// this, or a producer could take the permit and overwrite an item the + /// consumer had not finished taking. This store is what frees the slot, + /// exactly as advancing `head` is in `reserving_mpsc`. + fn release_permit(&self) { + self.permits.0.fetch_add(1, Ordering::Release); + } + + /// Writes and publishes `item` at `position`. + /// + /// # Safety + /// + /// The caller must hold a permit and the ticket naming `position`, so that + /// no other producer can write this slot and the consumer has finished with + /// whatever it held a lap ago. + unsafe fn publish(&self, position: u32, item: T) { + let slot = &self.slots[position as usize & self.mask]; + // SAFETY: the caller's ticket makes this thread the only writer, and its + // permit means the consumer has finished with the previous occupant. + unsafe { + (*slot.value.get()).write(item); + } + + // Release, and this is the publication: it must come after the write. + slot.sequence + .store(position.wrapping_add(1), Ordering::Release); + + // After the publication, never before. Kept here rather than omitted + // because `reserving_mpsc` rings on every publish, and a push path + // missing it would measure faster for a reason that has nothing to do + // with the claim protocol. + self.doorbell.signal(); + } + + fn len(&self) -> usize { + let tail = self.tail.0.load(Ordering::Acquire); + let head = self.head.0.load(Ordering::Acquire); + (tail.wrapping_sub(head) as usize).min(self.capacity) + } +} + +impl Drop for Shared { + fn drop(&mut self) { + // Every handle is gone, so the positions can be read directly. A slot + // whose sequence marks it published still holds an item nobody took. + let head = self.head.0.load(Ordering::Relaxed); + let tail = self.tail.0.load(Ordering::Relaxed); + let mut position = head; + while position != tail { + let slot = &self.slots[position as usize & self.mask]; + if slot.sequence.load(Ordering::Relaxed) == position.wrapping_add(1) { + // SAFETY: the sequence says a producer finished writing this + // slot and no consumer took it. Every handle is gone, so this + // is the only reader, and each position is visited once. + unsafe { + (*slot.value.get()).assume_init_drop(); + } + } + position = position.wrapping_add(1); + } + } +} + +/// A handle that can push. Clone it for more producers. +pub struct Producer { + shared: Arc>, + not_sync: PhantomData>, +} + +// SAFETY: the shared state is `Sync` for `T: Send`; the handle adds nothing. +unsafe impl Send for Producer {} + +impl Clone for Producer { + fn clone(&self) -> Self { + self.shared.producers.fetch_add(1, Ordering::Relaxed); + Self { + shared: Arc::clone(&self.shared), + not_sync: PhantomData, + } + } +} + +impl Drop for Producer { + fn drop(&mut self) { + if self.shared.producers.fetch_sub(1, Ordering::AcqRel) == 1 { + self.shared.doorbell.signal(); + } + } +} + +impl Producer { + /// Pushes an item, or hands it back. + /// + /// # Errors + /// + /// [`PushError::Full`] when no unreserved room remains, and + /// [`PushError::Disconnected`] when the consumer is gone. + pub fn push(&self, item: T) -> Result<(), PushError> { + if !self.shared.consumer_live.load(Ordering::Acquire) { + return Err(PushError::Disconnected(item)); + } + if !self.shared.take_permit() { + // Report disconnection in preference to fullness, matching + // `reserving_mpsc`: a full queue whose consumer is gone will never + // drain, so telling the caller to retry would be telling it to spin + // forever. + if !self.shared.consumer_live.load(Ordering::Acquire) { + return Err(PushError::Disconnected(item)); + } + self.shared.metrics.record_refusal(); + return Err(PushError::Full(item)); + } + + // The permit is held from here until the consumer takes the item. The + // ticket carries no decision, so a relaxed fetch-add is enough: what + // orders the write is the permit's acquire above and the release store + // that publishes the slot. + let position = self.shared.tail.0.fetch_add(1, Ordering::Relaxed); + + // SAFETY: this thread holds a permit and the ticket naming `position`. + unsafe { + self.shared.publish(position, item); + } + Ok(()) + } + + /// Claims a slot now for a message sent later. + /// + /// Takes a permit and **no ticket**, matching `reserving_mpsc`: an + /// outstanding reservation reduces the room available to other producers + /// without occupying a position, so it cannot stall the consumer however + /// long it is held. + /// + /// # Errors + /// + /// [`PushError::Full`] when no room remains. + pub fn reserve(&self) -> Result, PushError<()>> { + if !self.shared.take_permit() { + self.shared.metrics.record_refusal(); + return Err(PushError::Full(())); + } + self.shared.producers.fetch_add(1, Ordering::Relaxed); + Ok(Reservation { + shared: Arc::clone(&self.shared), + spent: false, + }) + } + + /// How many pushes have been refused for want of room. + #[must_use] + pub fn refused(&self) -> u64 { + self.shared.metrics.refused() + } +} + +/// A slot claimed in advance. +pub struct Reservation { + shared: Arc>, + spent: bool, +} + +// SAFETY: as `Producer`'s. +unsafe impl Send for Reservation {} + +impl Reservation { + /// Delivers the message the reservation was taken for. + /// + /// Cannot fail for want of room: the permit taken at `reserve` is still + /// held, so a slot is guaranteed. + pub fn send(mut self, item: T) { + self.spent = true; + let position = self.shared.tail.0.fetch_add(1, Ordering::Relaxed); + // SAFETY: the permit taken at `reserve` is still held and this ticket + // names a position no other producer can hold. + unsafe { + self.shared.publish(position, item); + } + } +} + +impl Drop for Reservation { + fn drop(&mut self) { + if !self.spent { + // Never redeemed: give the room back. + self.shared.release_permit(); + } + if self.shared.producers.fetch_sub(1, Ordering::AcqRel) == 1 { + self.shared.doorbell.signal(); + } + } +} + +/// The single consuming handle. +pub struct Consumer { + shared: Arc>, + not_sync: PhantomData>, +} + +// SAFETY: as `Producer`'s. +unsafe impl Send for Consumer {} + +impl Drop for Consumer { + fn drop(&mut self) { + self.shared.consumer_live.store(false, Ordering::Release); + } +} + +impl Consumer { + /// Takes the next item, if one has been published. + pub fn pop(&self) -> Option { + // Relaxed: this thread is the only writer of `head`. + let position = self.shared.head.0.load(Ordering::Relaxed); + let slot = &self.shared.slots[position as usize & self.shared.mask]; + // Acquire: pairs with the producer's release store in `publish`. + if slot.sequence.load(Ordering::Acquire) != position.wrapping_add(1) { + return None; + } + + // SAFETY: the sequence says the producer holding this position finished + // writing, and the acquire above makes that write visible. This is the + // only consumer and the position is given up below, so the item is read + // exactly once. + let item = unsafe { (*slot.value.get()).assume_init_read() }; + + // Relaxed is enough for `head` here, unlike `reserving_mpsc`, because no + // producer reads it. The release that actually frees the slot is the + // permit below. + self.shared + .head + .0 + .store(position.wrapping_add(1), Ordering::Relaxed); + self.shared.release_permit(); + Some(item) + } + + /// The exact number of items this queue holds when full. + #[must_use] + pub fn capacity(&self) -> usize { + self.shared.capacity + } + + /// Items currently held, as a snapshot. + #[must_use] + pub fn len(&self) -> usize { + self.shared.len() + } + + /// Whether the queue holds nothing, as a snapshot. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-waitable-queues/src/permit_mpsc/tests.rs b/crates/windows-waitable-queues/src/permit_mpsc/tests.rs new file mode 100644 index 00000000..890e277a --- /dev/null +++ b/crates/windows-waitable-queues/src/permit_mpsc/tests.rs @@ -0,0 +1,389 @@ +// Copyright (c) Mike Grier. + +//! Tests for the experimental permit-claiming MPSC. +//! +//! These are not merely "does it enqueue". The shape exists to make a +//! particular hazard impossible, so the tests that matter are the ones about +//! *admission*: that the queue never admits more claimants than it has slots, +//! that a reservation holds room back without occupying a position, and that an +//! overdrawn permit count is always restored. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; + +use super::*; +use crate::error::PushError; + +/// A payload that reports its own destruction, so leaks and double-drops in +/// teardown are observable rather than assumed. +#[derive(Debug)] +struct Tracked(Arc); + +impl Drop for Tracked { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +#[test] +fn a_capacity_below_the_minimum_is_refused() { + assert!(bounded::(1).is_err()); + assert!(bounded::(0).is_err()); +} + +#[test] +fn a_capacity_that_is_not_a_power_of_two_is_refused() { + assert!(bounded::(3).is_err()); + assert!(bounded::(100).is_err()); +} + +#[test] +fn a_capacity_above_the_maximum_is_refused() { + assert!(bounded::(BOUNDS_MAX.wrapping_mul(2)).is_err()); +} + +// Deliberately no test that `BOUNDS_MAX` is itself an acceptable capacity. That +// is a fact about constants, and the module's `const _: () = { ... }` block +// already asserts both halves of it -- a const assertion fails the build rather +// than a run somebody chose to make, so a test here would be the weaker +// statement of a property already guaranteed. + +#[test] +fn an_item_pushed_is_the_item_popped() { + let (tx, rx) = bounded::(4).expect("a valid capacity"); + tx.push(7).expect("room"); + assert_eq!(rx.pop(), Some(7)); +} + +#[test] +fn popping_an_empty_queue_reports_nothing() { + let (_tx, rx) = bounded::(4).expect("a valid capacity"); + assert_eq!(rx.pop(), None); +} + +#[test] +fn items_come_back_in_the_order_they_went_in() { + let (tx, rx) = bounded::(8).expect("a valid capacity"); + for value in 0..8 { + tx.push(value).expect("room"); + } + for value in 0..8 { + assert_eq!(rx.pop(), Some(value)); + } + assert_eq!(rx.pop(), None); +} + +#[test] +fn the_queue_holds_exactly_its_capacity_and_then_refuses() { + let (tx, rx) = bounded::(4).expect("a valid capacity"); + for value in 0..4 { + tx.push(value).expect("room"); + } + assert_eq!(rx.len(), 4); + match tx.push(99) { + Err(PushError::Full(item)) => assert_eq!(item, 99), + other => panic!("expected Full, got {:?}", other.is_ok()), + } +} + +#[test] +fn a_refusal_hands_the_item_back_and_is_counted() { + let (tx, _rx) = bounded::(2).expect("a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + assert_eq!(tx.refused(), 0); + assert!(tx.push(3).is_err()); + assert_eq!(tx.refused(), 1); +} + +#[test] +fn a_refusal_leaves_the_permit_count_intact() { + // The optimistic decrement overdraws and must undo. If it did not, a single + // refusal would permanently cost the queue a slot -- so the queue must + // still accept exactly `capacity` items after many refusals. + let (tx, rx) = bounded::(4).expect("a valid capacity"); + for value in 0..4 { + tx.push(value).expect("room"); + } + for _ in 0..1_000 { + assert!(tx.push(99).is_err()); + } + for value in 0..4 { + assert_eq!(rx.pop(), Some(value)); + } + // Every slot came back. + for value in 0..4 { + tx.push(value).expect("room after draining"); + } + assert_eq!(rx.len(), 4); +} + +#[test] +fn a_concurrent_refusal_storm_leaves_the_permit_count_intact() { + // The overdraft is bounded by the number of concurrent claimants, so the + // count can go transiently negative. What must not happen is that it fails + // to return to exactly the capacity. + let (tx, rx) = bounded::(4).expect("a valid capacity"); + for value in 0..4 { + tx.push(value).expect("room"); + } + thread::scope(|scope| { + for _ in 0..8 { + let tx = tx.clone(); + scope.spawn(move || { + for _ in 0..2_000 { + assert!(tx.push(99).is_err()); + } + }); + } + }); + for value in 0..4 { + assert_eq!(rx.pop(), Some(value)); + } + for value in 0..4 { + tx.push(value).expect("room after draining"); + } + assert!(tx.push(99).is_err(), "capacity must not have grown"); +} + +#[test] +fn the_ring_is_reused_across_many_laps() { + // Far more pushes than slots, so every slot serves many positions. This is + // ring wraparound, not position wraparound. + let (tx, rx) = bounded::(4).expect("a valid capacity"); + for value in 0..10_000 { + tx.push(value).expect("room"); + assert_eq!(rx.pop(), Some(value)); + } + assert_eq!(rx.pop(), None); +} + +#[test] +fn a_reservation_holds_room_back_from_other_producers() { + let (tx, _rx) = bounded::(4).expect("a valid capacity"); + let _reservation = tx.reserve().expect("room"); + // Three slots remain for best-effort pushes. + for value in 0..3 { + tx.push(value).expect("room"); + } + assert!( + tx.push(99).is_err(), + "the reserved slot must not be available to a push" + ); +} + +#[test] +fn a_reservation_delivers_even_when_the_queue_is_otherwise_full() { + let (tx, rx) = bounded::(4).expect("a valid capacity"); + let reservation = tx.reserve().expect("room"); + for value in 0..3 { + tx.push(value).expect("room"); + } + assert!(tx.push(99).is_err()); + // Reserved is guaranteed: this cannot fail. + reservation.send(42); + assert_eq!(rx.len(), 4); + for value in 0..3 { + assert_eq!(rx.pop(), Some(value)); + } + assert_eq!(rx.pop(), Some(42)); +} + +#[test] +fn a_reservation_dropped_unredeemed_gives_the_room_back() { + let (tx, _rx) = bounded::(4).expect("a valid capacity"); + { + let _reservation = tx.reserve().expect("room"); + for value in 0..3 { + tx.push(value).expect("room"); + } + assert!(tx.push(99).is_err()); + } + tx.push(99) + .expect("the dropped reservation released its slot"); +} + +#[test] +fn an_outstanding_reservation_does_not_block_the_consumer() { + // The semantic that distinguishes this from taking a ticket at reserve + // time: a reservation withholds capacity but occupies no position, so items + // pushed after it are delivered without waiting for it. + let (tx, rx) = bounded::(8).expect("a valid capacity"); + let reservation = tx.reserve().expect("room"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + assert_eq!(rx.pop(), Some(1)); + assert_eq!(rx.pop(), Some(2)); + assert_eq!(rx.pop(), None); + reservation.send(3); + assert_eq!(rx.pop(), Some(3)); +} + +#[test] +fn every_reservation_the_capacity_allows_can_be_taken_at_once() { + let (tx, rx) = bounded::(4).expect("a valid capacity"); + let reservations: Vec<_> = (0..4).map(|_| tx.reserve().expect("room")).collect(); + assert!(tx.push(99).is_err(), "every slot is spoken for"); + for (value, reservation) in reservations.into_iter().enumerate() { + reservation.send(value as u32); + } + for value in 0..4 { + assert_eq!(rx.pop(), Some(value)); + } +} + +#[test] +fn a_push_to_a_departed_consumer_is_reported_as_disconnection() { + let (tx, rx) = bounded::(4).expect("a valid capacity"); + drop(rx); + match tx.push(1) { + Err(PushError::Disconnected(item)) => assert_eq!(item, 1), + _ => panic!("expected Disconnected"), + } +} + +#[test] +fn a_full_queue_whose_consumer_is_gone_reports_disconnection_not_fullness() { + // Telling a caller to retry a queue that will never drain is telling it to + // spin forever, so disconnection wins over fullness. + let (tx, rx) = bounded::(2).expect("a valid capacity"); + tx.push(1).expect("room"); + tx.push(2).expect("room"); + drop(rx); + match tx.push(3) { + Err(PushError::Disconnected(_)) => {} + _ => panic!("expected Disconnected on a full, consumerless queue"), + } +} + +#[test] +fn undrained_items_are_dropped_exactly_once_at_teardown() { + let drops = Arc::new(AtomicUsize::new(0)); + { + let (tx, rx) = bounded::(4).expect("a valid capacity"); + for _ in 0..3 { + tx.push(Tracked(Arc::clone(&drops))).expect("room"); + } + // Take one, so teardown must drop exactly the two that remain. + drop(rx.pop().expect("an item")); + assert_eq!(drops.load(Ordering::Relaxed), 1); + } + assert_eq!( + drops.load(Ordering::Relaxed), + 3, + "every item must be dropped exactly once" + ); +} + +#[test] +fn teardown_after_a_lap_drops_only_the_live_items() { + // Slots hold stale bit patterns from earlier laps; teardown must not drop + // those a second time. + let drops = Arc::new(AtomicUsize::new(0)); + { + let (tx, rx) = bounded::(4).expect("a valid capacity"); + for _ in 0..12 { + tx.push(Tracked(Arc::clone(&drops))).expect("room"); + drop(rx.pop().expect("an item")); + } + assert_eq!(drops.load(Ordering::Relaxed), 12); + // Two live items remain at teardown. + tx.push(Tracked(Arc::clone(&drops))).expect("room"); + tx.push(Tracked(Arc::clone(&drops))).expect("room"); + } + assert_eq!(drops.load(Ordering::Relaxed), 14); +} + +#[test] +fn many_producers_deliver_every_item_exactly_once() { + const PRODUCERS: u32 = 8; + const EACH: u32 = 2_000; + + let (tx, rx) = bounded::(64).expect("a valid capacity"); + let mut seen = vec![0_u32; (PRODUCERS * EACH) as usize]; + + thread::scope(|scope| { + for producer in 0..PRODUCERS { + let tx = tx.clone(); + scope.spawn(move || { + for index in 0..EACH { + let value = producer * EACH + index; + // Bounded queue: retry rather than lose the item. + while tx.push(value).is_err() { + std::hint::spin_loop(); + } + } + }); + } + let mut taken = 0; + while taken < PRODUCERS * EACH { + if let Some(value) = rx.pop() { + seen[value as usize] += 1; + taken += 1; + } else { + std::hint::spin_loop(); + } + } + }); + + assert!( + seen.iter().all(|&count| count == 1), + "every item must arrive exactly once" + ); +} + +#[test] +fn the_queue_never_admits_more_claimants_than_it_has_slots() { + // The property the shape exists for, stated as an observable: at no moment + // may the number of items held exceed the capacity. A permit system that + // over-admitted would show up here as a length beyond the bound. + const CAPACITY: usize = 16; + let (tx, rx) = bounded::(CAPACITY).expect("a valid capacity"); + + thread::scope(|scope| { + for _ in 0..8 { + let tx = tx.clone(); + scope.spawn(move || { + for value in 0..4_000 { + let _ = tx.push(value); + } + }); + } + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(400); + while std::time::Instant::now() < deadline { + assert!( + rx.len() <= CAPACITY, + "the queue reported holding more than its capacity" + ); + let _ = rx.pop(); + } + }); + while rx.pop().is_some() {} +} + +#[test] +fn a_producer_and_a_reservation_contend_for_the_same_room_without_overdrawing() { + // `reserve` and `push` are two claimants on one resource. If they could + // both be admitted to the last slot, the queue would owe a slot that does + // not exist -- which is the hazard D-17's packing exists to prevent, and + // which this shape prevents with a single shared permit count instead. + for _ in 0..200 { + let (tx, rx) = bounded::(2).expect("a valid capacity"); + tx.push(0).expect("room"); + // One slot left, two claimants racing for it. + let reserver = tx.clone(); + let pusher = tx.clone(); + let (reserved, pushed) = thread::scope(|scope| { + let a = scope.spawn(move || reserver.reserve().ok()); + let b = scope.spawn(move || pusher.push(1).is_ok()); + (a.join().expect("no panic"), b.join().expect("no panic")) + }); + let claims = usize::from(reserved.is_some()) + usize::from(pushed); + assert!(claims <= 1, "both claimants took the same single slot"); + if let Some(reservation) = reserved { + reservation.send(2); + } + drop(rx); + } +} From c5e5b1cf16f7bd8db0caaf6f49d3785701277f77 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 11:22:33 -0400 Subject: [PATCH 208/361] feat(probes): measure the permit claim against the shipping shapes (D-35) Wires `permit_mpsc` into `probe-queue-contention` in both regimes and records the result as D-35. The probe measures real shapes rather than stand-ins, so the experimental feature is enabled on the probe crate's dependency; the two timing functions are line-for-line twins of the reserving ones with the shape substituted, deliberately not factored into a generic, which would put an indirection inside a timed region whose whole output is a few nanoseconds per push. The result inverts the expectation. In the isolated regime the permit claim is 2.7x cheaper per push than `reserving_mpsc` at 16 and 32 producers, and 12x cheaper than `slotwise_mpsc`, while being 1.45x more expensive at one. The crossover is between two and four producers. That was not predicted. The permit claim touches TWO shared lines on the push path where the shipping shape touches one plus a read, and D-26 had already established that the shared line is what collapses -- so a second one was expected to cost. The mechanism turns out to be retries, not lines: both of the permit shape's operations are unconditional read-modify-writes that cannot fail and never retry, where the shipping shape's compare-exchange retries once per lost race, and at 32 producers nearly every race is lost. Two corroborations from the same table. It is the only shape that gets FASTER per push as producers are added (42.8 ns at two to 19.5 at thirty-two), which is what a claim with no retry storm looks like. And it is the only one that stays near the floor -- 1.46x a bare contended fetch_add at 32 producers, while doing two of them plus a slot write plus a doorbell ring, against 4.0x for reserving and 17x for slotwise. Where it loses is explained rather than excused: uncontended, the shipping shape pays a compare-exchange that always succeeds plus a load of a quiet line, and a load is much cheaper than a read-modify-write. The permit claim converts a shared read into a shared RMW -- the wrong trade with no contention, the right one with it. The ABA hole and the contention cost turn out to be the same load. The read of `head` is simultaneously the stale input SH-14.1 exploits and the shared access D-26 measured, so removing it for correctness removed it for performance too. "Closing the hole will cost throughput" was the wrong worry. What this does NOT decide, stated so adoption does not assume it. The drained regime's refusal counts differ between shapes by two to five orders of magnitude and are unstable across runs, with two candidate explanations this harness cannot separate -- the permit shape is faster so attempts more pushes against a full queue, or its optimistic overdraw refuses near-full more readily than the shipping shape's re-read does. The second would be a behavioural change to a public contract, so SH-15.6 is now gated on the new SH-15.5.1 rather than on this measurement. The run was repeated three times. One outlier is recorded rather than dropped: permit at eight producers measured 56.7 ns in the second run against 26.1 and 26.8 in the other two, breaking an otherwise monotone trend. Eight is this host's physical core count, so scheduling variance is plausible, and a reader re-running this will likely see it. Also adds `Consumer::refused()` to the prototype, matching the shipping shapes: the harness drops its producers before reading the count, so a producer-only accessor is unreachable exactly when the number is wanted. Completed item: SH-15.5: Measure arm A against the shipping shape in `probe-queue-contention`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 28 +++++- crates/windows-platform-probes/Cargo.toml | 7 +- .../src/bin/queue_contention.rs | 53 ++++++++--- .../src/queue_contention.rs | 94 ++++++++++++++++++- .../windows-waitable-queues/DESIGN-NOTES.md | 91 ++++++++++++++++++ .../src/permit_mpsc.rs | 11 +++ 6 files changed, 268 insertions(+), 16 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 51abb0e7..aaa3d97e 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -833,7 +833,7 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. **Not claimed to be non-blocking.** A preempted ticket-holder still stalls the consumer at its position; this arm fixes the ABA hole and nothing about the progress condition. -- [ ] **SH-15.5** -- **Measure arm A against the shipping shape in `probe-queue-contention`.** The +- [x] **SH-15.5** -- **Measure arm A against the shipping shape in `probe-queue-contention`.** The probe deliberately measures the real shapes rather than stand-ins ("a stand-in would only measure itself"), so the arm must be a real module in the queue crate for this to mean anything. Report both regimes: isolated for the claim cost alone, drained for what the shared line costs when a consumer is @@ -841,12 +841,38 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. throughput, given that arm A touches two shared lines on the push path where today's shape touches one plus a read. +- [ ] **SH-15.5.1** -- **Settle why the two shapes' refusal counts differ by orders of magnitude, + because SH-15.6 cannot be decided without it.** In the drained regime `permit_mpsc` recorded + roughly 460,000 refusals at eight producers where `reserving_mpsc` recorded 0, and the counts are + unstable across runs (`reserving_mpsc` itself recorded 0 and then 2,363 for the same + configuration). Two candidate explanations, which the current harness cannot separate: the permit + shape is genuinely faster, so it attempts more pushes against a full queue and is refused more + often as a consequence; **or** its optimistic overdraw refuses near-full more readily than the + shipping shape's re-read of the claim does, in which case adopting it would change how eagerly a + caller sees backpressure. + The distinction matters and is not cosmetic. `reserving_mpsc` re-reads the claim and retries before + reporting `Full`, so it refuses only when the queue was genuinely full at an instant it observed. + If the permit shape refuses more eagerly, that is a **behavioural change to a public contract**, + and per D-34's own criterion it must be stated rather than discovered by a caller. + Measure refusals per *attempt* rather than per run, at a fixed attempt count with the consumer's + drain rate pinned, so throughput and refusal rate are separated. A test that admits exactly + `capacity` items from N concurrent producers into an initially empty queue would also settle the + narrow question of whether an overdraw can refuse while a slot is provably free. + - [ ] **SH-15.6** -- **Decide: merge, or delete.** The duplicated path exists so the speculative work could proceed without disturbing a working shape; leaving it to become permanent by inattention is the failure mode the duplication rule warns about. On the evidence from SH-15.5, either adopt arm A into `reserving_mpsc` (closing SH-14.1 and SH-14.3) or delete it and take one of SH-14.3's original options, recording why. Whichever way it goes, SH-14.1's hazard must be either fixed or documented as an accepted limitation with its exposure stated -- it may not simply stay open. + **Gated on SH-15.5.1**, not on SH-15.5: the throughput question is answered + ([D-35](crates/windows-waitable-queues/DESIGN-NOTES.md#d-35) -- 2.7x faster at 16-32 producers, + 1.45x slower at one), but adopting a claim that reports backpressure more eagerly would be a + behavioural change to a public contract, and that is not yet known either way. + Note also what the measurement did **not** cover, so adoption does not quietly assume it: the + permit shape has no `Waitable`/`Observable`/`Reserving` trait impls, no `Options`/disposal + integration, no high-water tracking, no race hooks, and no 32-bit run. Merging means writing all of + those, so the merge is a milestone rather than a rename. - [ ] **SH-15.7** -- **Build the stall seam that can actually witness the bug.** SH-14.3 already notes the property is invisible to a test that merely crosses the wrap: it needs a producer *held* between diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index 82c319b5..1deb5b78 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -91,7 +91,12 @@ windows-namespace-request-sys = { version = "0.2.0", path = "../windows-namespac # The contention probe measures the shipping queue shapes rather than a # reimplementation, for the same reason: a stand-in would only measure itself, # and the whole question is what the real tail claim costs. -windows-waitable-queues = { version = "0.1.0", path = "../windows-waitable-queues" } +# The experimental permit claim is enabled here because this probe is what +# decides its fate (SH-15.5): it must be measured against the shipping shapes +# on the same host, in the same run, by the same harness. +windows-waitable-queues = { version = "0.1.0", path = "../windows-waitable-queues", features = [ + "experimental-permit-claim", +] } wtf-string = { version = "0.1.0", path = "../wtf-string" } [dependencies.windows-sys] diff --git a/crates/windows-platform-probes/src/bin/queue_contention.rs b/crates/windows-platform-probes/src/bin/queue_contention.rs index 28ee2a2e..40a52d35 100644 --- a/crates/windows-platform-probes/src/bin/queue_contention.rs +++ b/crates/windows-platform-probes/src/bin/queue_contention.rs @@ -33,19 +33,21 @@ fn main() { // Question 1: does the claim collapse as producers are added? println!(" 1. tail-claim contention (isolated regime)\n"); println!( - " {:<18} {:>12} {:>12} {:>14}", - "producers", "slotwise x1thr", "reserving", "atomic floor" + " {:<18} {:>12} {:>12} {:>12} {:>14}", + "producers", "slotwise x1thr", "reserving", "permit", "atomic floor" ); for &producers in PRODUCER_COUNTS { let mpsc = observation.scaling(&observation.isolated, shapes::SLOTWISE_MPSC, producers); let reserving = observation.scaling(&observation.isolated, shapes::RESERVING_MPSC, producers); + let permit = observation.scaling(&observation.isolated, shapes::PERMIT_MPSC, producers); let floor = observation.scaling(&observation.isolated, shapes::BASELINE_FETCH_ADD, producers); println!( - " {producers:<18} {:>12} {:>12} {:>14}", + " {producers:<18} {:>12} {:>12} {:>12} {:>14}", format_scaling(mpsc), format_scaling(reserving), + format_scaling(permit), format_scaling(floor) ); } @@ -58,29 +60,37 @@ fn main() { // Question 2: what does reserving_mpsc's read of `head` actually cost? println!("\n 2. the price of reservation (drained regime, where `head` is written)\n"); println!( - " {:<18} {:>14} {:>14} {:>10}", - "producers", "slotwise ns/pu", "reserving", "ratio" + " {:<18} {:>14} {:>14} {:>10} {:>14} {:>16}", + "producers", "slotwise ns/pu", "reserving", "ratio", "permit", "permit/reserving" ); for &producers in PRODUCER_COUNTS { let plain = observation.find(&observation.drained, shapes::SLOTWISE_MPSC, producers); let reserving = observation.find(&observation.drained, shapes::RESERVING_MPSC, producers); - let ratio = match (plain, reserving) { - (Some(plain), Some(reserving)) if plain.nanos_per_push > 0.0 => { - format!("{:.2}x", reserving.nanos_per_push / plain.nanos_per_push) - } - _ => "--".to_owned(), - }; + let permit = observation.find(&observation.drained, shapes::PERMIT_MPSC, producers); + let ratio = format_ratio(reserving, plain); + // The column SH-15.5 exists to fill: the experimental claim against the + // shipping shape it would replace. Below 1.00 means the permit claim is + // cheaper; above means removing the room-decision race costs throughput. + let permit_ratio = format_ratio(permit, reserving); println!( - " {producers:<18} {:>14} {:>14} {:>10}", + " {producers:<18} {:>14} {:>14} {:>10} {:>14} {:>16}", format_nanos(plain), format_nanos(reserving), - ratio + ratio, + format_nanos(permit), + permit_ratio ); } println!("\n `reserving_mpsc` reads the consumer's position on every push and"); println!(" `mpsc` does not, which is the entire reason they ship as two"); println!(" shapes. This regime is the one that can price that read, because"); println!(" a consumer is writing the line being read."); + println!("\n `permit_mpsc` is experimental and is the candidate replacement"); + println!(" for `reserving_mpsc`: it removes that read entirely, and with it"); + println!(" the stale room decision behind SH-14.1, by making admission a"); + println!(" read-modify-write on a permit count instead. The last column is"); + println!(" the trade -- below 1.00 and the safer claim is also the cheaper"); + println!(" one; above 1.00 and closing the hole costs throughput."); println!("\n CAUTION: the drained regime has ONE consumer, because that is what"); println!(" MPSC means. At high producer counts it is expected to become"); @@ -106,6 +116,23 @@ fn format_scaling(scaling: Option) -> String { scaling.map_or_else(|| "--".to_owned(), |value| format!("{value:.2}x")) } +/// `numerator / denominator` as a cost ratio, or `--` when either is missing. +/// +/// Guards the denominator rather than trusting it: a shape that failed to run +/// reports zero, and a division by it would print `inf` or `NaN` in a column a +/// reader would otherwise take for a measurement. +fn format_ratio(numerator: Option, denominator: Option) -> String { + match (numerator, denominator) { + (Some(numerator), Some(denominator)) if denominator.nanos_per_push > 0.0 => { + format!( + "{:.2}x", + numerator.nanos_per_push / denominator.nanos_per_push + ) + } + _ => "--".to_owned(), + } +} + fn format_nanos(run: Option) -> String { run.map_or_else( || "--".to_owned(), diff --git a/crates/windows-platform-probes/src/queue_contention.rs b/crates/windows-platform-probes/src/queue_contention.rs index f855ab6c..e1b6a1d2 100644 --- a/crates/windows-platform-probes/src/queue_contention.rs +++ b/crates/windows-platform-probes/src/queue_contention.rs @@ -52,7 +52,7 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::thread; use std::time::Instant; -use windows_waitable_queues::{reserving_mpsc, slotwise_mpsc}; +use windows_waitable_queues::{permit_mpsc, reserving_mpsc, slotwise_mpsc}; /// How many pushes each producer thread performs in one timed run. const PUSHES_PER_PRODUCER: usize = 50_000; @@ -85,6 +85,9 @@ pub mod shapes { pub const SLOTWISE_MPSC: &str = "slotwise_mpsc"; /// The reservation-based MPSC. pub const RESERVING_MPSC: &str = "reserving_mpsc"; + /// The experimental permit-claiming MPSC, measured against + /// [`RESERVING_MPSC`] because it is a candidate replacement for it. + pub const PERMIT_MPSC: &str = "permit_mpsc"; /// The uncontended-atomic floor the queues are measured against. pub const BASELINE_FETCH_ADD: &str = "baseline_fetch_add"; } @@ -157,6 +160,9 @@ pub fn measure() -> Observation { isolated.push(median_run(shapes::RESERVING_MPSC, producers, |count| { time_isolated_reserving(count) })); + isolated.push(median_run(shapes::PERMIT_MPSC, producers, |count| { + time_isolated_permit(count) + })); drained.push(median_run(shapes::SLOTWISE_MPSC, producers, |count| { time_drained_mpsc(count) @@ -164,6 +170,9 @@ pub fn measure() -> Observation { drained.push(median_run(shapes::RESERVING_MPSC, producers, |count| { time_drained_reserving(count) })); + drained.push(median_run(shapes::PERMIT_MPSC, producers, |count| { + time_drained_permit(count) + })); } Observation { @@ -308,6 +317,37 @@ fn time_isolated_reserving(producers: usize) -> Repetition { (elapsed, refusals) } +/// The experimental permit claim, in the regime that isolates the claim itself. +/// +/// A line-for-line twin of [`time_isolated_reserving`] with one shape +/// substituted. Deliberately not factored into a generic over the two, which +/// would need a trait both implement and would put a dynamic or monomorphised +/// indirection inside the timed region -- in a measurement whose whole output is +/// a difference of a few nanoseconds per push. +fn time_isolated_permit(producers: usize) -> Repetition { + let (tx, rx) = permit_mpsc::bounded::(capacity_for(producers)).expect("a valid capacity"); + let gate = start_barrier(producers); + let started = thread::scope(|scope| { + for producer in 0..producers { + let tx = tx.clone(); + let gate = Arc::clone(&gate); + scope.spawn(move || { + gate.wait(); + for index in 0..PUSHES_PER_PRODUCER { + tx.push((producer * PUSHES_PER_PRODUCER + index) as u64) + .expect("the run fits in the capacity"); + } + }); + } + gate.wait(); + Instant::now() + }); + let elapsed = started.elapsed().as_nanos() as f64; + let refusals = tx.refused(); + while rx.pop().is_some() {} + (elapsed, refusals) +} + /// A capacity a real system would choose, so the drained regime exercises /// backpressure the way a real one would. const DRAINED_CAPACITY: usize = 1024; @@ -420,3 +460,55 @@ fn time_drained_reserving(producers: usize) -> Repetition { let refusals = consumer.join().expect("the consumer must not panic"); (elapsed, refusals) } + +/// The experimental permit claim, against a continuously draining consumer. +/// +/// The regime that can price the claim honestly, for the same reason the +/// reserving twin needs it: the shared line a producer touches is only +/// expensive when a consumer is writing it. Measured in isolation, an +/// uncontended line looks free -- which would be a confident wrong answer, and +/// this shape has more riding on that answer than the others, because it trades +/// `reserving_mpsc`'s *load* of the consumer's position for a read-modify-write +/// on a count the consumer also writes. +fn time_drained_permit(producers: usize) -> Repetition { + let (tx, rx) = permit_mpsc::bounded::(DRAINED_CAPACITY).expect("a valid capacity"); + let done = Arc::new(AtomicBool::new(false)); + let consumer_done = Arc::clone(&done); + let gate = start_barrier(producers + 1); + let consumer_gate = Arc::clone(&gate); + + let consumer = thread::spawn(move || { + consumer_gate.wait(); + while !consumer_done.load(Ordering::Relaxed) { + while rx.pop().is_some() {} + std::hint::spin_loop(); + } + while rx.pop().is_some() {} + rx.refused() + }); + + let started = thread::scope(|scope| { + for producer in 0..producers { + let tx = tx.clone(); + let gate = Arc::clone(&gate); + scope.spawn(move || { + gate.wait(); + for index in 0..PUSHES_PER_PRODUCER { + let mut item = (producer * PUSHES_PER_PRODUCER + index) as u64; + while let Err(error) = tx.push(item) { + item = error.into_inner(); + std::hint::spin_loop(); + } + } + }); + } + gate.wait(); + Instant::now() + }); + let elapsed = started.elapsed().as_nanos() as f64; + + done.store(true, Ordering::Relaxed); + drop(tx); + let refusals = consumer.join().expect("the consumer must not panic"); + (elapsed, refusals) +} diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 82caf326..fbdcc30a 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -58,6 +58,7 @@ preferred. | D-32 | **`Reserving::Reservation<'a>` gains a bound, before the crate publishes.** The associated type is currently unbounded, so a caller generic over the trait can claim a slot and drop it but never redeem it -- the trait cannot express the operation it exists for. Both implementors already have identical `send` and `is_disconnected` signatures, so the bound is additive; adding it after publication is a breaking change to every implementor. Done as SH-1.5: the [`Claim`](src/traits.rs) trait carries `send` and `is_disconnected`, and both reservation types implement it as forwarders. `Claim` must be in scope to call those methods on a claim whose concrete type the caller has not named, which is why it is re-exported at the crate root. | | D-33 | **`PushError` is `#[non_exhaustive]`, and the one-directional doorbell is disclosed rather than fixed before 0.1.0.** The receive-side errors already carried the attribute and the send side lacked it by omission; adding it after publication is itself breaking, so it is taken now while the crate has no external consumers. Whether a producer can *wait* for room stays open as [M32.3](../../CHECKLIST-io-domains.md) -- it is additive, so it does not gate the release -- but the absence is stated in both the crate docs and the README, because `crossbeam-channel`'s `send` blocks and a reader arriving from it will assume this one does too. | | D-34 | **Every bounded queue surveyed is ABA-safe for one of two reasons, and this crate's `reserving_mpsc` has neither.** Either the claim counter is a whole machine word, so recurrence is unreachable -- crossbeam, concurrent-queue, thingbuf, Vyukov, SCQ's `Head`/`Tail` -- or the authorizing compare-exchange is moved onto the cell, so the decision and the write are validated together (CRQ, SCQ). Ours packs the position into a 32-bit *subfield* and authorizes with an exchange that does not cover the separately-read `head`. Nikolaev (DISC 2019, section 3) states the width assumption the field relies on and states it for **CPU-word** width, which a subfield does not satisfy; DPDK's `rte_ring` is the same protocol as ours and its published justification covers modular arithmetic only. The generalisation -- ours, unstated in any source -- is that **the atomic operation authorizing the write must cover everything the decision depended on.** Survey in [DESIGN-SESSION-2026-09-02](design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md); the fix is M15 in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). | +| D-35 | **Measured: the permit claim is 2.7x faster than `reserving_mpsc` at 16-32 producers, and 1.45x slower at one.** The safer claim is also the faster one everywhere contention exists, which was not the expected result -- it touches *two* shared lines where the shipping shape touches one plus a read, and [D-26](#d-26) had established that the shared line is what collapses. The mechanism is that both of its operations are unconditional read-modify-writes that never retry, where the shipping shape's compare-exchange retries once per lost race; the retries dominate long before the second line does. It is the only shape measured that gets *faster* per push as producers are added (42.8 ns at two to 19.5 at thirty-two) and the only one that stays within 1.5x of a bare contended `fetch_add`. **This decides the shape of the fix but not the fix**: the drained regime's refusal counts differ by orders of magnitude in a way this harness cannot attribute, which SH-15.5.1 exists to settle before SH-15.6 adopts anything. | ## D-2: capabilities are sliced, not gathered @@ -1210,3 +1211,93 @@ Which fix to adopt. That is M15, which prototypes the central-permit claim and m than arguing it -- necessary because [D-26](#d-26) already measured that the single shared line is what collapses under contention, so a protocol that touches two shared lines instead of one is not obviously cheaper. This decision records only the landscape and the criterion. + +## D-35: the permit claim measured, and the result that inverts the expectation + +Run by `probe-queue-contention` on the reference host (x86-64, 16 logical / 8 physical, SMT on), +release build, five repetitions per configuration with the median kept. The whole run was repeated +three times; the isolated numbers reproduced within noise except one outlier noted below. + +### Isolated regime -- producers only, nothing ever refused + +The cleanest measurement of the claim, because nothing else touches the queue. Nanoseconds per push: + +| producers | `slotwise_mpsc` | `reserving_mpsc` | `permit_mpsc` | contended `fetch_add` | +|---|---|---|---|---| +| 1 | 6.3 | 5.5 | 8.0 | 2.4 | +| 2 | 58.6 | 33.9 | 42.8 | 12.7 | +| 4 | 89.6 | 33.5 | 31.5 | 14.7 | +| 8 | 143.4 | 37.9 | 26.1 | 15.9 | +| 16 | 225.1 | 56.1 | 20.5 | 14.6 | +| 32 | 234.3 | 53.1 | 19.5 | 13.4 | + +`permit_mpsc` against `reserving_mpsc`, as a cost ratio: **1.45x, 1.26x, 0.94x, 0.69x, 0.37x, +0.37x**. The crossover is between two and four producers. + +### The result, and why it was not expected + +**The safer claim is also the faster one everywhere contention exists.** At sixteen and thirty-two +producers it is 2.7x cheaper per push than the shape it would replace, and 12x cheaper than +`slotwise_mpsc`. + +That is the opposite of what the design predicted. `permit_mpsc` touches **two** shared lines on the +push path -- the permit count and the ticket -- where `reserving_mpsc` touches one plus a read of +`head`, and [D-26](#d-26) had already established that the single shared line is what collapses +under contention. The expectation was therefore that adding a second one would cost. + +**The mechanism is retries, not lines.** Both of `permit_mpsc`'s operations are unconditional +read-modify-writes: `fetch_sub` on the permits and `fetch_add` on the ticket. Neither can fail, so +neither retries. `reserving_mpsc`'s claim is a `compare_exchange_weak` that retries once per lost +race, and at thirty-two producers almost every race is lost. The retry loop dominates the second +cache line long before the second cache line matters. + +Two corroborating observations, both from the same table: + +- **It is the only shape that gets *faster* per push as producers are added** -- 42.8 ns at two down + to 19.5 at thirty-two. Every other shape, and the bare atomic floor, degrades monotonically. A + claim that cannot fail has no retry storm to suffer, so added producers buy parallelism in the + slot writes without adding claim work. +- **It is the only shape that stays close to the floor.** At thirty-two producers it costs 19.5 ns + against a bare contended `fetch_add`'s 13.4 -- 1.46x, while doing two of them plus a slot write + plus a doorbell ring. `reserving_mpsc` is 4.0x the floor there and `slotwise_mpsc` 17x. + +### Where it loses, and why that is the honest reading + +**At one producer it is 1.45x slower** (8.0 ns against 5.5). Uncontended, `reserving_mpsc` pays one +compare-exchange that always succeeds plus a load of an uncontended line -- and a load is far +cheaper than a read-modify-write. `permit_mpsc` pays two read-modify-writes regardless. The permit +claim converts a shared *read* into a shared *read-modify-write*, which is the wrong trade when +there is no contention and the right one when there is. + +A single-producer queue is a real configuration, so this is a genuine cost and not a rounding error. +It is also exactly the regime in which [D-16](#d-16)'s surviving half already says `spsc` is the +right shape. + +### What this does NOT decide + +**The drained regime is not clean enough to read.** Its refusal counts differ between shapes by two +to five orders of magnitude and are unstable across runs -- `reserving_mpsc` recorded 0 refusals at +eight producers in one run and 2,363 in the next, and `permit_mpsc` recorded roughly 460,000 and +490,000. There are at least two candidate explanations, and this harness cannot separate them: the +permit shape is genuinely faster, so it attempts more pushes against a full queue; or its optimistic +overdraw refuses near-full more readily than the shipping shape's re-read does. Since the probe's own +caution is that a run with many refusals was waiting for the consumer rather than for the claim, +those rows price backpressure rather than admission. + +The drained ratios are recorded for completeness -- 2.19x, 0.85x, 0.94x, 0.75x, 0.92x, 0.66x -- but +the isolated regime is the one that answers the question asked, and the refusal question is queued as +`SH-15.5.1` rather than resolved here. + +**One outlier, recorded rather than dropped.** In the second of the three runs, `permit_mpsc` at +eight producers measured 56.7 ns against 26.1 and 26.8 in the other two, breaking an otherwise +monotone trend. Eight producers is exactly this host's physical core count, so scheduling variance +there is plausible; two of three runs agree closely and the trend either side of that point is +unambiguous. It is noted because a reader re-running this will likely see it too. + +### What it means for SH-14.1 + +The ABA hole and the contention cost turn out to be **the same load**. `reserving_mpsc`'s read of the +consumer's `head` is simultaneously the stale input that SH-14.1 exploits and the shared access D-26 +measured. Removing it for correctness removes it for performance as well, which is why this +measurement came out the way it did -- and why "closing the hole will cost throughput" was the wrong +thing to have worried about. diff --git a/crates/windows-waitable-queues/src/permit_mpsc.rs b/crates/windows-waitable-queues/src/permit_mpsc.rs index fd0bfb36..e0037faf 100644 --- a/crates/windows-waitable-queues/src/permit_mpsc.rs +++ b/crates/windows-waitable-queues/src/permit_mpsc.rs @@ -491,6 +491,17 @@ impl Consumer { pub fn is_empty(&self) -> bool { self.len() == 0 } + + /// How many pushes have been refused for want of room. + /// + /// Readable from this side as well as the producer's, matching the shipping + /// shapes: a measurement harness drops its producers before reading the + /// count, so a producer-only accessor would be unreachable exactly when the + /// number is wanted. + #[must_use] + pub fn refused(&self) -> u64 { + self.shared.metrics.refused() + } } #[cfg(test)] From a5fc191190db638372ad7270d499457ec4f30725 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 11:38:30 -0400 Subject: [PATCH 209/361] docs: re-plan M15 -- SH-15.6 was a false binary, and add SH-15.8 Two corrections forced by what SH-15.5 measured and by the engineer's read of what it means. SH-15.6 was written as "merge, or delete", which presumes one claim protocol dominates. D-35 measured that none does: the permit claim wins from four producers upward and loses at one, with no configuration-free winner. D-29 already settled how this crate answers that shape of question for the two existing shapes -- both ship, the crate publishes what it measured, and the caller decides on its own hardware. Deleting a shape because no visible consumer wants it is what the platform-integrity rule forbids; adopting one because it won most rows is the same error facing the other way. The item now has three outcomes, and notes that a duplicated path becoming permanent BY DECISION is legitimate where becoming permanent by inattention is not. SH-15.8 is new and is a release blocker. The crate is close to its first publish with a known path to silent data loss -- a producer overwriting a live, unconsumed item -- documented nowhere a caller would see. Disclosing it is separable from deciding the fix, so it should not sit behind SH-15.6. D-31 is the precedent and makes this a legitimate outcome rather than a dodge: this crate already ships one known gap disclosed rather than fixed, with its own README and crate-doc sections, on the principle that the disclosure is the decision. But the item also records why the two are not equally forgiving. An unverified ordering is a risk of a bug; this is a known bug with a computed exposure, and its failure mode is silent -- no error, no panic, no counter -- so a caller cannot detect it and therefore cannot mitigate it afterwards. A disclosure only a careful reader finds is not adequate for a fault of that shape. The item pins the four things the disclosure must state so a caller can actually decide: that it is NOT 32-bit-only (POSITION_BITS is 32 by construction on every target, and the "32-bit position" spelling invites exactly the misreading SH-6.1 already had to be corrected for); the quantified exposure; that the wrap alone is not enough without a stalled producer; and the alternatives, since slotwise_mpsc no longer has this hazard and spsc never did. No code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 57 ++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index aaa3d97e..fd9dee99 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -859,12 +859,25 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. `capacity` items from N concurrent producers into an initially empty queue would also settle the narrow question of whether an overdraw can refuse while a slot is provably free. -- [ ] **SH-15.6** -- **Decide: merge, or delete.** The duplicated path exists so the speculative work - could proceed without disturbing a working shape; leaving it to become permanent by inattention is - the failure mode the duplication rule warns about. On the evidence from SH-15.5, either adopt arm A - into `reserving_mpsc` (closing SH-14.1 and SH-14.3) or delete it and take one of SH-14.3's original - options, recording why. Whichever way it goes, SH-14.1's hazard must be either fixed or documented as - an accepted limitation with its exposure stated -- it may not simply stay open. +- [ ] **SH-15.6** -- **Decide: merge, delete, or ship as a third peer.** **RE-PLANNED: this item was + written as a binary and the binary was wrong.** "Merge or delete" presumes one protocol dominates, + and [D-35](crates/windows-waitable-queues/DESIGN-NOTES.md#d-35) measured that none does -- the + permit claim wins from four producers upward and loses at one, with no configuration-free winner. + [D-29](crates/windows-waitable-queues/DESIGN-NOTES.md#d-29) already settled how this crate answers + that question for the two existing shapes: **both ship, the crate publishes what it measured, and + the caller decides on its own hardware.** Deleting a shape because no visible consumer wants it is + what the platform-integrity rule forbids; adopting one because it won most rows would be the same + error facing the other way. + So the live outcomes are three, and the third is now the most likely: adopt arm A into + `reserving_mpsc`; delete it and take one of SH-14.3's original options; or promote it to a named + peer alongside the other two, with the measurement published so a caller can choose. The + duplicated path still may not become permanent *by inattention* -- that is what this item guards -- + but becoming permanent *by decision* is a legitimate outcome rather than a failure of the + duplication rule. + Whichever way it goes, SH-14.1's hazard must be either fixed or documented as an accepted + limitation with its exposure stated -- it may not simply stay open. **That disclosure is no longer + gated on this item**: see SH-15.8, which must land before 0.1.0 publishes regardless of what is + decided here. **Gated on SH-15.5.1**, not on SH-15.5: the throughput question is answered ([D-35](crates/windows-waitable-queues/DESIGN-NOTES.md#d-35) -- 2.7x faster at 16-32 producers, 1.45x slower at one), but adopting a claim that reports backpressure more eagerly would be a @@ -880,6 +893,38 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. right shape. Without this, every arm above is argued rather than demonstrated, and the fix that is adopted has no regression test that would go red if it were reverted. +- [ ] **SH-15.8** -- **Disclose SH-14.1 publicly, and gate 0.1.0 on the disclosure rather than on the + fix.** **RELEASE BLOCKER.** The crate is days from its first publish with a known path to *silent + data loss* -- a producer overwriting a live, unconsumed item -- documented nowhere a caller would + see. That is not acceptable to ship in silence, and it is separable from deciding the fix: the + limitation exists now, whatever SH-15.6 later concludes. + **Precedent, and the reason this is a legitimate outcome rather than a dodge.** + [D-31](crates/windows-waitable-queues/DESIGN-NOTES.md#d-31) already ships one known gap this way -- + "the disclosure, not the deferral, is the decision" -- with its own README section and crate-doc + section stating plainly what is verified and what is not. This follows that shape exactly and sits + beside it. + **But the two are not equally forgiving, and the disclosure must say so.** An unverified memory + ordering is a risk of a bug; this is a *known* bug with a computed exposure. Its failure mode is + silent: no error, no panic, no counter -- an item is overwritten and the consumer receives the + wrong one, so **a caller cannot detect it and therefore cannot mitigate it after the fact.** A + disclosure that only a careful reader finds is not a disclosure for a fault of that shape. + What it must state, in the crate docs, the README, and `reserving_mpsc`'s own module docs: + 1. **It is not a 32-bit-only concern.** `POSITION_BITS` is 32 by construction on every target, so + this reaches x86-64 and ARM64 exactly as it reaches i686. The sibling spelling "32-bit + position" invites precisely the misreading that SH-6.1 already had to be corrected for once, so + the words "on every target" belong in the first sentence. + 2. **The exposure, quantified**: 2^32 pushes, which is 37 s to about 4 minutes of sustained pushing + at this crate's own measured rates -- roughly two minutes at two producers, the smallest count + that can trigger it. Sustained, not cumulative-over-uptime. + 3. **What is required to trigger it**: the wrap *plus* a producer stalled between its room check + and its claim, a window a few instructions wide. Rare, not unreachable, and a preemption is + enough. + 4. **The alternatives a caller has**, which is what makes this a decision they can actually take: + `slotwise_mpsc` does not have this hazard (SH-14.2 widened its positions to 64 bits on every + target); `spsc` never had it; and the queue is safe at any push volume below the wrap. + Sweep for consistency when writing it, per the contract-integrity rule: this fact will end up + stated in at least four places and they must not drift. + ## M-inf: parked, ungated - [ ] **SH-inf.1** -- **The per-cell cycle claim (SCQ's shape), which is the non-blocking one.** The From cf6d7593449f797f856021a082dd367012751c12 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 11:42:45 -0400 Subject: [PATCH 210/361] docs(waitable-queues)!: disclose SH-14.1's item-loss defect before 0.1.0 The crate was days from its first publish with a known path to silent data loss documented nowhere a caller would see. This states it, in the four places a caller could arrive from, and records the decision as D-36. Marked breaking because it changes a stated contract: `reserving_mpsc` was documented as a bounded queue that does not lose items, and it is not one past 2^32 pushes. Nothing about the API changed. Follows D-31's precedent -- this crate already ships one known gap disclosed rather than fixed, on the principle that the disclosure is the decision -- but the text says why the two are not equally forgiving. An unverified memory ordering is a RISK of a bug; this is a known one with a computed exposure, and its failure mode is silent: no error, no panic, no counter, the consumer simply receives a different item than was sent. A caller can neither detect nor mitigate it after the fact, which is exactly why it may not ship in silence. Every statement leads with "on every target, not only 32-bit ones". POSITION_BITS is 32 by construction, so this reaches x86-64 and ARM64 exactly as it reaches i686 -- and the natural spelling "32-bit position" invites the opposite reading, which SH-6.1 already had to be corrected for once. Putting that clause anywhere but the first sentence would reproduce the error the correction was for. The exposure is quantified rather than hedged: 2^32 pushes is 37 seconds to about four minutes of sustained pushing at this crate's measured rates, roughly two minutes at two producers, which is the smallest count that can trigger it. Sustained, not accumulated over an uptime. The wrap alone is not sufficient -- a producer must also stall in a window a few instructions wide -- but a preemption suffices, and "rare" over billions of pushes is not "never". The shape-selection guidance in the crate docs and the README needed fixing independently, and this is the part that would have done real damage: both said "start with reserving_mpsc" with no caveat, pointing callers at the hazardous shape by default. Both now lead with the volume question and name slotwise_mpsc, which has no such hazard since SH-14.2 widened its positions to 64 bits on every target, and both note that needing reservations does not settle the choice on its own. Swept per the contract-integrity rule: 27 matches across 7 files for the exposure figure, the hazard, and the recommendation. All statements agree, including slotwise_mpsc's own Position type comment, which explains the same mechanism as the reason it is u64 and whose "matter of minutes" matches the figures above. Completed item: SH-15.8: Disclose SH-14.1 publicly, and gate 0.1.0 on the disclosure rather than on the fix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 2 +- .../windows-waitable-queues/DESIGN-NOTES.md | 1 + crates/windows-waitable-queues/README.md | 58 ++++++++++++++++++- crates/windows-waitable-queues/src/lib.rs | 57 +++++++++++++++++- .../src/reserving_mpsc.rs | 23 ++++++++ 5 files changed, 138 insertions(+), 3 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index fd9dee99..cb54b883 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -893,7 +893,7 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. right shape. Without this, every arm above is argued rather than demonstrated, and the fix that is adopted has no regression test that would go red if it were reverted. -- [ ] **SH-15.8** -- **Disclose SH-14.1 publicly, and gate 0.1.0 on the disclosure rather than on the +- [x] **SH-15.8** -- **Disclose SH-14.1 publicly, and gate 0.1.0 on the disclosure rather than on the fix.** **RELEASE BLOCKER.** The crate is days from its first publish with a known path to *silent data loss* -- a producer overwriting a live, unconsumed item -- documented nowhere a caller would see. That is not acceptable to ship in silence, and it is separable from deciding the fix: the diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index fbdcc30a..5007c89e 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -59,6 +59,7 @@ preferred. | D-33 | **`PushError` is `#[non_exhaustive]`, and the one-directional doorbell is disclosed rather than fixed before 0.1.0.** The receive-side errors already carried the attribute and the send side lacked it by omission; adding it after publication is itself breaking, so it is taken now while the crate has no external consumers. Whether a producer can *wait* for room stays open as [M32.3](../../CHECKLIST-io-domains.md) -- it is additive, so it does not gate the release -- but the absence is stated in both the crate docs and the README, because `crossbeam-channel`'s `send` blocks and a reader arriving from it will assume this one does too. | | D-34 | **Every bounded queue surveyed is ABA-safe for one of two reasons, and this crate's `reserving_mpsc` has neither.** Either the claim counter is a whole machine word, so recurrence is unreachable -- crossbeam, concurrent-queue, thingbuf, Vyukov, SCQ's `Head`/`Tail` -- or the authorizing compare-exchange is moved onto the cell, so the decision and the write are validated together (CRQ, SCQ). Ours packs the position into a 32-bit *subfield* and authorizes with an exchange that does not cover the separately-read `head`. Nikolaev (DISC 2019, section 3) states the width assumption the field relies on and states it for **CPU-word** width, which a subfield does not satisfy; DPDK's `rte_ring` is the same protocol as ours and its published justification covers modular arithmetic only. The generalisation -- ours, unstated in any source -- is that **the atomic operation authorizing the write must cover everything the decision depended on.** Survey in [DESIGN-SESSION-2026-09-02](design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md); the fix is M15 in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). | | D-35 | **Measured: the permit claim is 2.7x faster than `reserving_mpsc` at 16-32 producers, and 1.45x slower at one.** The safer claim is also the faster one everywhere contention exists, which was not the expected result -- it touches *two* shared lines where the shipping shape touches one plus a read, and [D-26](#d-26) had established that the shared line is what collapses. The mechanism is that both of its operations are unconditional read-modify-writes that never retry, where the shipping shape's compare-exchange retries once per lost race; the retries dominate long before the second line does. It is the only shape measured that gets *faster* per push as producers are added (42.8 ns at two to 19.5 at thirty-two) and the only one that stays within 1.5x of a bare contended `fetch_add`. **This decides the shape of the fix but not the fix**: the drained regime's refusal counts differ by orders of magnitude in a way this harness cannot attribute, which SH-15.5.1 exists to settle before SH-15.6 adopts anything. | +| D-36 | **0.1.0 ships [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) disclosed rather than fixed, and the disclosure is a release blocker.** Following [D-31](#d-31)'s principle -- the disclosure, not the deferral, is the decision -- because the fix is a claim-protocol replacement ([D-35](#d-35)) whose adoption is still gated on an open question, and holding the release for it would trade a *documented* hazard for an undocumented rush. **The two gaps are not equally forgiving and the text says so**: an unverified ordering is a risk of a bug, this is a known one with a computed exposure, and its failure mode is silent -- no error, panic, or counter -- so a caller can neither detect nor mitigate it. That is precisely why it may not ship in silence. Stated in the crate docs, the README, and the shape's own module docs, each leading with **"on every target, not only 32-bit ones"**, because the natural spelling "32-bit position" invites the opposite reading and SH-6.1 already had to be corrected for exactly that. The shape-selection guidance in both documents was also amended: it previously said "start with `reserving_mpsc`" with no caveat, pointing callers at the hazardous shape by default. | ## D-2: capabilities are sliced, not gathered diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 1ee611f3..f7f22a21 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -67,6 +67,55 @@ says so rather than the documentation: `spsc` accepts one slot, and `slotwise_mp two, because its per-slot sequence cannot distinguish "just published" from "free again next lap" in a one-slot ring. +## A known defect in `reserving_mpsc`, disclosed rather than fixed + +**`reserving_mpsc` can lose an item after 2^32 pushes, on every target -- not +only 32-bit ones.** Its claim position is a 32-bit half of a packed word by +construction, so this reaches x86-64 and ARM64 exactly as it reaches i686. Read +that sentence before the paragraph below, because the phrase "32-bit position" +invites the opposite reading and this project has already had to correct that +misreading once. + +**What happens.** A producer checks that there is room, is descheduled, and +resumes after other producers have driven the position field through a complete +wrap. Its claim then succeeds against a value that is numerically identical but +a whole generation later, and it writes into a slot whose emptiness was decided +long ago. If that slot now holds an item the consumer has not taken, the item is +overwritten. + +**The failure is silent.** No error, no panic, no counter moves. The consumer +receives a different item than the one that was sent, and nothing observable +says so -- which is why this is documented here rather than left to a caller to +discover, and why it cannot be mitigated after the fact. + +**The exposure, measured rather than estimated.** 2^32 pushes is 37 seconds to +roughly four minutes of *sustained* pushing at this crate's own measured rates +-- about two minutes at two producers, which is the smallest count that can +trigger it at all. That is sustained throughput, not a total accumulated over an +uptime. Reaching the wrap is necessary but not sufficient: a producer must also +be stalled inside a window a few instructions wide. Rare, but a preemption is +enough, and "rare" over billions of pushes is not "never". + +**What to do about it.** The choice is a real one, which is why the crate states +the facts instead of quietly picking: + +- **`slotwise_mpsc` does not have this hazard.** Its positions are 64 bits on + every target, so the equivalent wrap needs 2^64 claims and cannot be reached. + Prefer it unless you need `Reserving`. +- **`spsc` never had it**, having no contended claim to race. +- **`reserving_mpsc` is sound below the wrap.** A queue that will not push 4.3 + billion items in one run, or that is not driven at sustained maximum rate by + two or more producers, is not exposed. +- If you need reservations *and* those volumes, say so -- the fix is prototyped + and measured, and it is the shipping decision that is open, not the + engineering. + +This is disclosed on the same principle as the ordering gap below: an adopter +gets the information we have rather than an assurance we cannot support. The two +are not equally forgiving, though, and the difference is worth stating plainly +-- an unverified ordering is a *risk* of a bug, while this is a known one with a +computed exposure. + ## How far the memory orderings are verified, and how far they are not Stated plainly, because a lock-free queue that is vague about this is asking to @@ -189,7 +238,14 @@ both rather than picking one for you. **Start here:** -- Need `reserve`? Only `reserving_mpsc` has it, and `slotwise_mpsc` structurally cannot. +- **Pushing more than ~4 billion items in one run, from two or more producers?** + Use `slotwise_mpsc`. `reserving_mpsc` has a known item-loss defect past that + volume, on every target -- see [A known defect in + `reserving_mpsc`](#a-known-defect-in-reserving_mpsc-disclosed-rather-than-fixed) + above, which you should read before choosing. +- Need `reserve`? Only `reserving_mpsc` has it, and `slotwise_mpsc` structurally + cannot. Weigh that against the defect above rather than treating the + capability as settling the choice. - Otherwise, **start with `reserving_mpsc`.** It was the faster of the two at every producer count we measured above one. - Only one producer *and* one consumer? Use `spsc`, which beats both. diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 00031753..3a1a2ceb 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -75,6 +75,56 @@ //! here has a single consumer, two of them have many *producers*, so a //! "there is room" signal has N waiters and is not the doorbell mirrored. //! +//! # A known defect in `reserving_mpsc`, disclosed rather than fixed +//! +//! **[`reserving_mpsc`] can lose an item after 2^32 pushes, on every target -- +//! not only 32-bit ones.** Its claim position is a 32-bit half of a packed word +//! by construction, so this reaches x86-64 and ARM64 exactly as it reaches +//! i686. Read that sentence before the paragraph below, because the phrase +//! "32-bit position" invites the opposite reading and this project has already +//! had to correct that misreading once. +//! +//! **What happens.** A producer checks that there is room, is descheduled, and +//! resumes after other producers have driven the position field through a +//! complete wrap. Its claim then succeeds against a value that is numerically +//! identical but a whole generation later, and it writes into a slot whose +//! emptiness was decided long ago. If that slot now holds an item the consumer +//! has not taken, the item is overwritten. +//! +//! **The failure is silent.** No error, no panic, no counter moves. The +//! consumer receives a different item than the one that was sent, and nothing +//! observable says so -- which is why this is documented here rather than left +//! to a caller to discover, and why it cannot be mitigated after the fact. +//! +//! **The exposure, measured rather than estimated.** 2^32 pushes is 37 seconds +//! to roughly four minutes of *sustained* pushing at this crate's own measured +//! rates -- about two minutes at two producers, which is the smallest count +//! that can trigger it at all. That is sustained throughput, not a total +//! accumulated over an uptime. Reaching the wrap is necessary but not +//! sufficient: a producer must also be stalled inside a window a few +//! instructions wide. Rare, but a preemption is enough, and "rare" over +//! billions of pushes is not "never". +//! +//! **What to do about it.** The choice is a real one, which is why the crate +//! states the facts instead of quietly picking: +//! +//! - **[`slotwise_mpsc`] does not have this hazard.** Its positions are 64 bits +//! on every target, so the equivalent wrap needs 2^64 claims and cannot be +//! reached. Prefer it unless you need [`Reserving`]. +//! - **[`spsc`] never had it**, having no contended claim to race. +//! - **[`reserving_mpsc`] is sound below the wrap.** A queue that will not push +//! 4.3 billion items in one run, or that is not driven at sustained maximum +//! rate by two or more producers, is not exposed. +//! - If you need reservations *and* those volumes, say so -- the fix is +//! prototyped and measured, and it is the shipping decision that is open, not +//! the engineering. +//! +//! This is disclosed on the same principle as the ordering gap below: an +//! adopter gets the information we have rather than an assurance we cannot +//! support. The two are not equally forgiving, though, and the difference is +//! worth stating plainly -- an unverified ordering is a *risk* of a bug, while +//! this is a known one with a computed exposure. +//! //! # How far the memory orderings are verified, and how far they are not //! //! Stated plainly because a lock-free queue that is vague about this is asking @@ -173,8 +223,13 @@ //! answered at all. Both are well-studied designs in production use elsewhere, //! which is why this crate ships both instead of picking one for you. //! +//! - **Pushing more than ~4 billion items in one run, from two or more +//! producers?** Use [`slotwise_mpsc`]. [`reserving_mpsc`] has a known +//! item-loss defect past that volume, on every target -- see the section +//! above, which you should read before choosing. //! - Need [`Reserving`]? Only [`reserving_mpsc`] has it; [`slotwise_mpsc`] structurally -//! cannot. +//! cannot. Weigh that against the defect above rather than treating the +//! capability as settling the choice. //! - Otherwise **start with [`reserving_mpsc`]**: it was the faster of the two //! at every producer count above one that we measured. //! - One producer *and* one consumer? Use [`spsc`], which beats both. diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index b05397d7..d1bc6fdc 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -6,6 +6,29 @@ //! claimed in advance, so that a later delivery cannot be refused for want of //! room. *Reserved is guaranteed, unreserved is best-effort.* //! +//! # Known defect: this shape can lose an item after 2^32 pushes +//! +//! **On every target, not only 32-bit ones** -- the claim position is a 32-bit +//! half of the packed word below by construction, so this reaches x86-64 and +//! ARM64 exactly as it reaches i686. +//! +//! A producer that has checked for room, been descheduled, and resumed after +//! other producers drove the position through a full wrap will claim +//! successfully against a numerically identical but generations-later value, +//! and write into a slot whose emptiness was decided long ago. **The failure is +//! silent**: the consumer receives a different item than was sent, and no error, +//! panic, or counter reports it. +//! +//! 2^32 pushes is 37 seconds to about four minutes of *sustained* pushing at +//! this crate's measured rates, roughly two minutes at two producers. The wrap +//! alone is not enough -- a producer must also stall inside a window a few +//! instructions wide -- but a preemption suffices. +//! +//! [`slotwise_mpsc`](crate::slotwise_mpsc) does not have this hazard, its +//! positions being 64 bits on every target; [`spsc`](crate::spsc) never had it. +//! Below the wrap this shape is sound. The full statement, and what to do about +//! it, is in the [crate documentation](crate). +//! //! # Why this is a separate shape rather than a method on `slotwise_mpsc` //! //! Because the two ask different questions to claim a slot, and only this one's From 817dae963eca6518c908b7c2e116c04864ad95e8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 12:17:28 -0400 Subject: [PATCH 211/361] docs(waitable-queues): ship the claim word in two widths (D-37, supersedes D-18) The engineer's decision, and it follows D-29: publish what we measured and let the caller choose rather than picking one tradeoff for everyone. `reserving_mpsc` is unchanged and always ships, on every target, with D-36's warnings. It is explicitly never silently swapped for a wide variant on targets that could host one -- a shape whose contract changes with the target is what PLATFORM INTEGRITY rule 2 forbids, and a caller who read "2^32" in the docs must get 2^32. `reserving_mpsc_wide` is queued as SH-15.9: the same protocol with a u128 word split 64/64, so recurrence needs 2^64 pushes and the capacity ceiling rises to 2^31 -> 2^62, which was D-18's original point. This supersedes D-18, marked as such adjacent to its title per the supersedence rule, and D-18 is retained because its cost analysis is what D-37 rests on. What changed is not the analysis but who pays: the costs land on a separate shape instead of being imposed on the shipping one. D-18 also needed a correction that the new gate is built around, and it is the substantive finding here. D-18's amendment said i686 "has no 128-bit atomic at all", which reads as "it will not compile". It compiles. portable-atomic's default `fallback` feature silently substitutes a GLOBAL LOCK, so the queue keeps working, stops being lock-free, and contends with any unrelated user of the fallback in the same process. Verified separately: `rustc 1.98.0 --print cfg -C target-feature=-cmpxchg16b` still emits `target_has_atomic="128"`, so a cfg gate alone does not catch it either. That is the same silent-degradation shape as SH-14.1 itself, so the gate is two conditions rather than one: the cfg, and a const assertion on portable-atomic's `is_always_lock_free()` (verified to be a pub const fn). The build fails rather than quietly taking a lock. This is the standard SH-14.2 already set when it probed i686 to confirm AtomicU64 was lock-free before widening slotwise_mpsc, recording that a hidden mutex "would have made this a bad trade" -- AtomicU128 on i686 is exactly that hidden mutex. D-7 puts the burden of proof on adding a Cargo feature, and SH-15.9 discharges it rather than waiving it: D-7 rejected feature-gating because the only benefit was compile time, which dead-code elimination already provides, and the cost here is a third-party dependency on a crate whose only current one is windows-sys. Dead-code elimination removes nothing from Cargo.lock or from a downstream auditor's review. SH-15.6 gains the comparison that must not be settled by whichever variant is finished first: on evidence so far the permit claim dominates the wide claim on every axis except maturity -- all targets rather than 64-bit only, 2.7x faster, no dependency, and its 2^31 ceiling is not intrinsic since its ticket could widen to u64 exactly as slotwise_mpsc's did. What the wide claim has is low risk, being a width change to a shape that has survived nine review rounds with unchanged behaviour. Conservative fix versus better fix is an argument for shipping both, not for choosing. Swept `128-bit compare-and-swap|deliberately not used|d-18`: 15 matches in 9 files, 3 stale and updated (D-18's own index row, which contradicted itself after the supersedence prefix; reserving_mpsc's module docs; and CHECKLIST-io-domains, which had already restated a superseded version once). Other crates' D-18 and the Tier 3 session transcript are untouched -- the latter deliberately, since a dated session record is not rewritten to match later decisions. No code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 16 ++--- CHECKLIST-ship-topology-and-queues.md | 65 +++++++++++++++++++ .../windows-waitable-queues/DESIGN-NOTES.md | 18 ++++- .../src/reserving_mpsc.rs | 20 ++++-- 4 files changed, 102 insertions(+), 17 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index c446e095..7c6b9a2f 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -261,15 +261,13 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m Two consequences worth noting: redeeming is a single exchange that moves both halves, so `occupied + reserved` is never momentarily wrong; and the producer stops needing the slot sequence for the "free" direction, so this shape's `pop` is one store *shorter* than `slotwise_mpsc`'s. - **A 128-bit compare-and-swap was raised and refused** - ([D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18)), and **that decision's reasoning was - amended on 2026-09-02 while its outcome stood** -- read it there rather than from this paragraph, - which previously restated the superseded version. In short: it does not merely lift the cap (a - 64-bit position would also close SH-14.1's ABA hole, unknown when D-18 was written), it *is* in the - x86-64 baseline on this workspace's Windows target, and the reason it is refused is that - `i686-pc-windows-msvc` has no 128-bit atomic at all -- so adopting it would mean dropping 32-bit - support. Revived by a tagged pointer, which is what M-inf.1's linked and sharded shapes would need, - or by dropping 32-bit for unrelated reasons. + **A 128-bit compare-and-swap was raised, refused, and then adopted for a separate shape.** D-18 + refused it; **[D-37](crates/windows-waitable-queues/DESIGN-NOTES.md#d-37) supersedes that** -- read + D-37 rather than this paragraph, which has already restated a superseded version once. In short: + widening *this* shape's word would make its contract depend on the target, because + `i686-pc-windows-msvc` has no lock-free 128-bit exchange and the fallback is a silent global lock. + So the narrow shape keeps its 64-bit word on every target, and a wide claim ships as its own peer + where the exchange is genuinely lock-free. **`spsc` reserves too, nearly free**, since one producer means `reserve` and `push` are the same thread. Its reservation *borrows* the producer where `reserving_mpsc`'s is owned and `Send`, because there the handle **is** the single-producer guarantee and an owned reservation could outlive it on diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index cb54b883..62ac9062 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -878,6 +878,19 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. limitation with its exposure stated -- it may not simply stay open. **That disclosure is no longer gated on this item**: see SH-15.8, which must land before 0.1.0 publishes regardless of what is decided here. + **One comparison to make explicitly rather than leave implied, now that SH-15.9 adds a third + answer to SH-14.1.** On the evidence so far the permit claim dominates the wide claim on every + axis except maturity: it fixes the hazard on *all* targets where the wide claim covers only 64-bit + ones, it is 2.7x faster at 16-32 producers where the wide claim keeps the retry loop that costs, + it needs no dependency, and its 2^31 ceiling is not intrinsic -- its position carries no decision, + so it could take a `u64` ticket exactly as `slotwise_mpsc` did in SH-14.2 and reach 2^62 on every + target too. + What the wide claim has instead is **risk**: it is a width change to a shape that has been through + nine review rounds and whose behaviour is unchanged, where the permit claim is a new protocol with + 23 tests, no trait impls, no verification, and an open question about whether it reports + backpressure more eagerly (SH-15.5.1). Those are genuinely different products -- conservative fix + versus better fix -- which is the argument for shipping both rather than the argument for choosing. + Do not let this comparison be settled by whichever is finished first. **Gated on SH-15.5.1**, not on SH-15.5: the throughput question is answered ([D-35](crates/windows-waitable-queues/DESIGN-NOTES.md#d-35) -- 2.7x faster at 16-32 producers, 1.45x slower at one), but adopting a claim that reports backpressure more eagerly would be a @@ -925,6 +938,58 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. Sweep for consistency when writing it, per the contract-integrity rule: this fact will end up stated in at least four places and they must not drift. +- [ ] **SH-15.9** -- **Ship the claim word in two widths: the narrow one everywhere, the wide one where + the hardware allows.** The engineer's decision, and it follows + [D-29](crates/windows-waitable-queues/DESIGN-NOTES.md#d-29): publish what we measured and let the + caller choose, rather than picking one tradeoff for everyone. + - **`reserving_mpsc` is unchanged and always ships**, on every target, with SH-15.8's warnings. It + is never silently swapped for the wide one on targets that could support it: a shape whose + contract changes with the target is exactly what + [PLATFORM INTEGRITY](../.github/copilot-instructions.md) rule 2 forbids, and a caller reading + "2^32" in the docs must get 2^32. + - **`reserving_mpsc_wide` is new**: the same claim protocol with a `u128` word split 64/64. The + position then needs 2^64 pushes to recur -- about 16,000 years at this crate's measured rates -- + so SH-14.1 is unreachable rather than merely unlikely. The capacity ceiling rises from 2^31 to + 2^62, which was [D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18)'s original point. + + **The trap this item exists to avoid, verified rather than assumed.** `rustc 1.98.0 --print cfg + --target x86_64-pc-windows-msvc -C target-feature=-cmpxchg16b` **still emits + `target_has_atomic="128"`**. The cfg tracks the target's maximum atomic width, not whether the + instruction is enabled -- so `#[cfg(target_has_atomic = "128")]` alone does **not** guarantee a + lock-free 128-bit exchange. Where the instruction is absent, `portable-atomic`'s default `fallback` + feature silently substitutes **a global lock**: the queue still compiles, still runs, and stops + being lock-free, contending with any unrelated user of the fallback in the same process. That is + the same silent-degradation shape as SH-14.1 itself and must not be shipped. + So the gate is **two** conditions, not one: the cfg, **and** a const assertion that + `AtomicU128::is_always_lock_free()` (a `pub const fn` in `portable-atomic`, verified). The build + fails rather than quietly taking a lock. This is the same standard SH-14.2 already applied when it + widened `slotwise_mpsc` -- it probed i686 specifically to confirm `AtomicU64` was lock-free there, + recording that a hidden mutex "would have made this a bad trade". `AtomicU128` on i686 *is* that + hidden mutex, which is why the wide shape must not exist there at all. + + **[D-7](crates/windows-waitable-queues/DESIGN-NOTES.md#d-7) puts the burden of proof on adding a + feature, and it is met here rather than waived.** D-7 rejected feature-gating shapes because the + only benefit was compile time, which dead-code elimination already provides. That reasoning does + not reach this case: the wide shape's cost is a **new third-party dependency** on a crate whose + only current one is `windows-sys`, and dead-code elimination removes nothing from `Cargo.lock`, + from a downstream auditor's review, or from a `cargo vet` run. A caller who does not want the + dependency must be able to not have it. Record the discharge as a decision rather than leaving it + to look like D-7 was ignored. + Verify at implementation time whether Cargo's `[target.'cfg(...)'.dependencies]` accepts + `target_has_atomic`; if it does not, the dependency is optional-by-feature and the module carries + the cfg separately. + +- [ ] **SH-15.10** -- **Measure the wide claim beside the narrow one, and publish the difference.** + Same harness, same host, same run as SH-15.5. The question is narrow and worth an answer either + way: does a 128-bit exchange cost anything measurable against a 64-bit one on this hardware? If it + does not, the wide shape is strictly better wherever it builds, and the guidance should say so. If + it does, that number is what a caller needs to choose between reach and speed. + Note the expected shape of the result, so a surprise is recognisable: the wide claim keeps the + compare-exchange **retry loop**, which [D-35](crates/windows-waitable-queues/DESIGN-NOTES.md#d-35) + identified as what actually costs at high producer counts -- so it should track `reserving_mpsc` + closely and should **not** approach the permit claim's numbers. A wide claim that measured as fast + as the permit claim would mean D-35's explanation is wrong. + ## M-inf: parked, ungated - [ ] **SH-inf.1** -- **The per-cell cycle claim (SCQ's shape), which is the non-blocking one.** The diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 5007c89e..232ed5de 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -41,7 +41,7 @@ preferred. | D-15 | **`Doorbell::clear` resets the event *before* clearing the flag that mirrors it, and the original order was a lost wakeup.** A producer signalling between the two lines set the flag and issued a real `SetEvent`; the `ResetEvent` that followed erased the signal and left the flag set, wedging the doorbell dark while it claimed to be lit. **Amends [D-9](#d-9)**, whose "there is no third case" holds only for a queue whose emptiness is one position comparison. | | D-16 | **Its cost premise is falsified by [D-26](#d-26); the conclusion stands on capability instead -- see [D-29](#d-29).** Reservation is a capability a shape may lack, so the reserving multi-producer queue ships as a peer of `slotwise_mpsc` rather than replacing it. Honouring a reservation requires counting free slots, which requires the consumer's position -- a single shared line `slotwise_mpsc`'s push deliberately never reads. The original rationale added that this made reserving the *more expensive* shape and that both should ship rather than charge every caller for it; measurement reversed that, and the split is now justified by the capability alone. **Amends [D-6](#d-6)**, which assumed one queue would carry every policy. | | D-17 | **The reservation count and the claim position live in one word, because a check-and-claim over both must be a single atomic operation.** Two atomics cannot be made correct with any amount of fencing: the pushing producer is load-then-store and the reserving one store-then-load, so the Dekker argument does not apply and both can miss each other. The 32/32 split is forced by the arithmetic, and caps this shape at 2^31 items. | -| D-18 | **A 128-bit compare-and-swap is refused -- outcome unchanged, reasoning replaced.** **Amended: three of the four reasons originally given were wrong or incomplete, and the decisive one was missing.** It would *not* lift the cap "and nothing else": a 64-bit position also collapses [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md)'s ABA recurrence, which was unknown when this was written. It is *not* outside the x86-64 baseline -- `rustc 1.98.0` emits `target_feature="cmpxchg16b"` for `x86_64-pc-windows-msvc`, so there is no floor to raise and no runtime detection to pay. What stands is the dependency (`AtomicU128` is still unstable, rust-lang/rust#99069) and, decisively, that **`i686-pc-windows-msvc` has no 128-bit atomic at all**: adopting this is not "widen the word" but "widen the word *and* drop 32-bit support". Revisit for a tagged pointer, or if 32-bit support is dropped for other reasons -- not before. | +| D-18 | **Superseded by [D-37](#d-37), which adopts a 128-bit compare-and-swap for a separate wide shape.** Retained because its analysis of the *costs* is still correct and D-37 depends on it; what changed is that those costs are now paid by a **separate shape** rather than imposed on this one. **Originally: a 128-bit compare-and-swap is refused.** **Amended once before being superseded, because three of the four reasons originally given were wrong or incomplete, and the decisive one was missing.** It would *not* lift the cap "and nothing else": a 64-bit position also collapses [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md)'s ABA recurrence, which was unknown when this was written. It is *not* outside the x86-64 baseline -- `rustc 1.98.0` emits `target_feature="cmpxchg16b"` for `x86_64-pc-windows-msvc`, so there is no floor to raise and no runtime detection to pay. What stands is the dependency (`AtomicU128` is still unstable, rust-lang/rust#99069) and, decisively, that **`i686-pc-windows-msvc` has no 128-bit atomic at all**: adopting this is not "widen the word" but "widen the word *and* drop 32-bit support". Revisit for a tagged pointer, or if 32-bit support is dropped for other reasons -- not before. | | D-19 | **The coalesced loss latch is deliberately not generalised from the file watcher.** Coalescing there is sound because a desync is *idempotent* -- two mean the same as one, and the answer to both is a re-scan. A queue of arbitrary `T` has no such property, so what generalises is a loss *count*, which is [M31.4](../../CHECKLIST-io-domains.md)'s observability rather than a policy. | | D-20 | **Undrained items are handed to a caller-supplied sink at teardown, and the sink is chosen at construction because `Drop` has nowhere to hand them back to.** Without one they are destroyed on whichever thread released the last handle -- which may be a pool callback that must not block, and closing a handle to a dead network path can block for a long time. The default is unchanged; what changes is that it is now a named choice. | | D-21 | **A panicking disposal sink is caught and the teardown walk continues.** The sink is caller code inside a destructor: a panic escaping it abandons every item behind it -- the exact handles the mechanism exists to account for -- and during an unwind aborts the process. Catching declines to turn a caller's bug into a much larger one. | @@ -60,6 +60,7 @@ preferred. | D-34 | **Every bounded queue surveyed is ABA-safe for one of two reasons, and this crate's `reserving_mpsc` has neither.** Either the claim counter is a whole machine word, so recurrence is unreachable -- crossbeam, concurrent-queue, thingbuf, Vyukov, SCQ's `Head`/`Tail` -- or the authorizing compare-exchange is moved onto the cell, so the decision and the write are validated together (CRQ, SCQ). Ours packs the position into a 32-bit *subfield* and authorizes with an exchange that does not cover the separately-read `head`. Nikolaev (DISC 2019, section 3) states the width assumption the field relies on and states it for **CPU-word** width, which a subfield does not satisfy; DPDK's `rte_ring` is the same protocol as ours and its published justification covers modular arithmetic only. The generalisation -- ours, unstated in any source -- is that **the atomic operation authorizing the write must cover everything the decision depended on.** Survey in [DESIGN-SESSION-2026-09-02](design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md); the fix is M15 in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). | | D-35 | **Measured: the permit claim is 2.7x faster than `reserving_mpsc` at 16-32 producers, and 1.45x slower at one.** The safer claim is also the faster one everywhere contention exists, which was not the expected result -- it touches *two* shared lines where the shipping shape touches one plus a read, and [D-26](#d-26) had established that the shared line is what collapses. The mechanism is that both of its operations are unconditional read-modify-writes that never retry, where the shipping shape's compare-exchange retries once per lost race; the retries dominate long before the second line does. It is the only shape measured that gets *faster* per push as producers are added (42.8 ns at two to 19.5 at thirty-two) and the only one that stays within 1.5x of a bare contended `fetch_add`. **This decides the shape of the fix but not the fix**: the drained regime's refusal counts differ by orders of magnitude in a way this harness cannot attribute, which SH-15.5.1 exists to settle before SH-15.6 adopts anything. | | D-36 | **0.1.0 ships [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) disclosed rather than fixed, and the disclosure is a release blocker.** Following [D-31](#d-31)'s principle -- the disclosure, not the deferral, is the decision -- because the fix is a claim-protocol replacement ([D-35](#d-35)) whose adoption is still gated on an open question, and holding the release for it would trade a *documented* hazard for an undocumented rush. **The two gaps are not equally forgiving and the text says so**: an unverified ordering is a risk of a bug, this is a known one with a computed exposure, and its failure mode is silent -- no error, panic, or counter -- so a caller can neither detect nor mitigate it. That is precisely why it may not ship in silence. Stated in the crate docs, the README, and the shape's own module docs, each leading with **"on every target, not only 32-bit ones"**, because the natural spelling "32-bit position" invites the opposite reading and SH-6.1 already had to be corrected for exactly that. The shape-selection guidance in both documents was also amended: it previously said "start with `reserving_mpsc`" with no caveat, pointing callers at the hazardous shape by default. | +| D-37 | **The reserving claim word ships in two widths: the narrow one on every target, the wide one only where a 128-bit exchange is genuinely lock-free.** `reserving_mpsc` keeps its packed 64-bit word, keeps [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md)'s hazard, keeps [D-36](#d-36)'s warnings, and is **never silently swapped** for the wide shape on targets that could host one -- a contract that changes with the target is what PLATFORM INTEGRITY rule 2 forbids, and a caller who read "2^32" must get 2^32. `reserving_mpsc_wide` is the same protocol with a `u128` word split 64/64: recurrence needs 2^64 pushes, and the capacity ceiling rises to 2^62. **The gate is two conditions, not one, and the second is the load-bearing one**: `target_has_atomic="128"` is emitted even with `cmpxchg16b` disabled (verified on 1.98), and `portable-atomic`'s default fallback then substitutes a **global lock** silently -- so a `const` assertion on `is_always_lock_free()` fails the build rather than shipping a queue that quietly stops being lock-free. That is the standard [SH-14.2](../../CHECKLIST-ship-topology-and-queues.md) already set when it probed i686 to confirm `AtomicU64` was lock-free before widening `slotwise_mpsc`, recording that a hidden mutex "would have made this a bad trade". [D-7](#d-7)'s burden of proof for adding a Cargo feature is **discharged, not waived**: D-7 rejected feature-gating because the only benefit was compile time, and the cost here is a third-party dependency, which dead-code elimination does not remove from `Cargo.lock` or from an auditor's review. | ## D-2: capabilities are sliced, not gathered @@ -573,6 +574,21 @@ constraint that binds: the count's half must be wide enough to hold the whole ca ## D-18: a 128-bit compare-and-swap is refused +**Superseded by [D-37](#d-37).** A 128-bit exchange is now adopted, but for a **separate wide +shape** rather than for this one: `reserving_mpsc` keeps its packed 64-bit word on every target, and +`reserving_mpsc_wide` is a peer beside it. Read this decision for the cost analysis, which D-37 +depends on and does not repeat -- and note one correction it needs, below, that D-37's gate is built +around. + +**The i686 failure is not a build failure, which is worse.** The amendment below says i686 "has no +128-bit atomic at all", which reads as "it will not compile". It compiles: `portable-atomic`'s +default `fallback` feature silently substitutes a **global lock**, so the queue keeps working and +stops being lock-free, contending with any unrelated user of the fallback in the same process. And +`target_has_atomic="128"` is emitted even with `cmpxchg16b` disabled -- verified on 1.98 -- so a cfg +gate alone does not catch it either. That silent degradation, not a compile error, is the real reason +this shape may not simply widen its word, and D-37's `const` assertion on `is_always_lock_free()` +exists to turn it back into a build failure. + **Amended 2026-09-02. The refusal stands; almost none of its original reasoning does.** The first version of this decision was written before [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) existed, and it asserted target facts that were never checked against the toolchain. Both faults are diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index d1bc6fdc..85d7fa51 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -102,14 +102,20 @@ //! `2^(b-1)`, and the count needs `b` bits because it can reach the capacity, so //! `b + b = 64` gives `b = 32`. There is no cleverer division of the word. //! -//! **A 128-bit compare-and-swap is deliberately not used** -//! ([D-18](../DESIGN-NOTES.md#d-18)). It would not remove the cost that +//! **A 128-bit compare-and-swap is deliberately not used *here*** +//! ([D-37](../DESIGN-NOTES.md#d-37)). It would not remove the cost that //! matters -- the consumer's position still has to be read -- and 2^31 slots is -//! a ring this shape allocates in full at construction. The operative reason, -//! though, is that `i686-pc-windows-msvc` has no 128-bit atomic at all, so -//! adopting one would mean dropping 32-bit support rather than merely widening -//! a word. See the decision for the full reasoning, which was amended in -//! 2026-09-02 after three of its original four reasons turned out not to hold. +//! a ring this shape allocates in full at construction. +//! +//! The operative reason is that widening *this* shape's word would change what +//! it offers depending on the target: `i686-pc-windows-msvc` has no lock-free +//! 128-bit exchange, so the same module would be lock-free on one target and +//! silently mutex-backed on another. A wider claim ships instead as its own +//! shape (`reserving_mpsc_wide`, not yet built -- see D-37), to exist only +//! where the exchange is genuinely lock-free. That keeps *this* module's +//! contract the same on every target, which is the property being protected +//! here: a caller who wants 2^62 slots and no wrap hazard will ask for it by +//! name rather than get it by accident of where they compiled. use core::cell::{Cell, UnsafeCell}; use core::fmt; From d33d6904c38cc679db8e8b7a561775d9d74cde29 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 12:31:48 -0400 Subject: [PATCH 212/361] feat(waitable-queues): widen the permit ticket to u64, and simplify D-37's gate Two changes, both from the engineer's direction. The permit shape's ticket and slot sequences become a named `Position = u64` on every target, matching what SH-14.2 did to slotwise_mpsc. The point is to make the fetch_add dumb: the ticket carries no decision -- admission was already settled by the permit -- so a wrap would have been harmless anyway, but at 64 bits it cannot happen, and neither property has to be argued at a call site again. The capacity ceiling rises from 2^31 to the crate-wide bound as a side effect; the old 2^31 existed only to match reserving_mpsc's range for measurement, and matching an artificial limit would now misreport what the shape can do. Two const assertions came out of this, and the second is the interesting one. Widening BOUNDS_MAX immediately tripped the existing permit-count assertion, which reserved half of i64 for the transient overdraft. That was wrong: the overdraft goes NEGATIVE, so it consumes the range below zero (a full 2^63 against a bound of one per in-flight thread) and cannot meet the positive bound from below. Corrected to the accurate relation. The new ticket-width assertion was tautological on first writing -- comparing a usize capacity against Position::MAX can never fail -- which is exactly the trap reserving_mpsc's own const block records having fallen into once. Restated against half the ticket's range, which is the bound `len`'s wrapping subtraction actually needs, and verified load-bearing by sabotage: narrowing Position to u32 fails the build on that assertion by name. Second change: D-37's gate for the wide shape is now one line of Cargo.toml, and the earlier design was overbuilt because I had not measured. Probing portable-atomic 1.15 on the pinned toolchain: - default-features = false: AtomicU128 does not exist on i686 (E0432, "no AtomicU128 in the root"), nor on x86_64 built with -C target-feature=-cmpxchg16b. It exists exactly where a native lock-free 128-bit exchange is guaranteed at compile time. The use statement IS the gate, and it fails loudly naming the missing type. - default features: compiles on i686 and silently substitutes a global lock. That, not anything intrinsic to a 128-bit exchange, is the entire source of the silent-degradation hazard -- so it is opted out of, not guarded against. - cfg(target_has_atomic = "128") is the wrong gate regardless: rustc still emits it under -C target-feature=-cmpxchg16b. - is_always_lock_free() IS const-evaluable (verified), but an assertion on it is worse than useless here: redundant where the type exists, unreachable where it does not. So: no cfg, no const assertion, just default-features = false. The consequence is recorded rather than buried -- reserving_mpsc_wide cannot be built for i686 at all, a portability cliff chosen over a performance cliff because a compile error naming AtomicU128 is more informative than a queue that quietly stops being lock-free. Also updates SH-15.6's comparison: the wide claim's last unique advantage over the permit claim was its capacity ceiling, and the widening removes it. 308 tests pass; the crate also checks clean for i686-pc-windows-msvc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 47 +++++---- .../windows-waitable-queues/DESIGN-NOTES.md | 10 +- .../src/permit_mpsc.rs | 98 +++++++++++++------ 3 files changed, 100 insertions(+), 55 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 62ac9062..69db2c1f 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -882,9 +882,9 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. answer to SH-14.1.** On the evidence so far the permit claim dominates the wide claim on every axis except maturity: it fixes the hazard on *all* targets where the wide claim covers only 64-bit ones, it is 2.7x faster at 16-32 producers where the wide claim keeps the retry loop that costs, - it needs no dependency, and its 2^31 ceiling is not intrinsic -- its position carries no decision, - so it could take a `u64` ticket exactly as `slotwise_mpsc` did in SH-14.2 and reach 2^62 on every - target too. + it needs no dependency, and **it now reaches the same 2^62 ceiling on every target**: its ticket + was widened to `u64` (as `slotwise_mpsc`'s was in SH-14.2), which was the wide claim's last + remaining unique advantage. What the wide claim has instead is **risk**: it is a width change to a shape that has been through nine review rounds and whose behaviour is unchanged, where the permit claim is a new protocol with 23 tests, no trait impls, no verification, and an open question about whether it reports @@ -952,20 +952,30 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. so SH-14.1 is unreachable rather than merely unlikely. The capacity ceiling rises from 2^31 to 2^62, which was [D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18)'s original point. - **The trap this item exists to avoid, verified rather than assumed.** `rustc 1.98.0 --print cfg - --target x86_64-pc-windows-msvc -C target-feature=-cmpxchg16b` **still emits - `target_has_atomic="128"`**. The cfg tracks the target's maximum atomic width, not whether the - instruction is enabled -- so `#[cfg(target_has_atomic = "128")]` alone does **not** guarantee a - lock-free 128-bit exchange. Where the instruction is absent, `portable-atomic`'s default `fallback` - feature silently substitutes **a global lock**: the queue still compiles, still runs, and stops - being lock-free, contending with any unrelated user of the fallback in the same process. That is - the same silent-degradation shape as SH-14.1 itself and must not be shipped. - So the gate is **two** conditions, not one: the cfg, **and** a const assertion that - `AtomicU128::is_always_lock_free()` (a `pub const fn` in `portable-atomic`, verified). The build - fails rather than quietly taking a lock. This is the same standard SH-14.2 already applied when it - widened `slotwise_mpsc` -- it probed i686 specifically to confirm `AtomicU64` was lock-free there, - recording that a hidden mutex "would have made this a bad trade". `AtomicU128` on i686 *is* that - hidden mutex, which is why the wide shape must not exist there at all. + **The gate is one line of `Cargo.toml`, and this was measured rather than designed.** An earlier + version of this item specified `#[cfg(target_has_atomic = "128")]` plus a `const` assertion on + `is_always_lock_free()`. Both are unnecessary. Probing `portable-atomic` 1.15 on the pinned + toolchain established: + - With **`default-features = false`**, `portable_atomic::AtomicU128` **does not exist** on + `i686-pc-windows-msvc` (`error[E0432]: unresolved import ... no AtomicU128 in the root`), nor on + `x86_64` built with `-C target-feature=-cmpxchg16b`. It exists exactly where the target has a + compile-time-guaranteed native lock-free 128-bit exchange. **The `use` statement is the gate**, + and it fails loudly, naming the missing type. + - With **default features**, the `fallback` feature compiles on i686 and silently substitutes a + **global lock**. That -- not anything intrinsic to a 128-bit exchange -- is the whole source of + the silent-degradation hazard, and it is opted out of rather than guarded against. + - `#[cfg(target_has_atomic = "128")]` is the **wrong** gate regardless: `rustc 1.98.0 --print cfg` + still emits it under `-C target-feature=-cmpxchg16b`, because it tracks the target's maximum + atomic width and not instruction availability. + - `is_always_lock_free()` **is** const-evaluable (confirmed: `const X: bool = + AtomicU128::is_always_lock_free();` compiles, yielding `true` on x86_64). A const assertion on it + is nonetheless **worse than useless** here -- redundant where the type exists, and unreachable + where it does not, because there is nothing to compile. + So: depend on `portable-atomic` with `default-features = false`, and add no cfg and no assertion. + Note the consequence plainly in the shape's docs: **`reserving_mpsc_wide` cannot be built for + i686 at all**, so a caller targeting 32-bit uses `reserving_mpsc` (with SH-15.8's warnings) or the + permit claim. A portability cliff was chosen over a performance cliff because a compile error + naming `AtomicU128` is more informative than a queue that silently stops being lock-free. **[D-7](crates/windows-waitable-queues/DESIGN-NOTES.md#d-7) puts the burden of proof on adding a feature, and it is met here rather than waived.** D-7 rejected feature-gating shapes because the @@ -975,9 +985,6 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. from a downstream auditor's review, or from a `cargo vet` run. A caller who does not want the dependency must be able to not have it. Record the discharge as a decision rather than leaving it to look like D-7 was ignored. - Verify at implementation time whether Cargo's `[target.'cfg(...)'.dependencies]` accepts - `target_has_atomic`; if it does not, the dependency is optional-by-feature and the module carries - the cfg separately. - [ ] **SH-15.10** -- **Measure the wide claim beside the narrow one, and publish the difference.** Same harness, same host, same run as SH-15.5. The question is narrow and worth an answer either diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index 232ed5de..a1884e0b 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -60,7 +60,7 @@ preferred. | D-34 | **Every bounded queue surveyed is ABA-safe for one of two reasons, and this crate's `reserving_mpsc` has neither.** Either the claim counter is a whole machine word, so recurrence is unreachable -- crossbeam, concurrent-queue, thingbuf, Vyukov, SCQ's `Head`/`Tail` -- or the authorizing compare-exchange is moved onto the cell, so the decision and the write are validated together (CRQ, SCQ). Ours packs the position into a 32-bit *subfield* and authorizes with an exchange that does not cover the separately-read `head`. Nikolaev (DISC 2019, section 3) states the width assumption the field relies on and states it for **CPU-word** width, which a subfield does not satisfy; DPDK's `rte_ring` is the same protocol as ours and its published justification covers modular arithmetic only. The generalisation -- ours, unstated in any source -- is that **the atomic operation authorizing the write must cover everything the decision depended on.** Survey in [DESIGN-SESSION-2026-09-02](design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md); the fix is M15 in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). | | D-35 | **Measured: the permit claim is 2.7x faster than `reserving_mpsc` at 16-32 producers, and 1.45x slower at one.** The safer claim is also the faster one everywhere contention exists, which was not the expected result -- it touches *two* shared lines where the shipping shape touches one plus a read, and [D-26](#d-26) had established that the shared line is what collapses. The mechanism is that both of its operations are unconditional read-modify-writes that never retry, where the shipping shape's compare-exchange retries once per lost race; the retries dominate long before the second line does. It is the only shape measured that gets *faster* per push as producers are added (42.8 ns at two to 19.5 at thirty-two) and the only one that stays within 1.5x of a bare contended `fetch_add`. **This decides the shape of the fix but not the fix**: the drained regime's refusal counts differ by orders of magnitude in a way this harness cannot attribute, which SH-15.5.1 exists to settle before SH-15.6 adopts anything. | | D-36 | **0.1.0 ships [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) disclosed rather than fixed, and the disclosure is a release blocker.** Following [D-31](#d-31)'s principle -- the disclosure, not the deferral, is the decision -- because the fix is a claim-protocol replacement ([D-35](#d-35)) whose adoption is still gated on an open question, and holding the release for it would trade a *documented* hazard for an undocumented rush. **The two gaps are not equally forgiving and the text says so**: an unverified ordering is a risk of a bug, this is a known one with a computed exposure, and its failure mode is silent -- no error, panic, or counter -- so a caller can neither detect nor mitigate it. That is precisely why it may not ship in silence. Stated in the crate docs, the README, and the shape's own module docs, each leading with **"on every target, not only 32-bit ones"**, because the natural spelling "32-bit position" invites the opposite reading and SH-6.1 already had to be corrected for exactly that. The shape-selection guidance in both documents was also amended: it previously said "start with `reserving_mpsc`" with no caveat, pointing callers at the hazardous shape by default. | -| D-37 | **The reserving claim word ships in two widths: the narrow one on every target, the wide one only where a 128-bit exchange is genuinely lock-free.** `reserving_mpsc` keeps its packed 64-bit word, keeps [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md)'s hazard, keeps [D-36](#d-36)'s warnings, and is **never silently swapped** for the wide shape on targets that could host one -- a contract that changes with the target is what PLATFORM INTEGRITY rule 2 forbids, and a caller who read "2^32" must get 2^32. `reserving_mpsc_wide` is the same protocol with a `u128` word split 64/64: recurrence needs 2^64 pushes, and the capacity ceiling rises to 2^62. **The gate is two conditions, not one, and the second is the load-bearing one**: `target_has_atomic="128"` is emitted even with `cmpxchg16b` disabled (verified on 1.98), and `portable-atomic`'s default fallback then substitutes a **global lock** silently -- so a `const` assertion on `is_always_lock_free()` fails the build rather than shipping a queue that quietly stops being lock-free. That is the standard [SH-14.2](../../CHECKLIST-ship-topology-and-queues.md) already set when it probed i686 to confirm `AtomicU64` was lock-free before widening `slotwise_mpsc`, recording that a hidden mutex "would have made this a bad trade". [D-7](#d-7)'s burden of proof for adding a Cargo feature is **discharged, not waived**: D-7 rejected feature-gating because the only benefit was compile time, and the cost here is a third-party dependency, which dead-code elimination does not remove from `Cargo.lock` or from an auditor's review. | +| D-37 | **The reserving claim word ships in two widths: the narrow one on every target, the wide one only where a 128-bit exchange is genuinely lock-free.** `reserving_mpsc` keeps its packed 64-bit word, keeps [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md)'s hazard, keeps [D-36](#d-36)'s warnings, and is **never silently swapped** for the wide shape on targets that could host one -- a contract that changes with the target is what PLATFORM INTEGRITY rule 2 forbids, and a caller who read "2^32" must get 2^32. `reserving_mpsc_wide` is the same protocol with a `u128` word split 64/64: recurrence needs 2^64 pushes, and the capacity ceiling rises to 2^62. **The gate is one line of `Cargo.toml`: `default-features = false`.** Measured, not designed -- with the default feature set `portable-atomic` compiles on i686 and silently substitutes a global lock, but with defaults off `AtomicU128` **does not exist** there (`no AtomicU128 in the root`), nor on x86_64 built without `cmpxchg16b`. It exists exactly where a native lock-free exchange is guaranteed at compile time, so the `use` statement is the gate and it fails loudly. A `cfg(target_has_atomic = "128")` would be the *wrong* gate -- it is emitted even with `cmpxchg16b` disabled -- and a `const` assertion on `is_always_lock_free()`, though genuinely const-evaluable, is redundant where the type exists and unreachable where it does not. That is the standard [SH-14.2](../../CHECKLIST-ship-topology-and-queues.md) already set when it probed i686 to confirm `AtomicU64` was lock-free before widening `slotwise_mpsc`, recording that a hidden mutex "would have made this a bad trade". [D-7](#d-7)'s burden of proof for adding a Cargo feature is **discharged, not waived**: D-7 rejected feature-gating because the only benefit was compile time, and the cost here is a third-party dependency, which dead-code elimination does not remove from `Cargo.lock` or from an auditor's review. | ## D-2: capabilities are sliced, not gathered @@ -585,9 +585,11 @@ around. default `fallback` feature silently substitutes a **global lock**, so the queue keeps working and stops being lock-free, contending with any unrelated user of the fallback in the same process. And `target_has_atomic="128"` is emitted even with `cmpxchg16b` disabled -- verified on 1.98 -- so a cfg -gate alone does not catch it either. That silent degradation, not a compile error, is the real reason -this shape may not simply widen its word, and D-37's `const` assertion on `is_always_lock_free()` -exists to turn it back into a build failure. +gate does not catch it either. That silent degradation, not a compile error, is the real reason this +shape may not simply widen its word. +D-37 turns it back into a build failure by the simplest available means: depending on +`portable-atomic` with **`default-features = false`**, which withholds the `fallback` feature, so +`AtomicU128` does not exist at all on a target that cannot do it natively. **Amended 2026-09-02. The refusal stands; almost none of its original reasoning does.** The first version of this decision was written before [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) diff --git a/crates/windows-waitable-queues/src/permit_mpsc.rs b/crates/windows-waitable-queues/src/permit_mpsc.rs index e0037faf..d6fa5e13 100644 --- a/crates/windows-waitable-queues/src/permit_mpsc.rs +++ b/crates/windows-waitable-queues/src/permit_mpsc.rs @@ -58,7 +58,7 @@ use core::cell::{Cell, UnsafeCell}; use core::marker::PhantomData; use core::mem::MaybeUninit; -use core::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; use crate::CacheAligned; @@ -67,6 +67,25 @@ use crate::doorbell::Doorbell; use crate::error::{CapacityError, PushError}; use crate::metrics::Metrics; +/// A ticket, and the slot sequence numbers compared against one. +/// +/// **64 bits on every target, deliberately, rather than `usize`**, for the same +/// reason [`slotwise_mpsc`](crate::slotwise_mpsc) made the same choice: a +/// 32-bit counter laps in minutes at this crate's measured rates, and a shape +/// whose soundness depends on the target's pointer width is not one this crate +/// ships twice over. +/// +/// It matters *less* here than there, and that difference is the point of the +/// shape. In `slotwise_mpsc` the position is compared against a slot sequence to +/// decide whether a slot is free, so a lap is a correctness hazard. Here the +/// ticket carries **no decision at all** -- admission was already settled by the +/// permit -- so its only job is to name a distinct slot, and it could wrap +/// harmlessly. 64 bits makes it a *dumb* `fetch_add` that nobody has to reason +/// about again: no wrap analysis, no ambiguity bound to re-derive, and a +/// difference against `head` that stays meaningful for any queue this process +/// could construct. +type Position = u64; + /// What this shape accepts as a capacity. See [`BOUNDS_MAX`]. const BOUNDS: Bounds = Bounds { min: 2, @@ -75,22 +94,17 @@ const BOUNDS: Bounds = Bounds { /// The largest capacity this shape accepts. /// -/// The same ceiling as [`reserving_mpsc`](crate::reserving_mpsc), so the two are -/// measured over the same range rather than over ranges that happen to differ. +/// The crate-wide ceiling, matching [`slotwise_mpsc`](crate::slotwise_mpsc) +/// rather than [`reserving_mpsc`](crate::reserving_mpsc). That shape's far lower +/// 2^31 is forced by its packing -- half a word for the position, half for the +/// reservation count -- and this one has no packed word to be constrained by. /// -/// Note what is *not* the reason for it here. In `reserving_mpsc` the ceiling is -/// forced by the packing -- half a word for the position, half for the count. -/// This shape has no packed word and could take the crate-wide bound directly; -/// it takes the narrower one anyway so that a measurement at a given capacity is -/// a measurement of the claim protocol and not of two different capacities. -pub const BOUNDS_MAX: usize = { - let packed = 1_usize << 31; - if packed <= MAX_ADMISSIBLE_CAPACITY { - packed - } else { - MAX_ADMISSIBLE_CAPACITY - } -}; +/// **This was 2^31 while the ticket was 32 bits**, so that a measurement against +/// `reserving_mpsc` covered the same range on both. Widening the ticket removed +/// the reason: the shapes are now measured at whatever capacity the harness +/// picks, which is well below either ceiling, and matching an artificial limit +/// would only misreport what this shape can do. +pub const BOUNDS_MAX: usize = MAX_ADMISSIBLE_CAPACITY; const _: () = { assert!( @@ -103,14 +117,34 @@ const _: () = { "a shape that accepts nothing would reject every capacity with a suggestion it would also \ reject" ); - // The permit count is signed and may go transiently negative by at most one - // per concurrent claimant (see `take_permit`), so the capacity must leave - // room below `i64::MAX` for every thread that could be in flight. A 2^31 - // ceiling against a 2^63 counter leaves 2^32 threads of headroom, which is - // more than the process can create. + // The permit count starts at the capacity and never exceeds it, so the + // capacity is what must fit in the signed count's *positive* range. + // + // **The transient overdraft does not constrain this**, which is worth + // stating because an earlier version of this assertion assumed it did and + // reserved half the range for it. The overdraft goes *negative* -- each + // concurrent claimant subtracts one before undoing -- so it consumes the + // range below zero, of which there is a full 2^63, against a bound of one + // per thread in flight. It cannot meet the positive bound from below. + assert!( + BOUNDS.max as u64 <= i64::MAX as u64, + "the permit count must be able to hold the whole capacity" + ); + // `len` reads the queue's depth as `tail - head` in wrapping arithmetic, and + // that difference is unambiguous only up to half the ticket's range. So the + // capacity must fit below that half, not merely below `Position::MAX`. + // + // **Stated against the half rather than the maximum deliberately.** An + // earlier version of this assertion compared `BOUNDS.max` to `Position::MAX`, + // which is *tautological* on every target -- a `usize` capacity cannot exceed + // a `u64` maximum -- and so asserted nothing at all. That is precisely the + // trap `reserving_mpsc`'s own const block records having fallen into once. + // This form fails if `Position` is ever narrowed to `u32`, which is the + // change it exists to catch. assert!( - BOUNDS.max as i64 <= i64::MAX / 2, - "the permit count must hold the whole capacity with room for transient overdraft" + (BOUNDS.max as u128) <= 1_u128 << (Position::BITS - 1), + "the ticket must be wide enough that a wrapping depth is unambiguous at any capacity this \ + shape accepts" ); }; @@ -129,7 +163,7 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit slots.push(Slot { // Anything that is not `position + 1` for the position this slot // first serves. Matches `reserving_mpsc`'s initialisation exactly. - sequence: AtomicU32::new(index as u32), + sequence: AtomicU64::new(index as Position), value: UnsafeCell::new(MaybeUninit::uninit()), }); } @@ -139,8 +173,8 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, - head: CacheAligned(AtomicU32::new(0)), - tail: CacheAligned(AtomicU32::new(0)), + head: CacheAligned(AtomicU64::new(0)), + tail: CacheAligned(AtomicU64::new(0)), permits: CacheAligned(AtomicI64::new(capacity as i64)), producers: AtomicUsize::new(1), consumer_live: AtomicBool::new(true), @@ -162,7 +196,7 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit /// One cell of the ring. struct Slot { /// `position + 1` once the producer holding `position` has finished writing. - sequence: AtomicU32, + sequence: AtomicU64, value: UnsafeCell>, } @@ -177,12 +211,14 @@ struct Shared { /// `reserving_mpsc` every push loads it to count free slots, and that load /// is both the cost D-26 measured and the stale input SH-14.1 exploits. /// Here it is consumer-private, kept atomic only so `len` can sample it. - head: CacheAligned, + head: CacheAligned, /// The ticket dispenser. Only ever `fetch_add`. /// /// Carries no decision, so it has no predicate that a recurrence could - /// invalidate, and it is free to wrap. - tail: CacheAligned, + /// invalidate. At [`Position`]'s width it is a *dumb* increment: it would be + /// sound if it wrapped, and it cannot wrap, so neither property has to be + /// argued at a call site again. + tail: CacheAligned, /// Slots not currently spoken for, as a signed count. /// /// Signed because the claim is an optimistic decrement that may overshoot; @@ -254,7 +290,7 @@ impl Shared { /// The caller must hold a permit and the ticket naming `position`, so that /// no other producer can write this slot and the consumer has finished with /// whatever it held a lap ago. - unsafe fn publish(&self, position: u32, item: T) { + unsafe fn publish(&self, position: Position, item: T) { let slot = &self.slots[position as usize & self.mask]; // SAFETY: the caller's ticket makes this thread the only writer, and its // permit means the consumer has finished with the previous occupant. From a43b6a8c264c8d34efbc67f9fc1e9aab6aa89979 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 12:52:13 -0400 Subject: [PATCH 213/361] refactor(probes): route the remaining twelve probes through the report sink Completes the sink adoption started when `report` was introduced. All fourteen probe binaries now compose their report as text and name the real stream in exactly one place, so a probe's findings are a value rather than bytes only a terminal ever sees. Pure refactor: no probe's report changed. Each conversion was checked by capturing the probe's output before the change and again after, and requiring the two to match. The verification method is the part worth recording, because the obvious tool is wrong and quietly said everything passed. The defect a conversion of this shape introduces is a helper that still writes to stdout while its caller composes a string. Every line still appears, so nothing is lost -- but the helper's lines arrive FIRST, ahead of the buffered ones, and the report is reordered. PowerShell's Compare-Object compares collections as sets, so it reports identical for exactly that case. It passed completion_port, pool_growth and queue_contention, all three of which were genuinely broken: 19, 4 and 2 print sites respectively sat in helper functions that the conversion had not touched. Redoing the comparison line-by-line and positionally found them immediately. Those three helpers now take the buffer as a parameter, and report.rs records the trap so the next person converting a probe does not rediscover it. Two further findings from the same check, both benign and both worth naming so a re-runner is not alarmed: - cancel_io's attempt ordering is a genuine race, so two lines legitimately swap between runs. - device_map embeds its own PID in a drive target, and several probes print measured nanoseconds, so an exact byte comparison is not the right test. The check normalises numbers and whitespace and compares structure -- line count, order, labels, format -- which is what a conversion can break. Mechanical work was scripted, and the script was not trusted: it inserted its imports inside a multi-line `use` block on the first run, which did not compile. Two further classes needed hand correction afterwards -- early `return;` in what is now a function returning String, and `println!` in expression position inside match arms -- because a regex cannot see either. device_map's closure became a fn taking the buffer, since a closure capturing `out` mutably holds that borrow across every later write. Completed item: SH-13.4: The other twelve probes still print directly, and now there is a sink to adopt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 2 +- .../src/bin/cancel_io.rs | 86 +++++-- .../src/bin/completion_port.rs | 114 ++++++-- .../src/bin/device_map.rs | 123 ++++++--- .../src/bin/error_mode.rs | 49 +++- .../src/bin/handle_state.rs | 46 +++- .../windows-platform-probes/src/bin/ioring.rs | 103 ++++++-- .../src/bin/peer_index_cache.rs | 243 +++++++++++++----- .../src/bin/pool_growth.rs | 72 ++++-- .../src/bin/queue_contention.rs | 161 +++++++++--- .../src/bin/request_cost.rs | 188 ++++++++++---- .../src/bin/topology.rs | 141 +++++++--- .../src/bin/worker_context.rs | 77 ++++-- crates/windows-platform-probes/src/report.rs | 16 +- 14 files changed, 1068 insertions(+), 353 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 69db2c1f..f004c6a2 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -665,7 +665,7 @@ mutation wrapper. The conversion's three silent fallbacks are replaced by one ru masked (their output is timing-dependent, so byte equality is not available): 38 lines and 50 lines respectively, **structurally identical** both times. -- [ ] **SH-13.4** -- **The other twelve probes still print directly, and now there is a sink to +- [x] **SH-13.4** -- **The other twelve probes still print directly, and now there is a sink to adopt.** `probe-peer-index-cache` (55 sites), `probe-request-cost` (45), `probe-topology` (32), `probe-queue-contention` (27), `probe-ioring` (24), `probe-completion-port` (22), `probe-worker-context` (22), `probe-device-map` (21), `probe-cancel-io` (19), diff --git a/crates/windows-platform-probes/src/bin/cancel_io.rs b/crates/windows-platform-probes/src/bin/cancel_io.rs index 013801ae..4497603b 100644 --- a/crates/windows-platform-probes/src/bin/cancel_io.rs +++ b/crates/windows-platform-probes/src/bin/cancel_io.rs @@ -11,9 +11,11 @@ //! is a call that can fail to return, so every case here runs behind a //! watchdog; a wedged `#[test]` would take the whole suite with it. +use std::fmt::Write as _; use windows_platform_probes::cancel_io::{ CancelOutcome, WATCHDOG, cancel_against_busy_thread, cancel_against_idle_thread, }; +use windows_platform_probes::report::{Stdout, emit}; fn describe(outcome: CancelOutcome) -> String { match outcome { @@ -26,37 +28,83 @@ fn describe(outcome: CancelOutcome) -> String { } fn main() { - println!("== is CancelSynchronousIo safe to point at a shared thread? ==\n"); - println!("watchdog: {WATCHDOG:?} per attempt\n"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "== is CancelSynchronousIo safe to point at a shared thread? ==\n" + ); + let _ = writeln!(out, "watchdog: {WATCHDOG:?} per attempt\n"); - println!("case 1: cancel against a thread with no I/O outstanding"); + let _ = writeln!( + out, + "case 1: cancel against a thread with no I/O outstanding" + ); let idle = cancel_against_idle_thread(); - println!(" {}", describe(idle)); - println!( + let _ = writeln!(out, " {}", describe(idle)); + let _ = writeln!( + out, " -> a point-in-time cancel finds nothing here; a standing request\n against the thread would not." ); - println!("\ncase 2: cancel against a thread hammering synchronous reads"); + let _ = writeln!( + out, + "\ncase 2: cancel against a thread hammering synchronous reads" + ); let busy = cancel_against_busy_thread(4); for (attempt, outcome) in busy.iter().enumerate() { - println!(" attempt {}: {}", attempt + 1, describe(*outcome)); + let _ = writeln!(out, " attempt {}: {}", attempt + 1, describe(*outcome)); } let wedged = busy.iter().any(|outcome| !outcome.returned()); - println!("\nconclusion:"); + let _ = writeln!(out, "\nconclusion:"); if wedged { - println!(" CancelSynchronousIo DID NOT RETURN against a thread that keeps"); - println!(" re-entering synchronous I/O. In the proposed mid-flight"); - println!(" cancellation design the canceller is a control-plane thread and"); - println!(" the target is a shared pool worker -- so a control plane can be"); - println!(" wedged by the very thing it is trying to rescue."); - println!(" This is why mid-flight cancellation stays deferred."); + let _ = writeln!( + out, + " CancelSynchronousIo DID NOT RETURN against a thread that keeps" + ); + let _ = writeln!( + out, + " re-entering synchronous I/O. In the proposed mid-flight" + ); + let _ = writeln!( + out, + " cancellation design the canceller is a control-plane thread and" + ); + let _ = writeln!( + out, + " the target is a shared pool worker -- so a control plane can be" + ); + let _ = writeln!(out, " wedged by the very thing it is trying to rescue."); + let _ = writeln!(out, " This is why mid-flight cancellation stays deferred."); } else { - println!(" every attempt returned on this host and this Windows build."); - println!(" That does NOT clear the design: the original measurement wedged"); - println!(" with four identical noninvasive samples over twelve seconds, so"); - println!(" a non-wedge here is a timing difference rather than a refutation."); - println!(" Re-run, and vary the hammer loop, before concluding otherwise."); + let _ = writeln!( + out, + " every attempt returned on this host and this Windows build." + ); + let _ = writeln!( + out, + " That does NOT clear the design: the original measurement wedged" + ); + let _ = writeln!( + out, + " with four identical noninvasive samples over twelve seconds, so" + ); + let _ = writeln!( + out, + " a non-wedge here is a timing difference rather than a refutation." + ); + let _ = writeln!( + out, + " Re-run, and vary the hammer loop, before concluding otherwise." + ); } + out } diff --git a/crates/windows-platform-probes/src/bin/completion_port.rs b/crates/windows-platform-probes/src/bin/completion_port.rs index 3b67a218..6c44578b 100644 --- a/crates/windows-platform-probes/src/bin/completion_port.rs +++ b/crates/windows-platform-probes/src/bin/completion_port.rs @@ -13,11 +13,14 @@ //! completion arrived, and so read a clean failure -- result code //! `ERROR_INVALID_PARAMETER`, zero bytes -- as a success. +use std::fmt::Write as _; use windows_platform_probes::completion_port::{CompletionPortFinding, ReadAttempt, measure}; use windows_platform_probes::ioring::IoRingSupport; +use windows_platform_probes::report::{Stdout, emit}; -fn describe(label: &str, attempt: ReadAttempt) { - println!( +fn describe(out: &mut String, label: &str, attempt: ReadAttempt) { + let _ = writeln!( + out, " {label:<46} result={:#010x} bytes={} first={:#04x} [{}]", attempt.result_code, attempt.bytes, @@ -26,26 +29,34 @@ fn describe(label: &str, attempt: ReadAttempt) { ); } -fn report(finding: CompletionPortFinding) { - println!("a PASS needs all three: success code, full byte count, and the fill byte.\n"); +fn report(out: &mut String, finding: CompletionPortFinding) { + let _ = writeln!( + out, + "a PASS needs all three: success code, full byte count, and the fill byte.\n" + ); describe( + out, "case 1 CONTROL: no association at all", finding.control_unassociated, ); describe( + out, "case 2 TEST: associated, then IoRing read", finding.after_iocp_association, ); describe( + out, "case 3a: IoRing read BEFORE association", finding.before_late_association, ); describe( + out, "case 3b: IoRing read AFTER association", finding.after_late_association, ); - println!( + let _ = writeln!( + out, " {:<46} [{}]", "case 4 CONTROL: overlapped read via the port", if finding.port_still_works { @@ -55,54 +66,107 @@ fn report(finding: CompletionPortFinding) { } ); describe( + out, "case 5a: IoRing BEFORE CreateThreadpoolIo", finding.before_threadpool_io, ); describe( + out, "case 5b: IoRing AFTER CreateThreadpoolIo", finding.after_threadpool_io, ); - println!("\n--- verdict ---"); + let _ = writeln!(out, "\n--- verdict ---"); if !finding.is_valid() { - println!(" INVALID: a negative control failed, so nothing can be concluded."); - println!(" The probe is broken rather than the platform answering."); + let _ = writeln!( + out, + " INVALID: a negative control failed, so nothing can be concluded." + ); + let _ = writeln!( + out, + " The probe is broken rather than the platform answering." + ); return; } if finding.association_forecloses_ioring() { - println!(" IOCP association FORECLOSES IoRing use of the same handle."); + let _ = writeln!( + out, + " IOCP association FORECLOSES IoRing use of the same handle." + ); if finding.port_still_works { - println!(" The handle is still healthy -- it completes through the port -- so it"); - println!(" is the IoRing path specifically that is refused, not the handle."); + let _ = writeln!( + out, + " The handle is still healthy -- it completes through the port -- so it" + ); + let _ = writeln!( + out, + " is the IoRing path specifically that is refused, not the handle." + ); } else { - println!(" NOTE: the port control also failed, so the handle may be broken"); - println!(" outright rather than only the IoRing path being refused."); + let _ = writeln!( + out, + " NOTE: the port control also failed, so the handle may be broken" + ); + let _ = writeln!( + out, + " outright rather than only the IoRing path being refused." + ); } } else { - println!(" COEXIST: association does NOT prevent IoRing use of the handle."); - println!(" This contradicts the reading windows-namespace-request-sys rests on;"); - println!(" the handle-destination fork would need revisiting."); + let _ = writeln!( + out, + " COEXIST: association does NOT prevent IoRing use of the handle." + ); + let _ = writeln!( + out, + " This contradicts the reading windows-namespace-request-sys rests on;" + ); + let _ = writeln!(out, " the handle-destination fork would need revisiting."); } if finding.threadpool_io_forecloses_ioring() { - println!("\n CreateThreadpoolIo forecloses it the SAME way. That is the path this"); - println!(" workspace actually uses, so the consequence lands on"); - println!(" windows-threadpool-sys's own users."); + let _ = writeln!( + out, + "\n CreateThreadpoolIo forecloses it the SAME way. That is the path this" + ); + let _ = writeln!( + out, + " workspace actually uses, so the consequence lands on" + ); + let _ = writeln!(out, " windows-threadpool-sys's own users."); } else { - println!("\n CreateThreadpoolIo does NOT foreclose it, which differs from raw IOCP."); - println!(" Worth knowing: the two are not interchangeable for this purpose."); + let _ = writeln!( + out, + "\n CreateThreadpoolIo does NOT foreclose it, which differs from raw IOCP." + ); + let _ = writeln!( + out, + " Worth knowing: the two are not interchangeable for this purpose." + ); } } fn main() { - println!("== IOCP association vs IoRing, on one handle ==\n"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); + let _ = writeln!(out, "== IOCP association vs IoRing, on one handle ==\n"); match measure() { IoRingSupport::Unavailable => { - println!("this host has no usable IoRing, so nothing was measured."); - println!("('cannot ask' is not 'the answer is no'.)"); + let _ = writeln!( + out, + "this host has no usable IoRing, so nothing was measured." + ); + let _ = writeln!(out, "('cannot ask' is not 'the answer is no'.)"); } - IoRingSupport::Measured(finding) => report(finding), + IoRingSupport::Measured(finding) => report(&mut out, finding), } + out } diff --git a/crates/windows-platform-probes/src/bin/device_map.rs b/crates/windows-platform-probes/src/bin/device_map.rs index 6e26ea40..722bcf8a 100644 --- a/crates/windows-platform-probes/src/bin/device_map.rs +++ b/crates/windows-platform-probes/src/bin/device_map.rs @@ -13,65 +13,120 @@ //! path resolved on a submitting thread and opened on a worker under a captured //! token can name a different device. +use std::fmt::Write as _; use windows_platform_probes::device_map::{SubstDrive, measure_with_subst}; +use windows_platform_probes::report::{Stdout, emit}; fn main() { - println!("== does impersonation change the DOS device map? ==\n"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); + let _ = writeln!(out, "== does impersonation change the DOS device map? ==\n"); let Some(drive) = SubstDrive::claim("binary") else { - println!("no free drive letter on this host, so the probe cannot run."); - println!("(Reported rather than measured: a probe that cannot set up its"); - println!("fixture must say so instead of producing a misleading negative.)"); - return; + let _ = writeln!( + out, + "no free drive letter on this host, so the probe cannot run." + ); + let _ = writeln!( + out, + "(Reported rather than measured: a probe that cannot set up its" + ); + let _ = writeln!( + out, + "fixture must say so instead of producing a misleading negative.)" + ); + return out; }; - println!( + let _ = writeln!( + out, "using {} as a subst-style link to {}\n", drive.letter(), drive.target() ); let finding = measure_with_subst(&drive); - let describe = - |label: &str, observation: &windows_platform_probes::device_map::MapObservation| { - println!("{label}"); - println!( - " {} -> {}", - observation.letter, - observation - .target - .as_deref() - .unwrap_or("(not found in this map)") - ); - match observation.logon_session { - Some((low, high)) => println!(" logon session LUID: {high:08x}:{low:08x}"), - None => println!(" logon session LUID: (not impersonating)"), + // Takes the buffer as an argument rather than capturing it, so that `out` + // stays available to the lines below. A closure capturing it mutably would + // hold the borrow across every later write. + fn describe( + out: &mut String, + label: &str, + observation: &windows_platform_probes::device_map::MapObservation, + ) { + let _ = writeln!(out, "{label}"); + let _ = writeln!( + out, + " {} -> {}", + observation.letter, + observation + .target + .as_deref() + .unwrap_or("(not found in this map)") + ); + match observation.logon_session { + Some((low, high)) => { + let _ = writeln!(out, " logon session LUID: {high:08x}:{low:08x}"); } - }; + None => { + let _ = writeln!(out, " logon session LUID: (not impersonating)"); + } + } + } - describe("our own session:", &finding.own_session); - println!(); + describe(&mut out, "our own session:", &finding.own_session); + let _ = writeln!(out); describe( + &mut out, "impersonating the anonymous session:", &finding.anonymous_session, ); - println!("\ncontrol:"); - println!( + let _ = writeln!(out, "\ncontrol:"); + let _ = writeln!( + out, " the two contexts are different logon sessions: {}", finding.sessions_differ() ); - println!("\nconclusion:"); + let _ = writeln!(out, "\nconclusion:"); if finding.impersonation_changes_the_map() { - println!(" the SAME drive letter, on the SAME thread, resolves differently"); - println!(" depending on the token in effect. A path resolved on a submitter"); - println!(" and opened on a worker under a captured token can name a"); - println!(" different device -- which is why lexical resolution does not"); - println!(" close that hazard."); + let _ = writeln!( + out, + " the SAME drive letter, on the SAME thread, resolves differently" + ); + let _ = writeln!( + out, + " depending on the token in effect. A path resolved on a submitter" + ); + let _ = writeln!( + out, + " and opened on a worker under a captured token can name a" + ); + let _ = writeln!( + out, + " different device -- which is why lexical resolution does not" + ); + let _ = writeln!(out, " close that hazard."); } else { - println!(" the letter resolved the same way in both contexts. Either the"); - println!(" finding has changed, or the impersonation did not take effect --"); - println!(" check the control above before believing the former."); + let _ = writeln!( + out, + " the letter resolved the same way in both contexts. Either the" + ); + let _ = writeln!( + out, + " finding has changed, or the impersonation did not take effect --" + ); + let _ = writeln!( + out, + " check the control above before believing the former." + ); } + out } diff --git a/crates/windows-platform-probes/src/bin/error_mode.rs b/crates/windows-platform-probes/src/bin/error_mode.rs index 43a01611..2d052a60 100644 --- a/crates/windows-platform-probes/src/bin/error_mode.rs +++ b/crates/windows-platform-probes/src/bin/error_mode.rs @@ -13,10 +13,13 @@ //! the one observation a test must not: the alignment bit's process-scope //! stickiness is irreversible, so it is demonstrated here and nowhere else. +use std::fmt::Write as _; + use windows_platform_probes::error_mode::{ alignment_bit_is_sticky_at_process_scope, bits, combined_invalid_installs_nothing, probe_bit, settable_bits, thread_mode_independent_of_process, }; +use windows_platform_probes::report::{Stdout, emit}; fn name(bit: u32) -> &'static str { match bit { @@ -29,7 +32,22 @@ fn name(bit: u32) -> &'static str { } fn main() { - println!("--- each bit on its own, set then read back ---"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +/// +/// **This one measures as it renders**, unlike its siblings, and the coupling is +/// real rather than laziness: the last observation permanently alters this +/// process (see `alignment_bit_is_sticky_at_process_scope`), so the order in +/// which the observations are taken is part of what is being reported. Splitting +/// measurement from rendering would invite a later reordering that silently +/// changes the result. +fn render() -> String { + let mut out = String::new(); + let _ = writeln!(out, "--- each bit on its own, set then read back ---"); for bit in [ bits::FAIL_CRITICAL_ERRORS, bits::NO_GP_FAULT_ERROR_BOX, @@ -44,7 +62,8 @@ fn main() { } else { "REJECTED" }; - println!( + let _ = writeln!( + out, "{} ok={} last_error={:<5} read_back=0x{:04X} -> {verdict}", name(bit), outcome.set_ok, @@ -53,11 +72,12 @@ fn main() { ); } - println!("\nsettable mask: 0x{:04X}", settable_bits()); + let _ = writeln!(out, "\nsettable mask: 0x{:04X}", settable_bits()); let (installed_nothing, read_back) = combined_invalid_installs_nothing(); - println!("\n--- one invalid bit alongside two valid ones ---"); - println!( + let _ = writeln!(out, "\n--- one invalid bit alongside two valid ones ---"); + let _ = writeln!( + out, "read back 0x{read_back:04X} -> {}", if installed_nothing { "the WHOLE call failed; none of the valid bits was installed" @@ -67,8 +87,9 @@ fn main() { ); let observation = thread_mode_independent_of_process(); - println!("\n--- process mode versus thread mode ---"); - println!( + let _ = writeln!(out, "\n--- process mode versus thread mode ---"); + let _ = writeln!( + out, "process=0x{:04X} thread=0x{:04X} -> {}", observation.process_mode, observation.thread_mode, @@ -79,10 +100,17 @@ fn main() { } ); - println!("\n--- irreversible: the alignment bit at process scope ---"); - println!("(this permanently alters this process, which is why no test does it)"); + let _ = writeln!( + out, + "\n--- irreversible: the alignment bit at process scope ---" + ); + let _ = writeln!( + out, + "(this permanently alters this process, which is why no test does it)" + ); let (before, after) = alignment_bit_is_sticky_at_process_scope(); - println!( + let _ = writeln!( + out, "before=0x{before:04X} after restore attempt=0x{after:04X} -> {}", if after & bits::NO_ALIGNMENT_FAULT_EXCEPT != 0 { "STICKY: the restore was ignored" @@ -90,4 +118,5 @@ fn main() { "clearable" } ); + out } diff --git a/crates/windows-platform-probes/src/bin/handle_state.rs b/crates/windows-platform-probes/src/bin/handle_state.rs index b2eadbaa..30a30cd5 100644 --- a/crates/windows-platform-probes/src/bin/handle_state.rs +++ b/crates/windows-platform-probes/src/bin/handle_state.rs @@ -11,22 +11,33 @@ //! observations themselves -- the actual file names each handle returned -- //! which is what makes a surprising result diagnosable rather than merely red. +use std::fmt::Write as _; use windows_platform_probes::handle_state::{ Fixture, SingleShot, closing_duplicate_preserves_source, duplicate_shares_cursor, ground_truth, query_disturbs_cursor, separate_opens_are_independent, }; +use windows_platform_probes::report::{Stdout, emit}; fn main() { + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); let fixture = Fixture::new("bin"); let truth = ground_truth(&fixture); - println!("--- ground truth (one handle, start to finish) ---"); - println!("{} entries: {truth:?}\n", truth.len()); + let _ = writeln!(out, "--- ground truth (one handle, start to finish) ---"); + let _ = writeln!(out, "{} entries: {truth:?}\n", truth.len()); let observation = duplicate_shares_cursor(&fixture); - println!("--- does a duplicate share the cursor? ---"); - println!("source (restart) -> {:?}", observation.source_first); - println!("duplicate (continue) -> {:?}", observation.other_next); - println!( + let _ = writeln!(out, "--- does a duplicate share the cursor? ---"); + let _ = writeln!(out, "source (restart) -> {:?}", observation.source_first); + let _ = writeln!(out, "duplicate (continue) -> {:?}", observation.other_next); + let _ = writeln!( + out, " -> {}\n", if observation.continued(&truth) { "SHARED: the duplicate continued where the source stopped" @@ -38,10 +49,11 @@ fn main() { ); let control = separate_opens_are_independent(&fixture); - println!("--- control: two separate opens ---"); - println!("open #1 (restart) -> {:?}", control.source_first); - println!("open #2 (continue) -> {:?}", control.other_next); - println!( + let _ = writeln!(out, "--- control: two separate opens ---"); + let _ = writeln!(out, "open #1 (restart) -> {:?}", control.source_first); + let _ = writeln!(out, "open #2 (continue) -> {:?}", control.other_next); + let _ = writeln!( + out, " -> {}\n", if control.restarted() { "INDEPENDENT, as expected -- so the result above is attributable to duplication" @@ -50,8 +62,9 @@ fn main() { } ); - println!("--- does closing the duplicate break the source? ---"); - println!( + let _ = writeln!(out, "--- does closing the duplicate break the source? ---"); + let _ = writeln!( + out, " -> {}\n", if closing_duplicate_preserves_source(&fixture) { "no: the source kept enumerating" @@ -60,7 +73,10 @@ fn main() { } ); - println!("--- does an interleaved single-shot query move the cursor? ---"); + let _ = writeln!( + out, + "--- does an interleaved single-shot query move the cursor? ---" + ); for (query, on_duplicate) in [ (SingleShot::BasicInfo, false), (SingleShot::IdInfo, false), @@ -68,7 +84,8 @@ fn main() { (SingleShot::BasicInfo, true), ] { let (succeeded, disturbed) = query_disturbs_cursor(&fixture, query, on_duplicate, &truth); - println!( + let _ = writeln!( + out, "{query:?}{} succeeded={succeeded} -> {}", if on_duplicate { " (on the duplicate)" @@ -82,4 +99,5 @@ fn main() { } ); } + out } diff --git a/crates/windows-platform-probes/src/bin/ioring.rs b/crates/windows-platform-probes/src/bin/ioring.rs index 929dfbee..d8c3284e 100644 --- a/crates/windows-platform-probes/src/bin/ioring.rs +++ b/crates/windows-platform-probes/src/bin/ioring.rs @@ -11,63 +11,116 @@ //! this reports "cannot measure" rather than a false negative on a host that //! has no ring. +use std::fmt::Write as _; use windows_platform_probes::ioring::{ IoRingSupport, is_available, measure_registration, measure_thread_agnosticism, }; +use windows_platform_probes::report::{Stdout, emit}; fn main() { - println!("== IoRing registration and thread agnosticism ==\n"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); + let _ = writeln!(out, "== IoRing registration and thread agnosticism ==\n"); if !is_available() { - println!("this host has no usable IoRing, so nothing was measured."); - println!("(Reported rather than measured: 'we could not ask' and 'the answer"); - println!("is no' are different facts, and conflating them is how a design note"); - println!("ends up citing a measurement that never ran.)"); - return; + let _ = writeln!( + out, + "this host has no usable IoRing, so nothing was measured." + ); + let _ = writeln!( + out, + "(Reported rather than measured: 'we could not ask' and 'the answer" + ); + let _ = writeln!( + out, + "is no' are different facts, and conflating them is how a design note" + ); + let _ = writeln!(out, "ends up citing a measurement that never ran.)"); + return out; } - println!("registration semantics:"); + let _ = writeln!(out, "registration semantics:"); match measure_registration() { - IoRingSupport::Unavailable => println!(" (no ring)"), + IoRingSupport::Unavailable => { + let _ = writeln!(out, " (no ring)"); + } IoRingSupport::Measured(observed) => { - println!( + let _ = writeln!( + out, " after re-registering ONE handle: index 0 usable {}, index 1 usable {}", observed.index_zero_usable_after_second, observed.index_one_usable_after_second ); if observed.replaces() { - println!(" -> REPLACES the whole table, which is what"); - println!(" windows-ioring-sys assumes and refuses a second call on."); + let _ = writeln!(out, " -> REPLACES the whole table, which is what"); + let _ = writeln!( + out, + " windows-ioring-sys assumes and refuses a second call on." + ); } else if observed.appends() { - println!(" -> APPENDS. windows-ioring-sys's index bookkeeping would be"); - println!(" WRONG and its refusal a needless restriction."); + let _ = writeln!( + out, + " -> APPENDS. windows-ioring-sys's index bookkeeping would be" + ); + let _ = writeln!(out, " WRONG and its refusal a needless restriction."); } else { - println!(" -> neither: even index 0 stopped working, so the probe"); - println!(" broke rather than the platform answering."); + let _ = writeln!( + out, + " -> neither: even index 0 stopped working, so the probe" + ); + let _ = writeln!(out, " broke rather than the platform answering."); } } } - println!("\nthread agnosticism:"); + let _ = writeln!(out, "\nthread agnosticism:"); match measure_thread_agnosticism() { - IoRingSupport::Unavailable => println!(" (no ring)"), + IoRingSupport::Unavailable => { + let _ = writeln!(out, " (no ring)"); + } IoRingSupport::Measured(observed) => { - println!( + let _ = writeln!( + out, " pending at submitter exit: {} | result code: {:#010x} | \ transferred the fill byte: {}", observed.pending_at_submitter_exit, observed.result_code, observed.filled ); if !observed.pending_at_submitter_exit { - println!(" -> the read had ALREADY completed, so this run measured"); - println!(" nothing about thread affinity either way."); + let _ = writeln!( + out, + " -> the read had ALREADY completed, so this run measured" + ); + let _ = writeln!(out, " nothing about thread affinity either way."); } if observed.survives_submitter_exit() { - println!(" -> an operation OUTLIVES the thread that submitted it, so a"); - println!(" design whose threads are transient by construction is safe."); + let _ = writeln!( + out, + " -> an operation OUTLIVES the thread that submitted it, so a" + ); + let _ = writeln!( + out, + " design whose threads are transient by construction is safe." + ); } else { - println!(" -> the operation did NOT survive its submitter. Every thread"); - println!(" in the proposed design is transient, so this would fail"); - println!(" only under load -- the worst place to discover it."); + let _ = writeln!( + out, + " -> the operation did NOT survive its submitter. Every thread" + ); + let _ = writeln!( + out, + " in the proposed design is transient, so this would fail" + ); + let _ = writeln!( + out, + " only under load -- the worst place to discover it." + ); } } } + out } diff --git a/crates/windows-platform-probes/src/bin/peer_index_cache.rs b/crates/windows-platform-probes/src/bin/peer_index_cache.rs index eb27b6b7..802be212 100644 --- a/crates/windows-platform-probes/src/bin/peer_index_cache.rs +++ b/crates/windows-platform-probes/src/bin/peer_index_cache.rs @@ -6,20 +6,35 @@ //! and are not for production use. Do not call them from production code, and //! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +use std::fmt::Write as _; use windows_placement_probe::peer_index_cache::{CAPACITY, ITEMS, Strategy, measure}; +use windows_platform_probes::report::{Stdout, emit}; fn main() { + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); windows_placement_probe::fingerprint::print_banner(); - println!("== what does caching the peer's index buy an SPSC ring? ==\n"); + let _ = writeln!( + out, + "== what does caching the peer's index buy an SPSC ring? ==\n" + ); let observation = measure(); - println!( + let _ = writeln!( + out, "{:<24} {:>10} {:>14} {:>14} {:>14}", "configuration", "ns/item", "items/sec", "cons. reads", "prod. reads" ); for run in std::iter::once(&observation.calibration).chain(&observation.strategies) { - println!( + let _ = writeln!( + out, "{:<24} {:>10.1} {:>14.0} {:>14} {:>14}", run.label, run.nanos_per_item, @@ -28,7 +43,8 @@ fn main() { run.producer_refreshes ); } - println!( + let _ = writeln!( + out, " ({ITEMS} items, capacity {CAPACITY}. The two read columns count how often each \ @@ -37,17 +53,18 @@ fn main() { technique exists to avoid touching.)" ); - println!("\ninterpretation:\n"); + let _ = writeln!(out, "\ninterpretation:\n"); let Some(baseline) = observation.get(Strategy::Baseline) else { - return; + return out; }; // The model has to reproduce the shipping queue before anything it says // about variants is worth reading. let drift = (baseline.nanos_per_item - observation.calibration.nanos_per_item).abs() / observation.calibration.nanos_per_item; - println!( + let _ = writeln!( + out, " calibration: the model's baseline differs from the shipping spsc by \ {:.0}% ({:.1} vs {:.1} ns/item).", @@ -56,16 +73,40 @@ fn main() { observation.calibration.nanos_per_item ); if drift > 0.25 { - println!(" CAUTION: that is a wide gap, so the rows below describe the MODEL"); - println!(" and not the shipping queue. The model has only the ring mechanics;"); - println!(" the shipping push also consults the reservation count, updates the"); - println!(" depth metric and rings the doorbell. This probe does NOT attribute"); - println!(" the gap between those, and no such attribution should be read into"); - println!(" it. What the gap does establish is a floor: whatever the shared"); - println!(" read costs, it is a minority of what the shipping queue spends per"); - println!(" item, so removing it cannot be the large win."); + let _ = writeln!( + out, + " CAUTION: that is a wide gap, so the rows below describe the MODEL" + ); + let _ = writeln!( + out, + " and not the shipping queue. The model has only the ring mechanics;" + ); + let _ = writeln!( + out, + " the shipping push also consults the reservation count, updates the" + ); + let _ = writeln!( + out, + " depth metric and rings the doorbell. This probe does NOT attribute" + ); + let _ = writeln!( + out, + " the gap between those, and no such attribution should be read into" + ); + let _ = writeln!( + out, + " it. What the gap does establish is a floor: whatever the shared" + ); + let _ = writeln!( + out, + " read costs, it is a minority of what the shipping queue spends per" + ); + let _ = writeln!(out, " item, so removing it cannot be the large win."); } else { - println!(" Close enough to treat the model as a stand-in for the real ring."); + let _ = writeln!( + out, + " Close enough to treat the model as a stand-in for the real ring." + ); } for strategy in [Strategy::Cached, Strategy::Warmed] { @@ -73,7 +114,8 @@ fn main() { continue; }; let speedup = baseline.nanos_per_item / run.nanos_per_item; - println!( + let _ = writeln!( + out, "\n {:<22} {:.2}x the baseline ({:.1} -> {:.1} ns/item)", match strategy { Strategy::Cached => "peer-index caching:", @@ -98,7 +140,7 @@ fn main() { // states its finding regardless of what it measured is worse than no // instrument, because it is believed. let Some(cached) = observation.get(Strategy::Cached) else { - return; + return out; }; // The batch depth is the mechanism, so compute it rather than assert it: it @@ -112,70 +154,155 @@ fn main() { baseline.producer_refreshes as f64 / cached.producer_refreshes.max(1) as f64; let speedup = baseline.nanos_per_item / cached.nanos_per_item; - println!(); - println!(" how far each shared read was amortised, with caching on:"); - println!( + let _ = writeln!(out); + let _ = writeln!( + out, + " how far each shared read was amortised, with caching on:" + ); + let _ = writeln!( + out, " consumer: {consumer_batch:.1} items per read ({consumer_reduction:.1}x fewer reads than baseline)" ); - println!( + let _ = writeln!( + out, " producer: {producer_batch:.1} items per read ({producer_reduction:.1}x fewer reads than baseline)" ); - println!(); + let _ = writeln!(out); let engaged = consumer_reduction > 1.5; if !engaged { - println!(" The technique did NOT engage: the consumer's shared reads barely"); - println!(" moved. Any throughput difference below is noise about something"); - println!(" else, and says nothing about peer-index caching."); + let _ = writeln!( + out, + " The technique did NOT engage: the consumer's shared reads barely" + ); + let _ = writeln!( + out, + " moved. Any throughput difference below is noise about something" + ); + let _ = writeln!(out, " else, and says nothing about peer-index caching."); } else if speedup >= 1.1 { - println!(" The technique engaged AND won, by {speedup:.2}x."); - println!(" Peer-index caching trades freshness for fewer reads, and that"); - println!(" trade pays when the batch it amortises over is deep. At the"); - println!(" depths above it is paying."); + let _ = writeln!(out, " The technique engaged AND won, by {speedup:.2}x."); + let _ = writeln!( + out, + " Peer-index caching trades freshness for fewer reads, and that" + ); + let _ = writeln!( + out, + " trade pays when the batch it amortises over is deep. At the" + ); + let _ = writeln!(out, " depths above it is paying."); } else if speedup <= 0.9 { - println!(" The technique engaged and still LOST, at {speedup:.2}x the baseline."); - println!(" This is a real result about the shape rather than a failed"); - println!(" implementation. Caching trades freshness for fewer reads; at the"); - println!(" batch depths above, each side idles on a stale bound it could"); - println!(" have refreshed, and that idling costs more than the reads saved."); + let _ = writeln!( + out, + " The technique engaged and still LOST, at {speedup:.2}x the baseline." + ); + let _ = writeln!( + out, + " This is a real result about the shape rather than a failed" + ); + let _ = writeln!( + out, + " implementation. Caching trades freshness for fewer reads; at the" + ); + let _ = writeln!( + out, + " batch depths above, each side idles on a stale bound it could" + ); + let _ = writeln!( + out, + " have refreshed, and that idling costs more than the reads saved." + ); if producer_reduction < 1.0 { - println!(" Note the producer count went UP: a cached index is consulted"); - println!(" only when it says 'no room', so a blocked producer refreshes on"); - println!(" every spin and gains nothing."); + let _ = writeln!( + out, + " Note the producer count went UP: a cached index is consulted" + ); + let _ = writeln!( + out, + " only when it says 'no room', so a blocked producer refreshes on" + ); + let _ = writeln!(out, " every spin and gains nothing."); } } else { - println!(" The technique engaged and changed throughput by {speedup:.2}x, which"); - println!(" is inside the noise of this probe. Treat it as no effect."); + let _ = writeln!( + out, + " The technique engaged and changed throughput by {speedup:.2}x, which" + ); + let _ = writeln!( + out, + " is inside the noise of this probe. Treat it as no effect." + ); } - println!(); - println!(" BATCH DEPTH IS THE VARIABLE, AND IT IS NOT A CONSTANT OF THE CODE."); - println!(" It depends on how the producer and consumer interleave, which"); - println!(" depends on the host: core count, whether siblings share a core,"); - println!(" and how the scheduler places the two threads. The same binary has"); - println!(" measured a depth near 1 on one machine and in the hundreds on"); - println!(" another, and the verdict inverted with it. Do not carry a"); - println!(" conclusion from one host to another -- run it on the host you"); - println!(" intend to make the decision for."); + let _ = writeln!(out); + let _ = writeln!( + out, + " BATCH DEPTH IS THE VARIABLE, AND IT IS NOT A CONSTANT OF THE CODE." + ); + let _ = writeln!( + out, + " It depends on how the producer and consumer interleave, which" + ); + let _ = writeln!( + out, + " depends on the host: core count, whether siblings share a core," + ); + let _ = writeln!( + out, + " and how the scheduler places the two threads. The same binary has" + ); + let _ = writeln!( + out, + " measured a depth near 1 on one machine and in the hundreds on" + ); + let _ = writeln!( + out, + " another, and the verdict inverted with it. Do not carry a" + ); + let _ = writeln!( + out, + " conclusion from one host to another -- run it on the host you" + ); + let _ = writeln!(out, " intend to make the decision for."); let Some(warmed) = observation.get(Strategy::Warmed) else { - return; + return out; }; let warm_reduction = baseline.consumer_refreshes as f64 / warmed.consumer_refreshes.max(1) as f64; - println!(); - println!( + let _ = writeln!(out); + let _ = writeln!( + out, " control (warming load): {:.2}x throughput, {:.2}x fewer consumer reads.", baseline.nanos_per_item / warmed.nanos_per_item, warm_reduction ); if warm_reduction < 1.5 { - println!(" It removed no shared read, which is what a control should do. A"); - println!(" discarded load cannot help: the authoritative load still happens,"); - println!(" and in a tight handoff loop the prefetch has no time to land."); - println!(" So the technique works by REMOVING the load, not by warming it."); + let _ = writeln!( + out, + " It removed no shared read, which is what a control should do. A" + ); + let _ = writeln!( + out, + " discarded load cannot help: the authoritative load still happens," + ); + let _ = writeln!( + out, + " and in a tight handoff loop the prefetch has no time to land." + ); + let _ = writeln!( + out, + " So the technique works by REMOVING the load, not by warming it." + ); } else { - println!(" UNEXPECTED: the control removed shared reads, so it is not acting"); - println!(" as a control. Distrust the comparison above until that is explained."); + let _ = writeln!( + out, + " UNEXPECTED: the control removed shared reads, so it is not acting" + ); + let _ = writeln!( + out, + " as a control. Distrust the comparison above until that is explained." + ); } + out } diff --git a/crates/windows-platform-probes/src/bin/pool_growth.rs b/crates/windows-platform-probes/src/bin/pool_growth.rs index 88ad8fe6..892b4d11 100644 --- a/crates/windows-platform-probes/src/bin/pool_growth.rs +++ b/crates/windows-platform-probes/src/bin/pool_growth.rs @@ -11,57 +11,89 @@ //! exceed it, and gets there promptly); this binary prints the numbers, which //! is what a new architecture actually needs to be re-measured against. +use std::fmt::Write as _; use windows_platform_probes::pool_growth::{measure_growth, measure_raise_while_saturated}; +use windows_platform_probes::report::{Stdout, emit}; -fn report(label: &str, maximum: u32, submissions: usize, runs_long: bool) { +/// Measure one configuration and append its block to `out`. +/// +/// Takes the buffer rather than printing: a helper that wrote to stdout while +/// its caller composed a string would emit its lines *before* the caller's, +/// reordering the report even though every line still appeared. +fn report(out: &mut String, label: &str, maximum: u32, submissions: usize, runs_long: bool) { let observed = measure_growth(maximum, submissions, runs_long); - println!("{label}"); - println!( + let _ = writeln!(out, "{label}"); + let _ = writeln!( + out, " max {} / submitted {} -> started while blocked {}, distinct threads {}", observed.maximum, observed.submitted, observed.started_while_blocked, observed.distinct_threads ); - println!( + let _ = writeln!( + out, " saturated {} | one thread each {} | slowest arrival {:?}", observed.saturated(), observed.one_thread_each(), observed.slowest_arrival() ); - println!(" arrivals (us): {:?}", observed.arrivals_us); + let _ = writeln!(out, " arrivals (us): {:?}", observed.arrivals_us); } fn main() { - println!("== how a blocked pool grows ==\n"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); + let _ = writeln!(out, "== how a blocked pool grows ==\n"); - report("P1 growth curve, max 4:", 4, 8, false); - println!(); - report("P1 growth curve, max 8:", 8, 16, false); - println!(); - report("P2 the same, runs-long:", 4, 8, true); - println!(); + report(&mut out, "P1 growth curve, max 4:", 4, 8, false); + let _ = writeln!(out); + report(&mut out, "P1 growth curve, max 8:", 8, 16, false); + let _ = writeln!(out); + report(&mut out, "P2 the same, runs-long:", 4, 8, true); + let _ = writeln!(out); let raise = measure_raise_while_saturated(2, 6, 8); - println!("P3 raise while saturated (2 -> 6):"); + let _ = writeln!(out, "P3 raise while saturated (2 -> 6):"); if raise.saturated_before_raise() { - println!( + let _ = writeln!( + out, " extra work started {:?} after the maximum was raised", raise.delay ); } else { - println!( + let _ = writeln!( + out, " NOT saturated before the raise ({} of {} started), so the delay", raise.started_before_raise, raise.base_max ); - println!(" below times growth toward the base maximum, not the raise."); - println!(" elapsed: {:?}", raise.delay); + let _ = writeln!( + out, + " below times growth toward the base maximum, not the raise." + ); + let _ = writeln!(out, " elapsed: {:?}", raise.delay); } if !raise.took_effect { - println!(" the settle window expired with no extra callback started."); + let _ = writeln!( + out, + " the settle window expired with no extra callback started." + ); } - println!("\nnote: every number here is from this host and this Windows build."); - println!("Re-run on another architecture rather than assuming they carry over."); + let _ = writeln!( + out, + "\nnote: every number here is from this host and this Windows build." + ); + let _ = writeln!( + out, + "Re-run on another architecture rather than assuming they carry over." + ); + out } diff --git a/crates/windows-platform-probes/src/bin/queue_contention.rs b/crates/windows-platform-probes/src/bin/queue_contention.rs index 40a52d35..c2a3943d 100644 --- a/crates/windows-platform-probes/src/bin/queue_contention.rs +++ b/crates/windows-platform-probes/src/bin/queue_contention.rs @@ -10,29 +10,47 @@ //! linked and sharded MPSC shapes are ever needed, and whether `mpsc` and //! `reserving_mpsc` should merge. See `queue_contention`'s module docs. +use std::fmt::Write as _; use windows_platform_probes::queue_contention::{PRODUCER_COUNTS, Run, measure, shapes}; +use windows_platform_probes::report::{Stdout, emit}; fn main() { + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); windows_placement_probe::fingerprint::print_banner(); - println!("== does the array queue's tail claim contend? ==\n"); + let _ = writeln!(out, "== does the array queue's tail claim contend? ==\n"); let observation = measure(); - println!( + let _ = writeln!( + out, "host reports {} logical processors\n", observation.logical_processors ); - println!("-- isolated: producers only, capacity large enough that nothing is refused --"); - print_table(&observation.isolated); + let _ = writeln!( + out, + "-- isolated: producers only, capacity large enough that nothing is refused --" + ); + render_table(&mut out, &observation.isolated); - println!("\n-- drained: a consumer popping continuously, capacity 1024 --"); - print_table(&observation.drained); + let _ = writeln!( + out, + "\n-- drained: a consumer popping continuously, capacity 1024 --" + ); + render_table(&mut out, &observation.drained); - println!("\ninterpretation:\n"); + let _ = writeln!(out, "\ninterpretation:\n"); // Question 1: does the claim collapse as producers are added? - println!(" 1. tail-claim contention (isolated regime)\n"); - println!( + let _ = writeln!(out, " 1. tail-claim contention (isolated regime)\n"); + let _ = writeln!( + out, " {:<18} {:>12} {:>12} {:>12} {:>14}", "producers", "slotwise x1thr", "reserving", "permit", "atomic floor" ); @@ -43,7 +61,8 @@ fn main() { let permit = observation.scaling(&observation.isolated, shapes::PERMIT_MPSC, producers); let floor = observation.scaling(&observation.isolated, shapes::BASELINE_FETCH_ADD, producers); - println!( + let _ = writeln!( + out, " {producers:<18} {:>12} {:>12} {:>12} {:>14}", format_scaling(mpsc), format_scaling(reserving), @@ -51,15 +70,34 @@ fn main() { format_scaling(floor) ); } - println!("\n Read as: throughput at N producers divided by throughput at one."); - println!(" 1.00 means N threads together push no faster than one did."); - println!(" The atomic floor is the cheapest possible contended operation,"); - println!(" so it says how much of any curve is the queue and how much is"); - println!(" simply what this processor does to a fought-over cache line."); + let _ = writeln!( + out, + "\n Read as: throughput at N producers divided by throughput at one." + ); + let _ = writeln!( + out, + " 1.00 means N threads together push no faster than one did." + ); + let _ = writeln!( + out, + " The atomic floor is the cheapest possible contended operation," + ); + let _ = writeln!( + out, + " so it says how much of any curve is the queue and how much is" + ); + let _ = writeln!( + out, + " simply what this processor does to a fought-over cache line." + ); // Question 2: what does reserving_mpsc's read of `head` actually cost? - println!("\n 2. the price of reservation (drained regime, where `head` is written)\n"); - println!( + let _ = writeln!( + out, + "\n 2. the price of reservation (drained regime, where `head` is written)\n" + ); + let _ = writeln!( + out, " {:<18} {:>14} {:>14} {:>10} {:>14} {:>16}", "producers", "slotwise ns/pu", "reserving", "ratio", "permit", "permit/reserving" ); @@ -72,7 +110,8 @@ fn main() { // shipping shape it would replace. Below 1.00 means the permit claim is // cheaper; above means removing the room-decision race costs throughput. let permit_ratio = format_ratio(permit, reserving); - println!( + let _ = writeln!( + out, " {producers:<18} {:>14} {:>14} {:>10} {:>14} {:>16}", format_nanos(plain), format_nanos(reserving), @@ -81,31 +120,81 @@ fn main() { permit_ratio ); } - println!("\n `reserving_mpsc` reads the consumer's position on every push and"); - println!(" `mpsc` does not, which is the entire reason they ship as two"); - println!(" shapes. This regime is the one that can price that read, because"); - println!(" a consumer is writing the line being read."); - println!("\n `permit_mpsc` is experimental and is the candidate replacement"); - println!(" for `reserving_mpsc`: it removes that read entirely, and with it"); - println!(" the stale room decision behind SH-14.1, by making admission a"); - println!(" read-modify-write on a permit count instead. The last column is"); - println!(" the trade -- below 1.00 and the safer claim is also the cheaper"); - println!(" one; above 1.00 and closing the hole costs throughput."); + let _ = writeln!( + out, + "\n `reserving_mpsc` reads the consumer's position on every push and" + ); + let _ = writeln!( + out, + " `mpsc` does not, which is the entire reason they ship as two" + ); + let _ = writeln!( + out, + " shapes. This regime is the one that can price that read, because" + ); + let _ = writeln!(out, " a consumer is writing the line being read."); + let _ = writeln!( + out, + "\n `permit_mpsc` is experimental and is the candidate replacement" + ); + let _ = writeln!( + out, + " for `reserving_mpsc`: it removes that read entirely, and with it" + ); + let _ = writeln!( + out, + " the stale room decision behind SH-14.1, by making admission a" + ); + let _ = writeln!( + out, + " read-modify-write on a permit count instead. The last column is" + ); + let _ = writeln!( + out, + " the trade -- below 1.00 and the safer claim is also the cheaper" + ); + let _ = writeln!( + out, + " one; above 1.00 and closing the hole costs throughput." + ); - println!("\n CAUTION: the drained regime has ONE consumer, because that is what"); - println!(" MPSC means. At high producer counts it is expected to become"); - println!(" consumer-bound, and a plateau there says nothing about the claim."); - println!(" The refusal counts above are what make that visible: a run with"); - println!(" many refusals was waiting for the consumer, not for the tail."); + let _ = writeln!( + out, + "\n CAUTION: the drained regime has ONE consumer, because that is what" + ); + let _ = writeln!( + out, + " MPSC means. At high producer counts it is expected to become" + ); + let _ = writeln!( + out, + " consumer-bound, and a plateau there says nothing about the claim." + ); + let _ = writeln!( + out, + " The refusal counts above are what make that visible: a run with" + ); + let _ = writeln!( + out, + " many refusals was waiting for the consumer, not for the tail." + ); + out } -fn print_table(runs: &[Run]) { - println!( +/// Append one regime's table to `out`. +/// +/// Takes the buffer rather than printing, for the reason `pool_growth`'s twin +/// records: a helper writing to stdout while its caller composes a string emits +/// its lines first, reordering the report without losing any of it. +fn render_table(out: &mut String, runs: &[Run]) { + let _ = writeln!( + out, "{:<18} {:>10} {:>14} {:>16} {:>14}", "shape", "producers", "ns/push", "pushes/sec", "refusals" ); for run in runs { - println!( + let _ = writeln!( + out, "{:<18} {:>10} {:>14.1} {:>16.0} {:>14}", run.shape, run.producers, run.nanos_per_push, run.pushes_per_second, run.refusals ); diff --git a/crates/windows-platform-probes/src/bin/request_cost.rs b/crates/windows-platform-probes/src/bin/request_cost.rs index 690f216c..ceaec81a 100644 --- a/crates/windows-platform-probes/src/bin/request_cost.rs +++ b/crates/windows-platform-probes/src/bin/request_cost.rs @@ -10,6 +10,8 @@ //! Read alongside `probe-doorbell-cost`: together they say whether the queue's //! mechanics or the request's allocation model deserves the attention. +use std::fmt::Write as _; +use windows_platform_probes::report::{Stdout, emit}; use windows_platform_probes::request_cost::measure; /// Measured by `probe-doorbell-cost` on the same machine. Restated here only to @@ -19,16 +21,26 @@ const DOORBELL_NS_REFERENCE: f64 = 164.9; const ATOMIC_NS_REFERENCE: f64 = 7.2; fn main() { - println!("== what does a namespace request cost to build? ==\n"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); + let _ = writeln!(out, "== what does a namespace request cost to build? ==\n"); let observation = measure(); - println!( + let _ = writeln!( + out, "{:<26} {:>10} {:>14} {:>16}", "operation", "ns/op", "x an atomic", "x a doorbell" ); for timing in &observation.timings { - println!( + let _ = writeln!( + out, "{:<26} {:>10.1} {:>14.1} {:>16.2}", timing.label, timing.nanos_per_op, @@ -36,13 +48,14 @@ fn main() { timing.nanos_per_op / DOORBELL_NS_REFERENCE, ); } - println!( + let _ = writeln!( + out, "\n(ratios use the reference doorbell {DOORBELL_NS_REFERENCE:.1} ns and atomic \ {ATOMIC_NS_REFERENCE:.1} ns measured\n by probe-doorbell-cost on the development \ machine; re-read that probe on this host\n before trusting them)" ); - println!("\ninterpretation:"); + let _ = writeln!(out, "\ninterpretation:"); let build = observation.get("build_open_request"); let capture = observation.get("capture_handle"); @@ -54,53 +67,111 @@ fn main() { // development machine. This probe now runs on hosted CI runners, which // are a heterogeneous fleet, so a ratio printed as though both halves // were local can be wrong even when the measurement is sound. - println!( + let _ = writeln!( + out, " building a pathed request costs {build:.0} ns, which is {:.1}x one", build / DOORBELL_NS_REFERENCE ); - println!( + let _ = writeln!( + out, " doorbell AS MEASURED ON THE DEVELOPMENT MACHINE ({DOORBELL_NS_REFERENCE:.1} ns)," ); - println!(" not on this one. Run probe-doorbell-cost here to make the ratio local."); - println!(); - println!(" SCOPE, because this is easy to over-read: that is a statement about"); - println!(" ONE OPERATION TYPE, not about the queue. A namespace open is the"); - println!(" heaviest payload the queue carries -- it resolves a path through"); - println!(" Win32 and may duplicate a handle -- and it ends in a CreateFileW"); - println!(" costing microseconds regardless. A registered-buffer read, which is"); - println!(" the hot path, carries no path and no handle: its descriptor is a slot"); - println!(" index and an offset, and there the queue's own mechanics are the"); - println!(" whole cost."); - println!(); - println!(" Nor is per-operation overhead the same thing as queue efficiency."); - println!(" Throughput under contention, cache behaviour, batching amortization"); - println!(" and backpressure decide that, and a single uncontended construction"); - println!(" time measures none of them."); - println!(); - println!(" What it does support: for an open-heavy workload, doorbell tuning"); - println!(" would be optimizing the small half. That is a finding about"); - println!(" OPERATION MIX, and it says nothing about the read path."); + let _ = writeln!( + out, + " not on this one. Run probe-doorbell-cost here to make the ratio local." + ); + let _ = writeln!(out); + let _ = writeln!( + out, + " SCOPE, because this is easy to over-read: that is a statement about" + ); + let _ = writeln!( + out, + " ONE OPERATION TYPE, not about the queue. A namespace open is the" + ); + let _ = writeln!( + out, + " heaviest payload the queue carries -- it resolves a path through" + ); + let _ = writeln!( + out, + " Win32 and may duplicate a handle -- and it ends in a CreateFileW" + ); + let _ = writeln!( + out, + " costing microseconds regardless. A registered-buffer read, which is" + ); + let _ = writeln!( + out, + " the hot path, carries no path and no handle: its descriptor is a slot" + ); + let _ = writeln!( + out, + " index and an offset, and there the queue's own mechanics are the" + ); + let _ = writeln!(out, " whole cost."); + let _ = writeln!(out); + let _ = writeln!( + out, + " Nor is per-operation overhead the same thing as queue efficiency." + ); + let _ = writeln!( + out, + " Throughput under contention, cache behaviour, batching amortization" + ); + let _ = writeln!( + out, + " and backpressure decide that, and a single uncontended construction" + ); + let _ = writeln!(out, " time measures none of them."); + let _ = writeln!(out); + let _ = writeln!( + out, + " What it does support: for an open-heavy workload, doorbell tuning" + ); + let _ = writeln!( + out, + " would be optimizing the small half. That is a finding about" + ); + let _ = writeln!( + out, + " OPERATION MIX, and it says nothing about the read path." + ); } if let Some(capture) = capture { - println!("\n duplicating a handle costs {capture:.0} ns -- a kernel transition, not"); - println!(" a memory copy, and easy to under-count when thinking about what an"); - println!(" SQE holds."); + let _ = writeln!( + out, + "\n duplicating a handle costs {capture:.0} ns -- a kernel transition, not" + ); + let _ = writeln!( + out, + " a memory copy, and easy to under-count when thinking about what an" + ); + let _ = writeln!(out, " SQE holds."); if let Some(build) = build { if capture > build { - println!( + let _ = writeln!( + out, " It is {:.1}x the cost of building the pathed request itself, so a", capture / build ); - println!(" request carrying a handle is dominated by the duplication, and"); - println!(" any allocation tuning on the path would be optimizing the wrong"); - println!(" half."); + let _ = writeln!( + out, + " request carrying a handle is dominated by the duplication, and" + ); + let _ = writeln!( + out, + " any allocation tuning on the path would be optimizing the wrong" + ); + let _ = writeln!(out, " half."); } else { - println!( + let _ = writeln!( + out, " It is {:.2}x the pathed request, so the two are comparable and", capture / build ); - println!(" neither dominates."); + let _ = writeln!(out, " neither dominates."); } } } @@ -110,18 +181,43 @@ fn main() { && let Some(clone) = observation.get("clone_prepared_units") && build > clone { - println!("\n WHERE THE TIME ACTUALLY GOES, and it is not the allocator:"); - println!(" `prepare` calls GetFullPathNameW to resolve the path against the"); - println!(" process working directory -- a Win32 call, because the CWD is mutable"); - println!(" by any thread and resolving later would be racy. So most of the cost"); - println!(" above is a syscall that no allocation scheme can remove."); - println!(" Cloning already-prepared units is {clone:.0} ns, which bounds what an"); - println!( + let _ = writeln!( + out, + "\n WHERE THE TIME ACTUALLY GOES, and it is not the allocator:" + ); + let _ = writeln!( + out, + " `prepare` calls GetFullPathNameW to resolve the path against the" + ); + let _ = writeln!( + out, + " process working directory -- a Win32 call, because the CWD is mutable" + ); + let _ = writeln!( + out, + " by any thread and resolving later would be racy. So most of the cost" + ); + let _ = writeln!( + out, + " above is a syscall that no allocation scheme can remove." + ); + let _ = writeln!( + out, + " Cloning already-prepared units is {clone:.0} ns, which bounds what an" + ); + let _ = writeln!( + out, " inline-storage or recycling scheme could recover at {:.0} ns per request", build - clone ); - println!(" AT MOST -- and only for a caller that can reuse a resolved path."); - println!(" A caller with a fresh path each time pays the resolution regardless."); + let _ = writeln!( + out, + " AT MOST -- and only for a caller that can reuse a resolved path." + ); + let _ = writeln!( + out, + " A caller with a fresh path each time pays the resolution regardless." + ); } let get = |label: &str| { @@ -129,7 +225,8 @@ fn main() { .get(label) .map_or("null".to_string(), |n| format!("{n:.1}")) }; - println!( + let _ = writeln!( + out, concat!( r#"{{"reason":"x-probe-request-cost","arch":"{}","prepare_short_ns":{},"#, r#""prepare_long_ns":{},"build_open_request_ns":{},"#, @@ -142,4 +239,5 @@ fn main() { get("clone_prepared_units"), get("capture_handle"), ); + out } diff --git a/crates/windows-platform-probes/src/bin/topology.rs b/crates/windows-platform-probes/src/bin/topology.rs index 5e4de388..3c4f5461 100644 --- a/crates/windows-platform-probes/src/bin/topology.rs +++ b/crates/windows-platform-probes/src/bin/topology.rs @@ -13,29 +13,53 @@ //! JSON object so those results can be mined out of build logs mechanically //! rather than read by eye. +use std::fmt::Write as _; +use windows_platform_probes::report::{Stdout, emit}; use windows_platform_probes::topology::measure; fn main() { - println!("== processor topology, and what each partitioning policy would yield ==\n"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "== processor topology, and what each partitioning policy would yield ==\n" + ); let observation = match measure() { Ok(observation) => observation, Err(error) => { - println!("Topology::discover failed: {error}"); - println!("(Reported rather than measured: a probe that cannot read its"); - println!("subject must say so instead of printing a misleading shape.)"); - return; + let _ = writeln!(out, "Topology::discover failed: {error}"); + let _ = writeln!( + out, + "(Reported rather than measured: a probe that cannot read its" + ); + let _ = writeln!( + out, + "subject must say so instead of printing a misleading shape.)" + ); + return out; } }; - println!("processors (online) : {}", observation.online_processors); - println!("processor groups : {}", observation.groups); - println!("packages : {}", observation.packages); - println!( + let _ = writeln!( + out, + "processors (online) : {}", + observation.online_processors + ); + let _ = writeln!(out, "processor groups : {}", observation.groups); + let _ = writeln!(out, "packages : {}", observation.packages); + let _ = writeln!( + out, "NUMA domains : {} ({} with no processors)", observation.numa_domains, observation.memoryless_numa_domains ); - println!("physical cores : {}", observation.cores.len()); + let _ = writeln!(out, "physical cores : {}", observation.cores.len()); let smt = observation .cores @@ -49,65 +73,102 @@ fn main() { .collect(); classes.sort_unstable(); classes.dedup(); - println!(" cores with SMT : {smt}"); - println!(" efficiency classes: {classes:?}"); + let _ = writeln!(out, " cores with SMT : {smt}"); + let _ = writeln!(out, " efficiency classes: {classes:?}"); if classes.len() > 1 { - println!(" (heterogeneous: an I/O thread left unconstrained can land on an"); - println!(" efficiency core, which is why even a single domain wants a mask)"); + let _ = writeln!( + out, + " (heterogeneous: an I/O thread left unconstrained can land on an" + ); + let _ = writeln!( + out, + " efficiency core, which is why even a single domain wants a mask)" + ); } - println!("\ncaches:"); + let _ = writeln!(out, "\ncaches:"); if observation.caches.is_empty() { - println!(" none reported"); + let _ = writeln!(out, " none reported"); } for cache in &observation.caches { - println!( + let _ = writeln!( + out, " L{:<2} {:>3} domain(s), processors per domain: {:?}", cache.level, cache.domains, cache.processors_per_domain ); } match observation.outermost_partitioning_cache() { - Some(cache) => println!( - "\noutermost cache that partitions this machine: L{} ({} domains)", - cache.level, cache.domains - ), - None => println!("\nno cache level partitions this machine: every level is machine-wide"), + Some(cache) => { + let _ = writeln!( + out, + "\noutermost cache that partitions this machine: L{} ({} domains)", + cache.level, cache.domains + ); + } + None => { + let _ = writeln!( + out, + "\nno cache level partitions this machine: every level is machine-wide" + ); + } } if !observation.caches.iter().any(|c| c.level == 3) { - println!("NOTE: this machine reports no L3 at all, so a policy keyed literally"); - println!("on \"L3\" would find nothing here. That is the measured case behind"); - println!("phrasing the rule as \"the outermost level that partitions\"."); + let _ = writeln!( + out, + "NOTE: this machine reports no L3 at all, so a policy keyed literally" + ); + let _ = writeln!( + out, + "on \"L3\" would find nothing here. That is the measured case behind" + ); + let _ = writeln!( + out, + "phrasing the rule as \"the outermost level that partitions\"." + ); } - println!("\ndomains each policy would produce:"); + let _ = writeln!(out, "\ndomains each policy would produce:"); for (name, count) in observation.domain_counts() { - println!(" {name:<34} {count}"); + let _ = writeln!(out, " {name:<34} {count}"); } - println!("\ncross-check against independently read Win32 counters:"); - println!( + let _ = writeln!( + out, + "\ncross-check against independently read Win32 counters:" + ); + let _ = writeln!( + out, " GetActiveProcessorCount : {}", observation.raw_active_processors ); - println!( + let _ = writeln!( + out, " GetActiveProcessorGroupCount: {}", observation.raw_group_count ); match observation.raw_highest_numa_node { - Some(highest) => println!( - " GetNumaHighestNodeNumber : {highest} (so {} nodes)", - highest + 1 - ), - None => println!(" GetNumaHighestNodeNumber : failed"), + Some(highest) => { + let _ = writeln!( + out, + " GetNumaHighestNodeNumber : {highest} (so {} nodes)", + highest + 1 + ); + } + None => { + let _ = writeln!(out, " GetNumaHighestNodeNumber : failed"); + } } let complaints = observation.cross_check(); if complaints.is_empty() { - println!(" => agree. windows-topology-sys parsed this machine consistently."); + let _ = writeln!( + out, + " => agree. windows-topology-sys parsed this machine consistently." + ); } else { - println!(" => DISAGREE. This is a finding, not a nuisance:"); + let _ = writeln!(out, " => DISAGREE. This is a finding, not a nuisance:"); for complaint in &complaints { - println!(" - {complaint}"); + let _ = writeln!(out, " - {complaint}"); } } @@ -123,7 +184,8 @@ fn main() { .into_iter() .map(|(name, count)| format!(r#""{name}":{count}"#)) .collect(); - println!( + let _ = writeln!( + out, concat!( r#"{{"reason":"x-probe-topology","arch":"{}","processors":{},"groups":{},"#, r#""packages":{},"numa_domains":{},"memoryless_numa_domains":{},"cores":{},"#, @@ -145,4 +207,5 @@ fn main() { policy_json.join(","), complaints.is_empty(), ); + out } diff --git a/crates/windows-platform-probes/src/bin/worker_context.rs b/crates/windows-platform-probes/src/bin/worker_context.rs index d51ff3ab..c6c8aaef 100644 --- a/crates/windows-platform-probes/src/bin/worker_context.rs +++ b/crates/windows-platform-probes/src/bin/worker_context.rs @@ -12,55 +12,88 @@ //! measurement can be eyeballed on a new host or a new Windows build without //! reading a test's output. +use std::fmt::Write as _; +use windows_platform_probes::report::{Stdout, emit}; use windows_platform_probes::worker_context::{ observe_on_worker, observe_on_worker_while_impersonating, }; fn main() { - println!("== what a thread-pool worker is handed ==\n"); + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit(&mut Stdout, &render()); +} + +/// The probe's whole report, as text. +fn render() -> String { + let mut out = String::new(); + let _ = writeln!(out, "== what a thread-pool worker is handed ==\n"); let plain = observe_on_worker(); - println!("submitted with no impersonation:"); - println!(" has thread token : {}", plain.has_thread_token); - println!(" OpenThreadToken err: {}", plain.open_token_error); - println!(" thread error mode : {:#06x}", plain.error_mode); - println!(" -> unimpersonated : {}", plain.is_unimpersonated()); - println!( + let _ = writeln!(out, "submitted with no impersonation:"); + let _ = writeln!(out, " has thread token : {}", plain.has_thread_token); + let _ = writeln!(out, " OpenThreadToken err: {}", plain.open_token_error); + let _ = writeln!(out, " thread error mode : {:#06x}", plain.error_mode); + let _ = writeln!(out, " -> unimpersonated : {}", plain.is_unimpersonated()); + let _ = writeln!( + out, " -> critical-error handler enabled: {}", plain.critical_error_handler_enabled() ); let impersonating = observe_on_worker_while_impersonating(); let worker = impersonating.worker; - println!( + let _ = writeln!( + out, " submitted WHILE the submitter impersonates:" ); - println!( + let _ = writeln!( + out, " submitter has token: {}", impersonating.submitter.has_thread_token ); - println!(" worker has token : {}", worker.has_thread_token); - println!(" OpenThreadToken err: {}", worker.open_token_error); - println!(" thread error mode : {:#06x}", worker.error_mode); - println!(" -> unimpersonated : {}", worker.is_unimpersonated()); - println!(" -> they disagree : {}", impersonating.disagree()); + let _ = writeln!(out, " worker has token : {}", worker.has_thread_token); + let _ = writeln!(out, " OpenThreadToken err: {}", worker.open_token_error); + let _ = writeln!(out, " thread error mode : {:#06x}", worker.error_mode); + let _ = writeln!(out, " -> unimpersonated : {}", worker.is_unimpersonated()); + let _ = writeln!(out, " -> they disagree : {}", impersonating.disagree()); - println!( + let _ = writeln!( + out, " conclusion:" ); if impersonating.disagree() { - println!(" a worker does NOT inherit the submitter's token, so identity"); - println!(" must be captured and applied explicitly."); + let _ = writeln!( + out, + " a worker does NOT inherit the submitter's token, so identity" + ); + let _ = writeln!(out, " must be captured and applied explicitly."); } else { - println!(" UNEXPECTED: the worker inherited a token. The ambient crate's"); - println!(" premise no longer holds and its design needs revisiting."); + let _ = writeln!( + out, + " UNEXPECTED: the worker inherited a token. The ambient crate's" + ); + let _ = writeln!( + out, + " premise no longer holds and its design needs revisiting." + ); } if plain.critical_error_handler_enabled() { - println!(" a worker's critical-error handler is ENABLED, so a hard device"); - println!(" error can put a modal dialog on shared infrastructure."); + let _ = writeln!( + out, + " a worker's critical-error handler is ENABLED, so a hard device" + ); + let _ = writeln!( + out, + " error can put a modal dialog on shared infrastructure." + ); } else { - println!(" UNEXPECTED: a worker starts with the handler suppressed."); + let _ = writeln!( + out, + " UNEXPECTED: a worker starts with the handler suppressed." + ); } + out } diff --git a/crates/windows-platform-probes/src/report.rs b/crates/windows-platform-probes/src/report.rs index 3fcb1686..79621084 100644 --- a/crates/windows-platform-probes/src/report.rs +++ b/crates/windows-platform-probes/src/report.rs @@ -27,12 +27,18 @@ //! a reader's attention. Adding a second stream here would be inventing a //! distinction the tools do not make. //! -//! # This is not yet used by every probe +//! # Every probe routes through this //! -//! The two probes added alongside this module route through it. The other -//! twelve predate it and still print directly; converting them is queued rather -//! than done here, so that this change stays reviewable and each conversion can -//! be checked against its probe's real output. +//! All fourteen, as of SH-13.4. Each conversion was checked by capturing the +//! probe's output before and after and requiring the two to match. +//! +//! **That check has to be positional**, which is worth recording because the +//! obvious tool is not. The defect a conversion introduces is a helper that +//! still writes to stdout while its caller composes a string: every line still +//! appears, but the helper's lines arrive *first*, so the report is reordered. +//! PowerShell's `Compare-Object` compares collections as sets and calls that +//! identical -- it passed three genuinely broken probes here before the +//! comparison was redone line-by-line. use std::fmt::Write as _; From 5b01b637a305d37d3be1fcd8598436a62d64acf2 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 13:07:08 -0400 Subject: [PATCH 214/361] docs: re-plan SH-2.2 -- the ioring release it demanded is not needed SH-2.2 asked for a decision -- whether to release `windows-ioring-sys` alongside `windows-topology-sys` 0.2.0, and in what order. The decision does not arise, because the premise behind it is false. The item said the published `windows-ioring-sys` "pins `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating that dependency and releasing `windows-ioring-sys` too", and that "the first symptom is a consumer unable to resolve the two together". That pin is a DEV-dependency. Cargo reports kind=dev, and the crate's src/ never names topology at all -- only four files under examples/ do. Cargo does not resolve dev-dependencies of dependencies, so a consumer of windows-ioring-sys never sees windows-topology-sys, and there is no version conflict to avoid. Verified rather than reasoned, because the claim reverses a planned release: a scratch crate depending on the published windows-ioring-sys 0.2.0 resolves windows-sys, windows-link, windows-threadpool-sys and windows-overlapped-io-sys -- and no topology crate. So no release is obliged. What remains is a mechanical three-pin update in one commit, releasing nothing. The three pins were also re-checked by kind rather than by name: ioring is dev, placement-probe and platform-probes are normal, and both of those are publish=false today. The item was reaching for something real, though, and it is now SH-2.5 rather than lost. Inside this workspace the path entry always wins, so ioring's examples are developed against whatever topology is on the branch; at cargo publish the verification build resolves the version requirement from crates.io instead. The two are free to diverge and nothing would report it until a publish failed. That hazard is currently dormant and the check is what makes that a fact rather than a hope: the examples use only Topology, Domain, DomainKind, ProcessorSet and discover, all of which pre-date 0.2.0, and none mentions provenance. So they would still build against ^0.1.0 today. SH-2.5 records the two honest fixes and asks for a choice rather than leaving it implicit. Swept the false premise: it was restated in the file's own "state this starts from" preamble and in PLANS.md, both corrected. The one remaining match is the quoted text inside SH-2.2 that is being refuted. No code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 58 +++++++++++++++++++-------- PLANS.md | 2 +- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index f004c6a2..a09c169a 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -36,7 +36,9 @@ release-blocking rather than restating the decision itself. - `windows-waitable-queues` **is not published**. First release, and its packaging is already complete: description, keywords, categories, README, documentation link and a workspace license are all present. Packaging is not a blocker; do not re-investigate it. -- `windows-ioring-sys` **0.2.0 is published and depends on `windows-topology-sys = "0.1.0"`.** +- `windows-ioring-sys` **0.2.0 is published and pins `windows-topology-sys = "0.1.0"` -- but as a + dev-dependency**, so consumers never resolve it and the pin obliges no release. Corrected at SH-2.2, + which was written on the opposite assumption. - This branch is **54 commits ahead of `main` with no pull request**, and release automation runs on `main`. Nothing ships until it merges. @@ -175,21 +177,45 @@ release-blocking rather than restating the decision itself. `windows-placement-probe` is today. Verified by removing the trigger again and watching the check fail with the crate named. -- [ ] **SH-2.2** -- Plan the **`windows-topology-sys` 0.2.0 ripple**. `windows-ioring-sys` is published - and pins `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating that dependency and - releasing `windows-ioring-sys` too. Decide the order and whether ioring's release is part of this - push or follows it -- but decide it, because a workspace that builds locally via `path` dependencies - will not reveal this and the first symptom is a consumer unable to resolve the two together. - **Three crates pin `"0.1.0"`, not one**, and they carry different obligations. Swept the workspace's - manifests rather than trusting the one that prompted this: - - `windows-ioring-sys` -- published, so the pin obliges a release, as above. - - `windows-placement-probe` -- to be published later - ([CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) -> `PT-5.6`). Its pin obliges no - release now, but must be corrected before that publication or it would ship depending on a - topology version it was never developed against. This is the nastiest of the three: the `path` - entry means it keeps building perfectly all the way to the moment of publish. - - `windows-platform-probes` -- never published, so its pin is inert. Update it with the others - anyway rather than leaving a manifest that misstates what it was built against. +- [ ] **SH-2.2** -- Update the three `windows-topology-sys = "0.1.0"` pins when 0.2.0 ships. + **RE-PLANNED: this item asked for a decision whose premise was false, and the decision it demanded + does not arise.** It said `windows-ioring-sys` is published against the old topology, "so the + breaking bump obliges updating that dependency and releasing `windows-ioring-sys` too", and that + "the first symptom is a consumer unable to resolve the two together". Neither holds. + **`windows-ioring-sys`'s topology dependency is a dev-dependency.** Cargo reports + `kind=dev`, and the crate's `src/` never names it -- only four files under `examples/` do. Cargo + does not resolve dev-dependencies *of* dependencies, so a consumer of `windows-ioring-sys` never + sees `windows-topology-sys` at all. **Verified rather than reasoned**: a scratch crate depending on + the published `windows-ioring-sys 0.2.0` resolves `windows-sys`, `windows-link`, + `windows-threadpool-sys` and `windows-overlapped-io-sys`, and no topology crate. There is no + resolution conflict to avoid and **no ioring release is obliged**, so the ordering question this + item existed to settle is empty. + What is actually left is mechanical -- update three pins, in one commit, releasing nothing: + - `windows-ioring-sys` -- `kind=dev`, invisible to consumers. Update for accuracy only. + - `windows-placement-probe` -- `kind=normal` but `publish = false` today, and planned for + publication at [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) -> `PT-5.6`, which + already names this pin among the three things it must not skip. Correct it here; that item is the + backstop, not the owner. + - `windows-platform-probes` -- `kind=normal`, never published, pin inert. Update it anyway rather + than leave a manifest that misstates what it was built against. + **The one genuine hazard the original item was reaching for is real but different**, and it is + SH-2.5 rather than this: ioring's examples are developed against the `path` topology and would be + verified against the *crates.io* one at its next publish. + +- [ ] **SH-2.5** -- **Nothing checks that `windows-ioring-sys`'s examples still compile against the + topology version its manifest pins.** Inside this workspace the `path` entry always wins, so the + examples are developed against whatever topology is on the branch; at `cargo publish` the + verification build resolves the `version` requirement from crates.io instead. The two have been + free to diverge and nothing would say so until a publish failed. + **Currently dormant, and the check is what makes that a fact rather than a hope**: the examples use + only `Topology`, `Domain`, `DomainKind`, `ProcessorSet` and `discover`, all of which pre-date + 0.2.0, and none mentions `provenance`. So they would still build against `^0.1.0` today. That is + luck holding, not a guarantee -- the moment an example uses anything 0.2.0 added, the failure + appears at publish time, which is the worst moment to find it. + Decide between the two honest fixes rather than leaving it implicit: either keep the pin current so + the two never diverge (and say in the manifest that this is why), or add a CI job that builds + ioring's examples against the *published* topology rather than the path one. The first is cheaper; + the second is what actually enforces it. - [x] **SH-2.4** -- Clear the **eight rustdoc warnings** in `windows-waitable-queues` before it is published: an unresolved link to `MIN_CAPACITY`, six links from public documentation to private diff --git a/PLANS.md b/PLANS.md index 0fb4f1c7..64cb4a19 100644 --- a/PLANS.md +++ b/PLANS.md @@ -20,7 +20,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | -| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`, so the breaking bump obliges updating and re-releasing it. M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later; SH-1.5 gave `Reserving`'s associated type the bound a generic caller needs, which had to land before publication); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | +| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later; SH-1.5 gave `Reserving`'s associated type the bound a generic caller needs, which had to land before publication); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | | [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | in progress | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Only the crates.io publication is gated on releasing `windows-topology-sys` and `windows-waitable-queues`** -- an earlier version of this row gated the whole tool on them, which was wrong: the canonical artifact is a CI-built binary compiled from this repository through `path` dependencies, and it needs nothing published. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M1B keys a processor by `(group, number)` so a machine with more than 64 of them is not silently miscounted; M1C measures each NUMA hop in both directions and places the ring deliberately; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 distributes the binary; M5+ publishes to crates.io once the download path is established. | [crates/windows-placement-probe/DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | From d35f5c1b7ef827908d68fad91c28cd3f68ed0d8c Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 13:20:54 -0400 Subject: [PATCH 215/361] docs: record SH-2.2's blocker and split its work by who owns each pin Answers "is there anything to be done": no, and now the item says why rather than reading as available work. The pins cannot be raised until topology 0.2.0 exists. A path dependency carrying a version must be satisfied by the version in the path crate's own manifest, and topology's still reads 0.1.0. Verified rather than assumed: setting one pin to "0.2.0" today fails the whole workspace's resolution with `error: failed to select a version for the requirement windows-topology-sys = "^0.2.0"`, naming the local directory as the location searched. Restored immediately and confirmed the workspace resolves again. So SH-2.2 is GATED BY SH-3.4, marked reciprocally at both ends per this file's own rule that an unmarked gate is a defect. Investigating the blocker turned up a second correction. release-please-config enables the cargo-workspace plugin, whose job is precisely rewriting intra-workspace version requirements when a member is bumped -- so part of SH-2.2's "update three pins" is not ours to do. The plugin only sees packages in its `packages` map, which splits the three: - windows-ioring-sys is managed, so the plugin should rewrite its pin. Flagged to verify rather than assume, because that pin is a dev-dependency and a tool that only rewrote [dependencies] would skip it silently. - windows-placement-probe and windows-platform-probes are not in the map at all, so their pins will certainly not be touched and remain ours. SH-3.4 now says to read the release PR's diff for the pins rather than only for the version number, and to record which of the three it actually changed, so SH-2.2 updates the remainder rather than guessing. Also flags a consequence to watch without pre-empting: the plugin bumps dependents of a bumped package, so it may produce a windows-ioring-sys release anyway. That is fine if it happens -- it is just not evidence that the obligation SH-2.2 was re-planned to remove existed after all. No code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 45 ++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 8 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index a09c169a..6c359baa 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -190,14 +190,33 @@ release-blocking rather than restating the decision itself. `windows-threadpool-sys` and `windows-overlapped-io-sys`, and no topology crate. There is no resolution conflict to avoid and **no ioring release is obliged**, so the ordering question this item existed to settle is empty. - What is actually left is mechanical -- update three pins, in one commit, releasing nothing: - - `windows-ioring-sys` -- `kind=dev`, invisible to consumers. Update for accuracy only. - - `windows-placement-probe` -- `kind=normal` but `publish = false` today, and planned for - publication at [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) -> `PT-5.6`, which - already names this pin among the three things it must not skip. Correct it here; that item is the - backstop, not the owner. - - `windows-platform-probes` -- `kind=normal`, never published, pin inert. Update it anyway rather - than leave a manifest that misstates what it was built against. + **BLOCKED, and the blocker is real rather than a preference: the pins cannot be updated until + topology 0.2.0 actually exists.** A `path` dependency carrying a `version` must be satisfied by the + version in the path crate's own manifest, and topology's still reads `0.1.0`. Verified rather than + assumed -- setting one pin to `"0.2.0"` today fails the whole workspace's resolution with + `error: failed to select a version for the requirement windows-topology-sys = "^0.2.0"`, naming the + local directory as the location searched. So this cannot be done early even as a tidy-up. + **GATED BY SH-3.4**, which is where release-please raises the release PR that bumps topology's + manifest. Nothing here can proceed before that lands. + **And two of the three pins are not ours to update.** `release-please-config.json` enables the + `cargo-workspace` plugin, whose job is exactly this -- rewriting intra-workspace version + requirements when a member is bumped -- but it only sees packages listed in its `packages` map: + - `windows-ioring-sys` -- **managed**, so the plugin should update this pin itself. `kind=dev` and + invisible to consumers, so the update is for accuracy only. **Verify rather than assume it + happened**: it is a dev-dependency, and a tool that only rewrote `[dependencies]` would silently + skip it. Watch for this on the release PR (SH-3.4). + - `windows-placement-probe` -- **not managed**, so the plugin will not touch it. `kind=normal`, + `publish = false` today, planned for publication at + [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) -> `PT-5.6`, which already names this + pin among the three things it must not skip. Ours to update; that item is the backstop, not the + owner. + - `windows-platform-probes` -- **not managed**, `kind=normal`, never published, pin inert. Ours to + update, and worth doing rather than leaving a manifest that misstates what it was built against. + **One consequence to watch for, not to pre-empt.** The `cargo-workspace` plugin also *bumps* + dependents of a bumped package. If it treats the dev-dependency as grounds to bump + `windows-ioring-sys`, an ioring release will happen -- not because one is obliged (it is not; see + above) but because the tooling produced one. That is acceptable if it occurs; it is only a problem + if it is mistaken for evidence that the obligation existed after all. **The one genuine hazard the original item was reaching for is real but different**, and it is SH-2.5 rather than this: ioring's examples are developed against the `path` topology and would be verified against the *crates.io* one at its next publish. @@ -265,6 +284,16 @@ release-blocking rather than restating the decision itself. **0.2.0** for the topology crate. If it proposes 0.1.1, the breaking-change marker did not take and the version would silently understate the break -- fix the marker rather than editing the version by hand, or the next break will do the same thing. + **GATES SH-2.2**, which cannot run before this: the three `windows-topology-sys = "0.1.0"` pins + cannot be raised while topology's own manifest still reads `0.1.0` -- a `path` dependency's + `version` must be satisfied by the path crate, and attempting it fails the workspace's resolution + outright. + **Read the release PR's diff for the pins, not only for the version number.** The `cargo-workspace` + plugin should rewrite `windows-ioring-sys`'s requirement itself, but that one is a *dev*-dependency + and a tool that only rewrote `[dependencies]` would skip it without saying so. `windows-placement-probe` + and `windows-platform-probes` are outside release-please's `packages` map entirely, so their pins + will certainly not be touched and are SH-2.2's to update by hand. Record which of the three the PR + actually changed, so SH-2.2 updates the remainder rather than guessing. ## M4: release From dcdc402089e921289de311c9c6f8beb3f13ea343 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 13:31:54 -0400 Subject: [PATCH 216/361] docs: re-plan the ship checklist so it says what is actually open The file had become hard to read as a plan: 1075 lines and 83.8 KB, of which eight fully-complete milestones were the bulk, and several open items no longer described the world. Archived the completed work. M1 (settling the public surface) and M7 through M13 (seven consecutive PR #56 review rounds) were every-item complete and moved to COMPLETED-CHECKLIST.md, leaving a pointer where each block was. The file is now 677 lines / 52.2 KB. Item arithmetic checks: 34 active + 37 archived = 71, which is what was there before. Resolved a duplicated decision. SH-14.3 ("decide the fix") and SH-15.6 were two open items for one decision -- M15's own header said it existed to answer SH-14.3 -- and SH-14.3's option list had gone stale in three ways: option 1 claimed widening was impossible for reserving_mpsc, which is true only of its own word (D-37 widens a separate shape); option 4's "drop 32-bit" turns out to be entailed by option 1 rather than an alternative (D-18, amended); and the list had no entry for the claim protocol since built and measured (D-35), the current front-runner. SH-14.3 is now checked as superseded, explicitly "asked and answered elsewhere" rather than decided, with its one surviving contribution (no wrap test can witness the bug) attributed to SH-15.7. Fixed drift found while reading: - SH-6.1 still said slotwise_mpsc reaches the 32-bit wrap. SH-14.2 widened its positions to u64 on every target, so it does not; the item's subject is now reserving_mpsc alone, and would disappear entirely if SH-15.6 adopts the permit claim. - SH-6.1 also read as though crossing the wrap could witness SH-14.1. It cannot -- that needs a producer held mid-claim -- so it and SH-15.7 are now marked complementary at both ends. - M6 said "placed last so the numbering does not churn" while M14 and M15 sat below it. - M15's preamble spoke of "both arms" and "neither arm" after arm B moved to M-inf, and cited SH-14.3's superseded list. Explained two things that read as errors and are not: SH-15.4 is deliberately vacant (it became SH-inf.1, and the number is left unused so older references still resolve), and SH-15.5.1 is open beneath a checked SH-15.5 because it is work the measurement spawned, not a leftover of it. Added a status table at the top, which is what was actually missing -- including the fact a reader most needs and the file never stated: the critical path to a release is M3 -> M4 and is NOT blocked on M15, because SH-14.1 ships disclosed rather than fixed (D-36). Verified rather than asserted: nothing in M4 or M5 references M14 or M15. What does gate the queue crate specifically is M6. PLANS.md's row updated to match. No code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 582 ++++---------------------- COMPLETED-CHECKLIST.md | 469 +++++++++++++++++++++ PLANS.md | 2 +- 3 files changed, 562 insertions(+), 491 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 6c359baa..daf65337 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -4,6 +4,28 @@ placement tool in [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) has something to build against and other people can run it on hardware this workspace does not own. +## Where this stands + +The release has not happened. Nothing below M6 is release work -- M14 and M15 are review rounds that +arrived during it, and M15 carries the largest open question in the file. + +| Milestone | State | What it is waiting on | +|---|---|---| +| M1 settle the public surface | **done, archived** | -- | +| M2 repair the release plumbing | 2 of 5 open | SH-2.2 is blocked on SH-3.4; SH-2.3 needs the merge commit; SH-2.5 needs a decision | +| M3 land the branch | open | the pull request is still a draft | +| M4 release | open | M3 | +| M5 verify from outside | open | M4 | +| M6 long-running validation | open | gates SH-4.3, so it gates the queue crate's publication | +| M7-M13 review rounds | **done, archived** | -- | +| M14 ninth review round | 1 open | SH-14.1 is the ABA defect itself; it is disclosed (SH-15.8) and its fix is M15 | +| M15 the claim protocol | 5 open | SH-15.6 is the decision; it is gated on SH-15.5.1 | +| M-inf parked | ungated | not scheduled, deliberately | + +**The critical path to a release is M3 -> M4, and it is not blocked on M15.** SH-14.1 ships disclosed +rather than fixed ([D-36](crates/windows-waitable-queues/DESIGN-NOTES.md#d-36)), so M15 can conclude +after 0.1.0 without holding it up. What *does* block the queue crate specifically is M6. + ## Before checking anything off in this file Items here are cross-linked to the other plans, and **a cross-reference is an instruction, not a @@ -42,124 +64,16 @@ release-blocking rather than restating the decision itself. - This branch is **54 commits ahead of `main` with no pull request**, and release automation runs on `main`. Nothing ships until it merges. -## M1: settle the public surface before it is public - -- [x] **SH-1.1** -- **MIRRORS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.8 -- one piece of - work seen from two plans. Check both off in the same commit; neither is done alone.** - **Decide M31.8 (merge-or-delete for `slotwise_mpsc` and `reserving_mpsc`) before the first - publish, not after.** This is the highest-leverage item in the file and it is release-blocking for a - mechanical reason: the decision may *delete a public type*. Doing that before 0.1.0 costs nothing; - doing it after means a breaking release, a yank-and-migrate for anyone who adopted it, and a - permanent line in the changelog explaining why a shape existed for one version. - The measurement is already done and agrees across both architectures -- see M31.5 and M31.7 in - [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) -- so this needs a decision, not more work. - -- [x] **SH-1.2** -- **GOVERNS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.6 -- this is not - that item and does not complete it.** It decides only whether M31.6 blocks SH-4.3. If the answer is - "it gates", record that on M31.6 and SH-4.3 cannot proceed until M31.6 is done; if "it does not", - record that too, so a later reader does not mistake a considered choice for an oversight. **Checking - this off never checks off M31.6.** - **Decide explicitly whether M31.6 (loom verification) gates 0.1.0**, and record the - answer either way rather than letting it drift into "not yet". - - **Decided: it does not gate 0.1.0. It gates 1.0, and the gap is disclosed in the crate's own - documentation rather than left for an adopter to discover.** Recorded as D-31. - - Three findings drove it, and the second was not expected: - - - **Loom would close the demonstrated gap.** The sabotage sweep showed a weakened `Acquire` on the - producer's load of `head` survives the whole suite. That defect lives in queue code, which is - exactly what loom models well. - - **Loom would *not* close the gap where a real bug actually occurred.** The doorbell's correctness - is the interleaving of an `AtomicBool` mirror with real `SetEvent`/`ResetEvent` syscalls. Loom - models the atomics and cannot model the syscalls; stubbing them tests a *model* of `SetEvent` - rather than `SetEvent`. D-15's lost wakeup -- the only ordering bug this crate has actually had -- - was found by sabotage, and loom would not have found it. So loom is valuable and is **not** the - thing standing between this crate and confidence about its hardest part. - - **The risk loom addresses is mostly regression risk**, and that risk is lowest now. The orderings - are believed correct and were reasoned about at the time; sabotage *introduced* the weakening to - prove the suite was blind to it. Regression risk rises with contributors, changes, and consumers - -- all of which start after publication, not before. - - Against that, gating would block 0.1.0, and through it the placement tool and the NUMA measurements - from other people's machines that this whole sequence exists to obtain. Loom is invasive work: every - atomic in the crate goes behind a `cfg` shim across four modules. - - **The disclosure is what makes this a decision rather than a punt**, and it is not optional: the - crate documentation states what is verified, states that stress testing here is *known* not to catch - ordering defects and cites the measurement showing it, and says loom is planned before 1.0. An - adopter then makes their own call with the same information we have. `0.x` carries the rest. - The reason it deserves a deliberate answer rather than a default: the sabotage sweep demonstrated - that weakening the producer's `Acquire` load of `head` to `Relaxed` left **all twenty tests green**, - while every logic defect injected beside it was caught. So this is not an untested-by-omission gap, - it is a gap this workspace has *evidence* the existing tests cannot close. Publishing a lock-free - queue with it open is a defensible choice; making it unknowingly is not. - -- [x] **SH-1.3** -- **Qualify both MPSC shapes by name.** `mpsc` beside `reserving_mpsc` made one - canonical by implication -- which contradicts this crate's own "no shape is the canonical one", and - after SH-1.1 is simply false. Renamed to `slotwise_mpsc`, which names its claim protocol: it claims - slot by slot with no shared counter. `sequence_mpsc` was considered and rejected for inviting the - reading that it alone preserves FIFO order, which both shapes do. Recorded as D-30. - **Belongs in M1 for the same reason SH-1.1 does**: it is a public-surface change, free before the - first publish and a breaking rename with a deprecation path afterwards. - -- [x] **SH-1.4** -- **State the algorithms' pedigree and why an existing crate is not used.** A public - concurrent-queue crate has to answer both questions or a reader assumes the worst: that the - algorithms are homegrown, and that the author did not look at the alternatives. - Neither is true, and the honest answers are load-bearing. The algorithms are *published designs* - chosen deliberately, because a concurrent queue is a bad place to be original -- the failure mode is - a reordering that appears on one machine, under load, months later. And the reason no channel crate - fits is structural rather than dismissive: **on Windows, waiting is a kernel-object operation**, so a - queue whose readiness is not a `HANDLE` cannot join a `WaitForMultipleObjects` alongside an I/O - completion, a process handle, or a cancellation event -- however good its own blocking receive, and - however rich its own `select`, which can only select over its own channels. - Written into both the crate docs and the README, because docs.rs shows one and crates.io the other. - -- [x] **SH-1.5** -- **Bound `Reserving::Reservation<'a>` so a generic caller can redeem what it claims.** - Done: the `Claim` trait carries `send` and `is_disconnected`, `Reservation<'a>` is bound on it, and - both reservation types implement it as forwarders. 87 lines across five files, no concrete signature - changed, `slotwise_mpsc` untouched because it does not implement `Reserving` at all. Mutation-tested - rather than assumed: 62 mutants over the whole reservation surface report 0 missed, and the first - run found a real gap -- `is_disconnected` stuck at `false` survived on `spsc`, because the connected - case was asserted there and the disconnected case only on the other shape. - The associated type is declared with no bound at all, so a caller generic over - [`Reserving`](crates/windows-waitable-queues/src/traits.rs) can call `reserve()` and then do - nothing with the result except drop it. `reserve` is `#[must_use]` precisely because a held claim - withholds capacity from every other producer -- and the one operation that discharges it, `send`, - is inherent to each shape's concrete type and unreachable through the trait. The trait cannot - express the operation it exists for. - - **The two implementors already agree exactly, so this is additive**: both - `spsc::Reservation<'a, T>` and `reserving_mpsc::Reservation` already have - `send(self, item: T) -> Result<(), Disconnected>`, `is_disconnected(&self) -> bool`, and a - `Drop` that returns the slot. No concrete signature changes; nothing to migrate. - - Add a `Reservation` trait carrying `send` and `is_disconnected`, bound the associated type on it, - and implement it for both types. `is_disconnected` is included rather than deferred for the reason - the `Reserving` docs give at length -- a caller needs to learn the stream ended *before* doing the - work the claim was taken for -- and because `reserving_mpsc`'s reservation is `Send`, so it may be - redeemed on a thread holding no producer handle to ask instead. Adding it later is the same - breaking change, merely deferred. - - **Why this blocks rather than waits.** Adding a bound to an associated type is a breaking change - to the trait: every implementor must then satisfy it. It is free while the crate is unpublished - and a major bump with a migration afterwards, and this is the milestone that exists to settle - exactly that -- see SH-1.1 and SH-1.3, both landed on the same "free before the first publish" - reasoning. D-3 already makes this argument ("the trait *shape* is fixed now so signatures stay - compatible"); this is the same reasoning applied to a piece it missed. Pull request #56 is what - puts these traits in front of consumers, so the window closes when it merges. - - **How it surfaced**, recorded because the route is the useful part: not from review and not from a - failing test, but from a `cargo mutants` run showing that nothing exercised the capability traits - at all, and then from being unable to write the obvious generic test for `Reserving` -- the test - in [traits/tests.rs](crates/windows-waitable-queues/src/traits/tests.rs) is scoped to claim-and-release - and says so. A contract gap presenting as an untestable API is a signal worth keeping. - - Extend that test to claim-and-redeem through the trait as part of this item, since it is the - check that would have caught the gap in the first place. +> **M1 -- settling the public surface before publication -- is complete and archived.** Moved to +> [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md) on 2026-09-02. ## M2: repair the release plumbing before relying on it +**Read in file order, not numeric order.** These items are in dependency order, and the numbers are +historical -- SH-2.5 was split out of SH-2.2 and sits beside it, while SH-2.3 is last because it +needs the merge commit. Renumbering would break the references already in commit messages, so the +order is the authority and the numbers are only names. + - [x] **SH-2.1** -- **Add `windows-waitable-queues-v*` to the tag trigger list in [.github/workflows/publish-crate.yml](.github/workflows/publish-crate.yml).** It is missing. The crate *is* registered with release-please, so release-please will happily raise the release PR and @@ -331,7 +245,9 @@ release-blocking rather than restating the decision itself. ## M6: long-running validation -**Placed last so the numbering does not churn, and it gates SH-4.3 all the same.** `windows-waitable-queues` +**Numbered last among the release milestones, and it gates SH-4.3 all the same.** (It is no longer +*positioned* last: M14 and M15 were appended afterwards, as review rounds that had to go somewhere.) +`windows-waitable-queues` 0.1.0 does not publish until this milestone is done. The reasoning is in SH-1.2 / D-31: the crate ships without machine-checked orderings, and long-running validation is part of what it owes instead. @@ -353,10 +269,18 @@ D-31 says cannot be supported. `slotwise_mpsc` "use `usize` positions and cannot be driven there at all", and that is **false on a 32-bit target**, where `usize` *is* 32 bits. The claim was written from a 64-bit reading and never re-checked against the 32-bit support the crate otherwise takes seriously enough to have a - dedicated `BOUNDS_MAX` derivation and a `const` assertion for. `slotwise_mpsc` reaches the same - wrap on such a target; `spsc` does too, but has no compare-exchange claim to be raced, so wrapping - alone does not expose it. See SH-14.1 and SH-14.2, which are about the *correctness* hole this - testing gap was hiding. + dedicated `BOUNDS_MAX` derivation and a `const` assertion for. See SH-14.1 and SH-14.2, which are + about the *correctness* hole this testing gap was hiding. + **SUPERSEDED IN PART, and the scope is now narrower than the paragraph above implies.** + `slotwise_mpsc` **no longer reaches the wrap on any target**: SH-14.2 widened its positions to a + named `Position = u64` everywhere, so it needs 2^64 claims. `spsc` never had a compare-exchange + claim to race. So the only shape this item still has to drive across the wrap is + `reserving_mpsc` -- and if SH-15.6 adopts the permit claim, whose ticket is likewise `u64`, that + one goes too and this item's subject disappears entirely. + **A wrap test alone cannot witness SH-14.1**, which is worth stating here because this item reads + as though it could. Crossing 2^32 exercises the arithmetic; the defect additionally needs a + producer *held* between its room check and its claim. That seam is SH-15.7's, and the two are + complementary rather than alternatives. What exists today is *ring* wraparound (positions cycling through slots) and the packing arithmetic checked at the boundary; what does not is the queue actually crossing 2^32 end to end. **Tracking every item is impossible at that count**, so the invariants are the cheap ones: per-producer sequence @@ -394,343 +318,9 @@ D-31 says cannot be supported. Keep a short in-suite smoke run over the same engine so the code cannot rot, and keep it out of the fast unit suite, which must stay under a second. -## M7: PR #56 automated-review round - -The findings an automated review raised against the pull request that lands this work, verified against -the source before being accepted. Each item names what was checked, so a later reader can tell a real -repair from a reviewer's guess that was taken on trust. - -- [x] **SH-7.1** -- **`reserving_mpsc` reports `Full` from a claim word that was never current.** - `push` and `reserve` load the claim word relaxed, then test room with - `has_room_beyond_reservations(position, reserved)`, which computes - `position.wrapping_sub(head)`. If other producers claim and publish past `position` and the consumer - drains them while this thread is between the load and the room check, `head` passes the stale - `position` and the subtraction wraps to near `u32::MAX` -- so the queue reports `Full` (and records a - refusal) at the moment it is empty, and `reserve` returns `None` for the same reason. The compare-and- - swap that would have caught the staleness is never reached, because both paths return before it. - Re-read the claim and retry when it moved; report no room only from a word still current. - -- [x] **SH-7.2** -- **The NUMA cross-check compares a count against a highest identifier.** - `windows-platform-probes`'s `Observation::cross_check` compares `numa_domains` (a count of memory - domains) with `GetNumaHighestNodeNumber() + 1`. Windows documents that value as the highest node - *number*, and does not guarantee node numbers are dense -- nodes 0 and 2 give a count of 2 and a - highest of 2, and the probe then reports a parsing regression on correct hardware. Memory domains - already carry the node number in `Domain::id`, so compare highest against highest. - -- [x] **SH-7.3** -- **A cache level is called a partition without checking that it is one.** - `cache_partitions_at_level` deduplicates by equal processor set, which is exactly right for the - measured case it was written for (L1i and L1d over identical sets). It does not establish a - *partition*: `Topology` is deliberately constructible by hand and by deserialization (D-12), so - distinct-but-overlapping sets reach `outermost_partitioning_cache`, which returns them as domains a - consumer then double-counts. Require the distinct sets to be pairwise disjoint before a level - qualifies as partitioning. - -- [x] **SH-7.4** -- **`windows-waitable-queues` cannot build its documentation on docs.rs.** The crate - is Windows-only and imports `std::os::windows::io` unconditionally, but its manifest omits the - `[package.metadata.docs.rs]` target block that every other published Windows-only crate here carries, - so docs.rs would build it for its default Linux target and fail. Add the same block. - -- [x] **SH-7.5** -- **The mutant injector replaces every occurrence on the line, not the first.** - `tools/inject-mutant.ps1` calls the *static* `[regex]::Replace(input, pattern, replacement, 1)`, whose - fourth parameter is `RegexOptions` -- `1` is `IgnoreCase`, not a replacement count, and no static - overload takes a count at all. The tool therefore does precisely what its own header comment says it - exists to avoid. Fix the replacement, refuse a line whose pattern occurs more than once unless a - column disambiguates it, verify the baseline is green before trusting a "caught", run with all - features so a feature-gated mutation is not reported as surviving, perform the mutating write inside - the guarded region so a failed write still restores, and route its output through one sink. - -- [x] **SH-7.6** -- **A spike that fails to run is reported as a finding about the machine.** - `tools/run-numa-spikes.ps1` checks the exit code of `cargo build` but not of `cargo run`, then decides - vacuity by searching the output for `VACUOUS`. A crashed spike prints no such line, so the summary - says "**NOT vacuous -- this runner has more than one NUMA node**" and the script exits 0. That is the - instrument breaking while claiming a result, which the script's own documentation says is the one - thing worth failing over. - -- [x] **SH-7.7** -- **Two tools write output from several sites, and two hazards remain in the - sabotage/mutation harness.** `tools/check-publishable.ps1` and `tools/inject-mutant.ps1` each call - `Write-Host` from several places, against the repository's one-output-sink rule. - `tools/run-sabotage.ps1` performs its patching write before entering the `try` whose `finally` - restores the file, so a write that throws part-way leaves the clean source damaged. - `tools/run-mutants.ps1` derives a deterministic output directory per package or file, so a second run - of the same scope overwrites the analysis the parameter documentation promises to preserve. - The placement probe's tests name scratch directories without the process id, so two concurrent test - processes -- which the documented `-j 2` mutation workflow creates -- delete each other's fixtures. - -- [x] **SH-7.8** -- **Reply to every thread and resolve the ones that are addressed**, including the one - finding that was checked and found not to hold: `GetSystemDirectoryW` returning exactly the buffer - length is unreachable (success excludes the terminator, failure includes it and so exceeds the - buffer), though the guard is widened anyway so the next reader need not redo the analysis. - -## M8: PR #56 third review round (suppressed findings) - -The reviewer generated no new inline comments in these rounds and instead listed **suppressed** findings in -the review body, so none of them arrived as a resolvable thread. They are recorded here because a finding -that produces no thread is otherwise invisible to the "are all comments resolved?" check that gates merge. - -- [x] **SH-8.1** -- **The contention probe times thread creation, and lets early producers run alone.** - All five timed runs in `windows-platform-probes`'s `queue_contention` start the clock *before* - `thread::scope` spawns anything, and every worker begins pushing the moment it is spawned. At 50,000 - pushes each, an early producer can finish a large uncontended prefix -- or finish outright -- while the - last threads are still being created, so a row labelled 16 or 32 producers may never have had 16 or 32 - contenders. The measured interval also includes spawn cost. This is not a cosmetic inaccuracy: the - module's own header says these numbers decide whether two speculative queue shapes get written at all - and whether the two shipped shapes merge. Hold every participant -- producers *and*, in the drained - runs, the consumer -- at a start barrier, and start the clock when it releases. - -- [x] **SH-8.2** -- **A failed backup write leaves a truncated file under the canonical name.** - `write_backup_to_new_file` reserves the name with `create_new` and then `write_all`s through `?`, so a - disk-full or quota failure returns an error while leaving a zero-length or partial `.json` behind. That - file is indistinguishable from a real record to whoever collects it, and the next run's collision - suffix steps politely around it. Publish by rename: write the bytes to an exclusively-created temporary - in the same directory, flush, and move it onto the reserved name only once the write has succeeded. - -- [x] **SH-8.3** -- **`places_from_topology` drops processors and invents NUMA membership.** - Two defects in one conversion, both reachable only through a hand-built or deserialized `Topology` -- - which is exactly the input this seam exists to accept (D-12). - It iterates `class_of`, which is populated only from `DomainKind::Core` domains, so an online processor - with no core domain is **silently absent from the result** -- and the documented core-id fallback - beneath it, written to keep group 1's cpu5 distinct from group 0's, is unreachable dead code as a - direct consequence. - It then defaults absent NUMA membership to `unwrap_or(0)`. That is the right answer only when the - topology names no memory domain at all; when it names nodes 1 and 2, it **fabricates node 0** and files - a processor under a node the machine does not have -- the precise failure this crate's own rule - ("a seam that only moves data is safe; a seam that lets fabricated labels reach real hardware is not") - exists to prevent. - Iterate the online processors so every one is placed, and refuse a topology that names memory domains - but not this processor's, rather than inventing one. - -## M9: PR #56 fourth review round - -- [x] **SH-9.1** -- **Both bounded shapes could report a length larger than their capacity.** - `len` reads the producer-side position and then `head`, which are two instants; a consumer draining - past the sampled position makes the wrapping subtraction yield a number near the integer maximum. The - comment beside it claimed the overestimate was "safe in the direction that matters for a backpressure - gauge", which is true of a *bounded* overestimate and not of `usize::MAX`. Both are now clamped to the - capacity, so the skew still resolves towards full -- the safe direction -- while the impossible value - is gone. - -- [x] **SH-9.2** -- **`reserving_mpsc` inherited a `remaining()` that counted reserved slots as room.** - `Bounded::remaining` defaults to `capacity - len`, and this shape's `len` excludes reservations by - design, so an empty queue of four holding one reservation answered four while only three items fit -- - promising room for a push guaranteed to be refused. Overridden on both handles and both trait impls, - reading the packed claim word **once** so the position and the reservation count cannot be sampled at - different instants; `is_full` is now defined in terms of it rather than restating the rule. - -- [x] **SH-9.3** -- **The pull request description described the release plumbing, not the product.** - The body framed the change as CI and provenance work and mentioned `windows-waitable-queues` only - under release tracking, while the majority of the diff is that crate's public API and its three - lock-free queue implementations. Rewritten to lead with the shipped surface. - -## M10: PR #56 fifth review round - -- [x] **SH-10.1** -- **`BOUNDS_MAX` does not compile on a 32-bit target.** `reserving_mpsc`'s maximum - was a flat `1 << 31`, derived from the packed position's width alone. On a 32-bit target the - crate-wide `WRAPPING_MAX_CAPACITY` is `usize::MAX / 2`, which is `2^31 - 1` -- *narrower* than the - packing -- so the const assertion that no shape may exceed it fails the build outright, for every - capacity including the small valid ones. Now the narrower of the two limits, kept a power of two so - the value stays one a caller could actually pass. Verified in both directions against a real - `i686-pc-windows-msvc` check: the old constant fails with `E0080`, the new one compiles. - -- [x] **SH-10.2** -- **The backup's final name was visible empty for the whole write.** The previous - round reserved the destination with `create_new` and renamed onto it, which fixed the truncated-file - case and left a worse one: an empty file under the record's own name for the duration of the write, - and permanently if the process was killed in that window -- contradicting the absent-or-complete - guarantee its own doc comment claimed. Publication is now a single atomic no-replace `MoveFileExW` - from a fully-written temporary. `std::fs::rename` cannot express this: on Windows it always passes - `MOVEFILE_REPLACE_EXISTING`, so it would clobber a record a concurrent run had placed. - -- [x] **SH-10.3** -- **The tool discovered the topology three times.** The plan used one reading, the - fingerprint another, and `core_affinity::measure` a third, so a processor going offline mid-run could - have the announced plan, the recorded host, and the measured rows describing different machines with - nothing saying which. The plan and the fingerprint now derive from one `Topology::discover`. - `measure` still discovers its own, and deliberately so: its documentation refuses a - `measure_with(places)` seam because a supplied list's processor *numbers* stay valid on the real host - while its node labels need not, so every pin would succeed and real timings would be filed under - fabricated labels. Its rows carry their own places, so each row states what it measured. - -- [x] **SH-10.4** -- **`spsc` had the same `remaining()` defect, and it was missed.** The previous round - corrected `reserving_mpsc` and stopped there, but `spsc` implements `Reserving` too -- so reserving - every slot left it reporting the full capacity as available while both `push` and `reserve` refused. - Its `Bounded` impls now override `remaining` on the producer *and* the consumer, its `len` is clamped - to the capacity like the other two shapes', and `is_full` is defined in terms of `remaining` rather - than restating the rule. The trait's default now documents that a `Reserving` shape must override it, - so the next shape to reserve does not inherit the same wrong answer silently. - -- [x] **SH-10.5** -- **The high-water depth could record a peak the queue never reached.** - `reserving_mpsc`'s `publish` sampled the depth from its own position and a relaxed load of `head`, - ungated and unclamped. `slotwise_mpsc`'s twin is bounded by construction -- its producer's acquire - load of the slot's sequence synchronizes-with the consumer freeing that slot, so `head` cannot be - older than `position - capacity + 1` -- but this shape has a second entry point with no such edge: - `Reservation::send` redeems without a room check, so the only `head` its thread is ordered against is - the one *`reserve`* read, which may be arbitrarily old by the time the reservation is redeemed. The - sample is now gated on tracking (parity with the twin), read before publication, and clamped to the - capacity. - `Observable::high_water`'s contract is corrected to match what all three shapes actually deliver: an - **upper bound** on the true peak, never below it and never above the capacity, with the reason the - cheap sample is preferred to an exact count. Counting exactly would put a read-modify-write on a line - shared by every producer and the consumer into every push and every pop -- the line this crate pads - its positions apart to keep out of the hot path. - -## M11: PR #56 sixth review round - -Three findings against `places_from_topology`, all of the same shape, plus three against the -mutation wrapper. The conversion's three silent fallbacks are replaced by one rule. - -- [x] **SH-11.1** -- **Three fallbacks each invented an answer that reads as a real one.** - `places_from_topology` accepted a topology whose domains do not cover every processor, and filled - each gap with a value indistinguishable from a measured one. A processor absent from every core - domain was given a synthetic core id derived from its group and number, which can equal a real - core domain's id -- `classify` then reports two processors as SMT siblings when one's core is - merely unknown. Its efficiency class became `0`, which is also a genuine Windows class, so - `within_class_pair` reports a same-class pair against a real class-0 core. Its cache domain became - `None`, which the type already means "no cache level partitions this machine" -- so two processors - omitted from an incomplete partition compare equal and serialize a confident same-cache - measurement. - The three share one cause: an absence was read as a value. The rule now distinguishes *uniform* - absence from a *gap*. A machine that reports no core domains at all, or no partitioning cache - level, has told us something true about itself and still converts. A machine that places every - other processor but not this one has told us nothing about this one, and the conversion refuses: - `places_from_topology` returns `Err(UnplacedProcessor)` naming the processor and, in a new - `MissingPlacement` field, which of core / cache domain / NUMA node was missing. - `MissingPlacement` is `#[non_exhaustive]`. - Core and efficiency class are two spellings of one rule -- `Topology::cores()` filters to - `DomainKind::Core`, so a processor's class is known exactly when its core is -- and an - `EfficiencyClass` variant written for the second was removed on discovering it is unreachable. - Sabotage confirms the pair behaves that way: removing either refusal alone leaves the suite green, - because the other still fires; removing both fails two tests. - -- [x] **SH-11.2** -- **The mutation wrapper's output directory could collide.** The stamp has - one-second resolution, so two runs launched in the same second -- a script starting several scopes - at once, which is exactly the case that wants separate output -- selected the same directory and - interleaved their results. A short random suffix now follows the stamp, which still sorts - chronologically. - -- [x] **SH-11.3** -- **The wrapper terminated fault handlers it did not start.** Cleanup matched - `WerFault` / `WerFaultSecure` / `vsjitdebugger` by name across the whole session, so a crash report - the user was reading or a debugger attached to an unrelated process was killed by a mutation sweep. - The wrapper now records the ones already running at startup and skips them. - -- [x] **SH-11.4** -- **The `mutants.out` nesting finding does not hold; documented in place.** - The report was that `Join-Path $OutputDirectory 'mutants.out'` doubles a path cargo-mutants already - appends. It does not: cargo-mutants treats `--output` as the parent and creates `mutants.out` - inside it. Verified on disk -- a run with `--output .scratch\mutants-encoding-` produced - `\mutants.out\caught.txt` with 22 lines, matching the 22 caught the wrapper reported. A - comment now records the evidence, since the path reads like a duplication and has been challenged - once already. - -- [x] **SH-11.5** -- **A hard-killed run could block the next one's backup entirely.** The temporary - was named for the record plus this process's id and nothing else, and created with `create_new`. A - run killed mid-write leaves that file behind, and Windows reuses process ids -- so a later run - issued the same id found the corpse under the only name it would ever try. The resulting - `AlreadyExists` left `write_temporary` *before* the caller's suffix loop was reached, so the whole - backup failed rather than landing under a next-best name. The temporary now carries its own - attempt counter, matching the final name's budget; a stale file is stepped around rather than - overwritten, since it belongs to whatever left it. - -- [x] **SH-11.6** -- **Both tools bypassed their own single-output-sink contract.** `run-mutants.ps1` - emitted its per-category summary directly to the success stream, and `run-sabotage.ps1` did the - same for the `-List` output, every blank line, the result table, and the injected patch text -- - each contradicting the `Write-Report` doc comment directly above them. All now route through the - sink, which gained pipeline binding so a formatted table can flow into it. Verified: the `-List` - path emits zero objects to the success stream. - -- [x] **SH-11.7** -- **The sabotage harness silently narrowed its own sweep.** Found while checking - SH-11.6's output: the harness prepends the `test` subcommand to a manifest's `testArgs`, but nine - of the eleven manifests already begin with `test`. The result was `cargo test test -p ...`, in - which the second word is not a subcommand but a TESTNAME filter -- a sweep claiming to run a - package's suite while running a subset of it, the same false-green this tool exists to prevent. - The vector is now normalised so either manifest spelling produces one `test`. - **No prior verification was weakened**: every crate swept so far keeps its tests under a - `mod tests`, so the accidental filter matched all of them, and all fourteen recorded baselines - report "0 filtered out". The defect was latent, and would have appeared on the first crate laid - out differently. - -## M12: PR #56 seventh review round - -- [x] **SH-12.1** -- **The banner undercounted the machine it was about to measure.** - `Fingerprint::processors` is documented as the logical-processor count but was summed over - core-domain membership, which agreed with that meaning only while every processor was guaranteed to - sit in a core domain. SH-11.1 stopped guaranteeing it: `places_from_topology` now explicitly accepts - a topology naming no cores and places every online processor. The banner consequently read - `0p/0c` for a machine the measurement was about to use four processors on -- a defect this branch - created rather than inherited. - The count is now read off `topology.processors` with the same `online` filter the placement applies, - so the summary counts exactly what the measurement will use. `cores` deliberately still counts core - domains: zero there is the honest report that the topology named none. - Sabotage-verified. Two new tests pin both directions -- an uncored processor is still counted, an - offline slot is not -- and each asserts equality against `places_from_topology`'s own output rather - than a literal, so the two cannot drift apart again. - -- [x] **SH-12.2** -- **Two consequences of that fix, found by sweeping it rather than reported.** - `cache_domain_sizes` fills itself with the processor count when no cache level partitions the host, - so the bare-machine render silently improved from `L-[0]` to `L-[4]`. - `numa_node_sizes` did not, and now does not sum to `processors` in that one case: it reports the - nodes the topology *named*, and a bare topology names none, while every placement still reports the - documented node-`0` default. Keeping it that way is deliberate -- the cache list can afford to fill - itself because `L-` marks the absence, and `numa[4]` would be indistinguishable from a host that - genuinely reported one node of four. The behaviour is now documented on the field and pinned by a - test that asserts the whole render, including the `!!SYNTHETIC!!` provenance marker. The stronger - fix needs a marker, which is a serialized field and so a schema bump, tracked as - [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) `PT-6.2`. - -## M13: PR #56 eighth review round - -- [x] **SH-13.1** -- **A record could splice two machines together.** The tool announced a shape read - at one instant while `core_affinity::measure` discovered again at another, so a processor going - offline -- or moving group or node -- between them produced a record whose `host` described one - machine while every row was measured on a different one. Nothing in the file said so, and the host - is precisely what a reader interprets row sets *through*. - `measure` now reports the shape it actually ran on, as `Observation::host`, which is the fix that - keeps the anti-synthetic boundary intact: the measurement still discovers for itself and no seam - accepts a fabricated shape from outside. `SubmissionRecord::new` refuses when the announced and - measured hosts differ, so the splice is unrepresentable rather than merely avoided at the one - current call site; the tool checks first anyway and reports the disagreement in terms a runner can - act on. Refusing rather than silently recording the measured shape, because the notice is what the - runner consented to. - -- [x] **SH-13.2** -- **The tool wrote from 54 independent print sites.** The repository's - one-output-sink rule requires an output abstraction at the *first* output site so the storage - target and the formatting stay separable from the call sites that compose content. This binary had - none, which is why its collection notice -- a disclosure a runner reads before agreeing to publish - facts about their machine -- could only be exercised by running the process and capturing stdout. - A `Sink` trait now carries the two streams the tool genuinely has, `print_collection_notice` and - `print_plan` became `render_*` functions returning a `String` (matching the idiom the record report - already used), and `main` is the only place that names the real streams. - Verified as a pure refactor by comparing the built binary's output before and after: `--preview` - and `--help` are **byte-identical**, and `--version` differs only by the build identity correctly - reporting the working tree as `DIRTY`. Eight new tests cover what was previously unreachable, - including that the notice shows the model rather than describing it, that a withheld model reads - differently from one the host would not report, and that the two streams cannot satisfy each - other's assertions. - -- [x] **SH-13.3** -- **The two new probes wrote from 94 independent print sites between them.** Same - rule as SH-13.2, in [core_affinity.rs](crates/windows-platform-probes/src/bin/core_affinity.rs) - (67 sites) and [doorbell_cost.rs](crates/windows-platform-probes/src/bin/doorbell_cost.rs) (27). - A `Report` sink now lives in [report.rs](crates/windows-platform-probes/src/report.rs), shared by - both. One stream, not two: unlike the placement probe these have only ever written to stdout, and - inventing a diagnostic stream they do not use would be adding a distinction the tools do not make. - Each `main` is now three lines -- measure, render, emit -- and is the only place naming the real - stream. - One find during the conversion that the mechanical part would have missed: `render` called - `fingerprint::print_banner()`, which writes to stdout *itself*. Left alone it would have put the - identifying line on the terminal while leaving it out of the returned report, so a captured report - would be missing the one line saying which machine produced it -- and the `!!SYNTHETIC!!` taint - marker with it. `banner_line()` already existed for exactly this and is now used. - Verified as a pure refactor by running both probes before and after and comparing with numerals - masked (their output is timing-dependent, so byte equality is not available): 38 lines and 50 lines - respectively, **structurally identical** both times. - -- [x] **SH-13.4** -- **The other twelve probes still print directly, and now there is a sink to - adopt.** `probe-peer-index-cache` (55 sites), `probe-request-cost` (45), `probe-topology` (32), - `probe-queue-contention` (27), `probe-ioring` (24), `probe-completion-port` (22), - `probe-worker-context` (22), `probe-device-map` (21), `probe-cancel-io` (19), - `probe-pool-growth` (16), `probe-handle-state` (14), `probe-error-mode` (10) -- 307 sites. - Deliberately **not** done in the review round that introduced the sink: those probes predate it and - are outside that round's scope, and each conversion needs its own before/after comparison against - the probe's real output, which is what makes it a refactor rather than a rewrite. - Queued rather than left as a note precisely because a half-adopted abstraction is the state most - likely to be forgotten -- the next probe author will see twelve neighbours printing directly and - reasonably conclude that is the house style. +> **M7 through M13 -- seven PR #56 review rounds -- are complete and archived.** Moved to +> [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md) on 2026-09-02. M14 and M15 stay below because they +> carry open work. ## M14: PR #56 ninth review round -- an ABA hole the wrap test would not have caught @@ -782,24 +372,22 @@ counter; nothing protects the decision. `producers` stays `AtomicUsize`: it is a handle refcount, not a position, and nothing compares it against one. -- [ ] **SH-14.3** -- **Decide the fix, which is a design decision rather than a patch.** Recorded - here so the options are not re-derived, with what each costs: - 1. **Widen the counter so it cannot lap.** For `slotwise_mpsc` this is `AtomicU64` instead of - `AtomicUsize`, which is free on 64-bit and costs a `cmpxchg8b`-class operation on 32-bit x86. - For `reserving_mpsc` there is **no room**: 32 position bits plus 32 reservation bits is exactly - the 64-bit word, and `MAX_RESERVED` must cover `BOUNDS_MAX` (2^31), so no generation field can - be carved out without narrowing the capacity the shape offers. - 2. **Narrow `reserving_mpsc`'s capacity to buy generation bits.** A smaller `BOUNDS_MAX` frees - bits in both halves. This does not *eliminate* the lap, it lengthens it -- any finite field - wraps -- so it is a mitigation whose adequacy has to be argued rather than a fix. - 3. **Re-validate after the claim.** Cheap to say, hard to do: once the exchange succeeds the - position is claimed, so there is no safe way to back out without a second protocol. - 4. **Drop 32-bit support explicitly** -- resolves SH-14.2 only, and leaves SH-14.1 untouched - because that one is target-independent. This narrows the platform, so per the repository's - platform-integrity rule it is the engineer's decision and not one to take in passing. - Whatever is chosen, the property is not observable from a test that merely crosses the wrap: it - needs a producer *held* between its decision and its exchange, which means a deliberate seam -- - the crate's existing race hooks (`ARM`, `CLEAR`, `CLAIM`) are the shape of what is needed. +- [x] **SH-14.3** -- **SUPERSEDED BY SH-15.6, which is the same decision with better information.** + Checked off as *asked and answered elsewhere*, not as decided: the decision itself is still open, + and it is open in exactly one place now rather than two. + This item enumerated four options and asked for a choice. Keeping it open beside SH-15.6 meant two + live items for one decision, and worse, **this one's option list is now wrong in three ways**: + option 1 said widening is impossible for `reserving_mpsc`, which is true only of its *own* word -- + [D-37](crates/windows-waitable-queues/DESIGN-NOTES.md#d-37) widens a separate shape instead; + option 4's "drop 32-bit" turns out to be entailed by option 1 rather than an alternative to it + ([D-18](crates/windows-waitable-queues/DESIGN-NOTES.md#d-18), amended); and the list has no entry + for the claim protocol that was since built and measured + ([D-35](crates/windows-waitable-queues/DESIGN-NOTES.md#d-35)), which is the current front-runner. + A stale option list competing with a current one is how a decision gets re-litigated from the wrong + premises. The live list is SH-15.6's. + Its one surviving contribution is the observation that no test crossing the wrap can witness the + bug -- a producer must be *held* between its decision and its exchange. That is not lost: it is + SH-15.7, which owns the seam. - [x] **SH-14.4** -- **Every statement of the wait protocol was missing its last step.** Five findings, one cause. `blocking::recv` has always had four steps -- pop, `arm`, **check disconnection and take @@ -821,22 +409,28 @@ counter; nothing protects the decision. no code today, so this compiles nothing -- it is there so the first example somebody adds is compiled rather than trusted, this round being the demonstration that prose nothing executes rots. -## M15: the claim protocol, prototyped rather than argued (SH-14.3's decision) +## M15: the claim protocol, prototyped rather than argued (absorbs SH-14.3) -**This milestone exists to answer SH-14.3 with a measurement.** SH-14.1 is a real correctness hole -and SH-14.3 lists four ways out, all of which either lengthen the lap or narrow the platform. Prior-art -research (recorded in the design note item below) found a fifth shape that removes the hazard by -construction, and a sixth that additionally changes the queue's progress condition. Neither can be -chosen on reasoning alone, because [D-26](crates/windows-waitable-queues/DESIGN-NOTES.md#d-26) -already measured that the single shared line is what collapses under contention -- so an "obviously -cheaper" claim protocol that touches two shared lines instead of one may well be slower. +**This milestone owns SH-14.1's fix, and SH-15.6 is where it is decided.** It absorbed SH-14.3, whose +four options were stale before they were chosen from. The candidates now on the table are three, not +four: the permit claim (built and measured, SH-15.3/SH-15.5), the wide claim word (planned, SH-15.9), +and doing nothing but the disclosure already shipped at SH-15.8. None could be chosen on reasoning +alone, because [D-26](crates/windows-waitable-queues/DESIGN-NOTES.md#d-26) had already measured that +the single shared line is what collapses under contention -- so an "obviously cheaper" claim protocol +that touches two shared lines instead of one might well have been slower. It was not +([D-35](crates/windows-waitable-queues/DESIGN-NOTES.md#d-35)), which is exactly why it was measured. -**Built as duplicated paths, per the repository's platform-integrity rule.** Neither arm modifies -`reserving_mpsc`. The shipping shape keeps working and keeps its tests green while the speculative -ones are proven or discarded, and the merge-or-delete decision is SH-15.6 rather than something that +**Built as a duplicated path, per the repository's platform-integrity rule.** The prototype does not +modify `reserving_mpsc`: the shipping shape keeps working and keeps its tests green while the +speculative one is proven or discarded, and merge-or-delete is SH-15.6 rather than something that happens by drift. -**The principle both arms are instances of**, stated once so it is not re-derived: *the atomic +**Numbering note: there is no SH-15.4.** It was the second arm -- the per-cell cycle claim -- and it +moved to `SH-inf.1` when in-order delivery, inline storage and non-blocking progress turned out to be +over-constrained together. The number is left vacant rather than reused, so a reference to SH-15.4 in +an older commit still resolves to something. + +**The principle the prototype is an instance of**, stated once so it is not re-derived: *the atomic operation that authorizes the write must cover everything the decision depended on.* Today's protocol decides "there is room" from a separately-read `head` and then compare-exchanges only the claim word, so a full recurrence of the 32-bit position field revalidates nothing. Every fix below closes that @@ -897,7 +491,10 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. one plus a read. - [ ] **SH-15.5.1** -- **Settle why the two shapes' refusal counts differ by orders of magnitude, - because SH-15.6 cannot be decided without it.** In the drained regime `permit_mpsc` recorded + because SH-15.6 cannot be decided without it.** + **Spawned by SH-15.5, not a leftover of it.** SH-15.5 is checked because its own action -- taking + the measurement -- is finished; this is new work that the measurement revealed, which is why the + pairing of a checked parent and an open child is correct rather than a contradiction. In the drained regime `permit_mpsc` recorded roughly 460,000 refusals at eight producers where `reserving_mpsc` recorded 0, and the counts are unstable across runs (`reserving_mpsc` itself recorded 0 and then 2,363 for the same configuration). Two candidate explanations, which the current harness cannot separate: the permit @@ -958,8 +555,13 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. - [ ] **SH-15.7** -- **Build the stall seam that can actually witness the bug.** SH-14.3 already notes the property is invisible to a test that merely crosses the wrap: it needs a producer *held* between its room decision and its exchange. The crate's existing race hooks (`ARM`, `CLEAR`, `CLAIM`) are the - right shape. Without this, every arm above is argued rather than demonstrated, and the fix that is - adopted has no regression test that would go red if it were reverted. + right shape. Without this, the prototype above is argued rather than demonstrated, and the fix that + is adopted has no regression test that would go red if it were reverted. + **COMPLEMENTS SH-6.1, and neither substitutes for the other.** That item drives the queue across + 2^32 and so exercises the arithmetic; this one supplies the stall that turns a wrap into the actual + defect. A reader who does only SH-6.1 will get a green run and conclude wrongly. + **This is the one M15 item that is worth doing whatever SH-15.6 decides** -- a fix with no test + that fails without it is a fix nobody can safely revisit. - [x] **SH-15.8** -- **Disclose SH-14.1 publicly, and gate 0.1.0 on the disclosure rather than on the fix.** **RELEASE BLOCKER.** The crate is days from its first publish with a known path to *silent diff --git a/COMPLETED-CHECKLIST.md b/COMPLETED-CHECKLIST.md index 12f27648..cc971610 100644 --- a/COMPLETED-CHECKLIST.md +++ b/COMPLETED-CHECKLIST.md @@ -1716,3 +1716,472 @@ capture-set formatting, declared emptiness, and restore reports; then rerun muta classify any survivors that are behaviorally equivalent. The final mutation run tested 233 mutants: 142 were caught, 91 were unviable, and none were missed. + +## Moved 2026-09-02 -- M1 of the topology/queues release: the public surface settled before publication + +Every item complete. The milestone existed because its decisions were free before 0.1.0 and expensive +after: a deleted public type costs a yank-and-migrate once published, and `Reserving`'s associated +type needed its bound before any caller could depend on the unbounded form. Moved from +[CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md). + +### M1: settle the public surface before it is public + +- [x] **SH-1.1** -- **MIRRORS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.8 -- one piece of + work seen from two plans. Check both off in the same commit; neither is done alone.** + **Decide M31.8 (merge-or-delete for `slotwise_mpsc` and `reserving_mpsc`) before the first + publish, not after.** This is the highest-leverage item in the file and it is release-blocking for a + mechanical reason: the decision may *delete a public type*. Doing that before 0.1.0 costs nothing; + doing it after means a breaking release, a yank-and-migrate for anyone who adopted it, and a + permanent line in the changelog explaining why a shape existed for one version. + The measurement is already done and agrees across both architectures -- see M31.5 and M31.7 in + [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) -- so this needs a decision, not more work. + +- [x] **SH-1.2** -- **GOVERNS [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M31.6 -- this is not + that item and does not complete it.** It decides only whether M31.6 blocks SH-4.3. If the answer is + "it gates", record that on M31.6 and SH-4.3 cannot proceed until M31.6 is done; if "it does not", + record that too, so a later reader does not mistake a considered choice for an oversight. **Checking + this off never checks off M31.6.** + **Decide explicitly whether M31.6 (loom verification) gates 0.1.0**, and record the + answer either way rather than letting it drift into "not yet". + + **Decided: it does not gate 0.1.0. It gates 1.0, and the gap is disclosed in the crate's own + documentation rather than left for an adopter to discover.** Recorded as D-31. + + Three findings drove it, and the second was not expected: + + - **Loom would close the demonstrated gap.** The sabotage sweep showed a weakened `Acquire` on the + producer's load of `head` survives the whole suite. That defect lives in queue code, which is + exactly what loom models well. + - **Loom would *not* close the gap where a real bug actually occurred.** The doorbell's correctness + is the interleaving of an `AtomicBool` mirror with real `SetEvent`/`ResetEvent` syscalls. Loom + models the atomics and cannot model the syscalls; stubbing them tests a *model* of `SetEvent` + rather than `SetEvent`. D-15's lost wakeup -- the only ordering bug this crate has actually had -- + was found by sabotage, and loom would not have found it. So loom is valuable and is **not** the + thing standing between this crate and confidence about its hardest part. + - **The risk loom addresses is mostly regression risk**, and that risk is lowest now. The orderings + are believed correct and were reasoned about at the time; sabotage *introduced* the weakening to + prove the suite was blind to it. Regression risk rises with contributors, changes, and consumers + -- all of which start after publication, not before. + + Against that, gating would block 0.1.0, and through it the placement tool and the NUMA measurements + from other people's machines that this whole sequence exists to obtain. Loom is invasive work: every + atomic in the crate goes behind a `cfg` shim across four modules. + + **The disclosure is what makes this a decision rather than a punt**, and it is not optional: the + crate documentation states what is verified, states that stress testing here is *known* not to catch + ordering defects and cites the measurement showing it, and says loom is planned before 1.0. An + adopter then makes their own call with the same information we have. `0.x` carries the rest. + The reason it deserves a deliberate answer rather than a default: the sabotage sweep demonstrated + that weakening the producer's `Acquire` load of `head` to `Relaxed` left **all twenty tests green**, + while every logic defect injected beside it was caught. So this is not an untested-by-omission gap, + it is a gap this workspace has *evidence* the existing tests cannot close. Publishing a lock-free + queue with it open is a defensible choice; making it unknowingly is not. + +- [x] **SH-1.3** -- **Qualify both MPSC shapes by name.** `mpsc` beside `reserving_mpsc` made one + canonical by implication -- which contradicts this crate's own "no shape is the canonical one", and + after SH-1.1 is simply false. Renamed to `slotwise_mpsc`, which names its claim protocol: it claims + slot by slot with no shared counter. `sequence_mpsc` was considered and rejected for inviting the + reading that it alone preserves FIFO order, which both shapes do. Recorded as D-30. + **Belongs in M1 for the same reason SH-1.1 does**: it is a public-surface change, free before the + first publish and a breaking rename with a deprecation path afterwards. + +- [x] **SH-1.4** -- **State the algorithms' pedigree and why an existing crate is not used.** A public + concurrent-queue crate has to answer both questions or a reader assumes the worst: that the + algorithms are homegrown, and that the author did not look at the alternatives. + Neither is true, and the honest answers are load-bearing. The algorithms are *published designs* + chosen deliberately, because a concurrent queue is a bad place to be original -- the failure mode is + a reordering that appears on one machine, under load, months later. And the reason no channel crate + fits is structural rather than dismissive: **on Windows, waiting is a kernel-object operation**, so a + queue whose readiness is not a `HANDLE` cannot join a `WaitForMultipleObjects` alongside an I/O + completion, a process handle, or a cancellation event -- however good its own blocking receive, and + however rich its own `select`, which can only select over its own channels. + Written into both the crate docs and the README, because docs.rs shows one and crates.io the other. + +- [x] **SH-1.5** -- **Bound `Reserving::Reservation<'a>` so a generic caller can redeem what it claims.** + Done: the `Claim` trait carries `send` and `is_disconnected`, `Reservation<'a>` is bound on it, and + both reservation types implement it as forwarders. 87 lines across five files, no concrete signature + changed, `slotwise_mpsc` untouched because it does not implement `Reserving` at all. Mutation-tested + rather than assumed: 62 mutants over the whole reservation surface report 0 missed, and the first + run found a real gap -- `is_disconnected` stuck at `false` survived on `spsc`, because the connected + case was asserted there and the disconnected case only on the other shape. + The associated type is declared with no bound at all, so a caller generic over + [`Reserving`](crates/windows-waitable-queues/src/traits.rs) can call `reserve()` and then do + nothing with the result except drop it. `reserve` is `#[must_use]` precisely because a held claim + withholds capacity from every other producer -- and the one operation that discharges it, `send`, + is inherent to each shape's concrete type and unreachable through the trait. The trait cannot + express the operation it exists for. + + **The two implementors already agree exactly, so this is additive**: both + `spsc::Reservation<'a, T>` and `reserving_mpsc::Reservation` already have + `send(self, item: T) -> Result<(), Disconnected>`, `is_disconnected(&self) -> bool`, and a + `Drop` that returns the slot. No concrete signature changes; nothing to migrate. + + Add a `Reservation` trait carrying `send` and `is_disconnected`, bound the associated type on it, + and implement it for both types. `is_disconnected` is included rather than deferred for the reason + the `Reserving` docs give at length -- a caller needs to learn the stream ended *before* doing the + work the claim was taken for -- and because `reserving_mpsc`'s reservation is `Send`, so it may be + redeemed on a thread holding no producer handle to ask instead. Adding it later is the same + breaking change, merely deferred. + + **Why this blocks rather than waits.** Adding a bound to an associated type is a breaking change + to the trait: every implementor must then satisfy it. It is free while the crate is unpublished + and a major bump with a migration afterwards, and this is the milestone that exists to settle + exactly that -- see SH-1.1 and SH-1.3, both landed on the same "free before the first publish" + reasoning. D-3 already makes this argument ("the trait *shape* is fixed now so signatures stay + compatible"); this is the same reasoning applied to a piece it missed. Pull request #56 is what + puts these traits in front of consumers, so the window closes when it merges. + + **How it surfaced**, recorded because the route is the useful part: not from review and not from a + failing test, but from a `cargo mutants` run showing that nothing exercised the capability traits + at all, and then from being unable to write the obvious generic test for `Reserving` -- the test + in [traits/tests.rs](crates/windows-waitable-queues/src/traits/tests.rs) is scoped to claim-and-release + and says so. A contract gap presenting as an untestable API is a signal worth keeping. + + Extend that test to claim-and-redeem through the trait as part of this item, since it is the + check that would have caught the gap in the first place. + +## Moved 2026-09-02 -- M7 through M13: seven PR #56 review rounds, all findings resolved + +Seven consecutive automated-review rounds on the pull request that ships `windows-topology-sys` +0.2.0 and `windows-waitable-queues` 0.1.0, kept as milestones so each round's findings stayed +attributable to the round that raised them. All items complete. Moved from +[CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md); the two later rounds +(M14, M15) remain there because they carry open work. + +### M7: PR #56 automated-review round + +The findings an automated review raised against the pull request that lands this work, verified against +the source before being accepted. Each item names what was checked, so a later reader can tell a real +repair from a reviewer's guess that was taken on trust. + +- [x] **SH-7.1** -- **`reserving_mpsc` reports `Full` from a claim word that was never current.** + `push` and `reserve` load the claim word relaxed, then test room with + `has_room_beyond_reservations(position, reserved)`, which computes + `position.wrapping_sub(head)`. If other producers claim and publish past `position` and the consumer + drains them while this thread is between the load and the room check, `head` passes the stale + `position` and the subtraction wraps to near `u32::MAX` -- so the queue reports `Full` (and records a + refusal) at the moment it is empty, and `reserve` returns `None` for the same reason. The compare-and- + swap that would have caught the staleness is never reached, because both paths return before it. + Re-read the claim and retry when it moved; report no room only from a word still current. + +- [x] **SH-7.2** -- **The NUMA cross-check compares a count against a highest identifier.** + `windows-platform-probes`'s `Observation::cross_check` compares `numa_domains` (a count of memory + domains) with `GetNumaHighestNodeNumber() + 1`. Windows documents that value as the highest node + *number*, and does not guarantee node numbers are dense -- nodes 0 and 2 give a count of 2 and a + highest of 2, and the probe then reports a parsing regression on correct hardware. Memory domains + already carry the node number in `Domain::id`, so compare highest against highest. + +- [x] **SH-7.3** -- **A cache level is called a partition without checking that it is one.** + `cache_partitions_at_level` deduplicates by equal processor set, which is exactly right for the + measured case it was written for (L1i and L1d over identical sets). It does not establish a + *partition*: `Topology` is deliberately constructible by hand and by deserialization (D-12), so + distinct-but-overlapping sets reach `outermost_partitioning_cache`, which returns them as domains a + consumer then double-counts. Require the distinct sets to be pairwise disjoint before a level + qualifies as partitioning. + +- [x] **SH-7.4** -- **`windows-waitable-queues` cannot build its documentation on docs.rs.** The crate + is Windows-only and imports `std::os::windows::io` unconditionally, but its manifest omits the + `[package.metadata.docs.rs]` target block that every other published Windows-only crate here carries, + so docs.rs would build it for its default Linux target and fail. Add the same block. + +- [x] **SH-7.5** -- **The mutant injector replaces every occurrence on the line, not the first.** + `tools/inject-mutant.ps1` calls the *static* `[regex]::Replace(input, pattern, replacement, 1)`, whose + fourth parameter is `RegexOptions` -- `1` is `IgnoreCase`, not a replacement count, and no static + overload takes a count at all. The tool therefore does precisely what its own header comment says it + exists to avoid. Fix the replacement, refuse a line whose pattern occurs more than once unless a + column disambiguates it, verify the baseline is green before trusting a "caught", run with all + features so a feature-gated mutation is not reported as surviving, perform the mutating write inside + the guarded region so a failed write still restores, and route its output through one sink. + +- [x] **SH-7.6** -- **A spike that fails to run is reported as a finding about the machine.** + `tools/run-numa-spikes.ps1` checks the exit code of `cargo build` but not of `cargo run`, then decides + vacuity by searching the output for `VACUOUS`. A crashed spike prints no such line, so the summary + says "**NOT vacuous -- this runner has more than one NUMA node**" and the script exits 0. That is the + instrument breaking while claiming a result, which the script's own documentation says is the one + thing worth failing over. + +- [x] **SH-7.7** -- **Two tools write output from several sites, and two hazards remain in the + sabotage/mutation harness.** `tools/check-publishable.ps1` and `tools/inject-mutant.ps1` each call + `Write-Host` from several places, against the repository's one-output-sink rule. + `tools/run-sabotage.ps1` performs its patching write before entering the `try` whose `finally` + restores the file, so a write that throws part-way leaves the clean source damaged. + `tools/run-mutants.ps1` derives a deterministic output directory per package or file, so a second run + of the same scope overwrites the analysis the parameter documentation promises to preserve. + The placement probe's tests name scratch directories without the process id, so two concurrent test + processes -- which the documented `-j 2` mutation workflow creates -- delete each other's fixtures. + +- [x] **SH-7.8** -- **Reply to every thread and resolve the ones that are addressed**, including the one + finding that was checked and found not to hold: `GetSystemDirectoryW` returning exactly the buffer + length is unreachable (success excludes the terminator, failure includes it and so exceeds the + buffer), though the guard is widened anyway so the next reader need not redo the analysis. + +### M8: PR #56 third review round (suppressed findings) + +The reviewer generated no new inline comments in these rounds and instead listed **suppressed** findings in +the review body, so none of them arrived as a resolvable thread. They are recorded here because a finding +that produces no thread is otherwise invisible to the "are all comments resolved?" check that gates merge. + +- [x] **SH-8.1** -- **The contention probe times thread creation, and lets early producers run alone.** + All five timed runs in `windows-platform-probes`'s `queue_contention` start the clock *before* + `thread::scope` spawns anything, and every worker begins pushing the moment it is spawned. At 50,000 + pushes each, an early producer can finish a large uncontended prefix -- or finish outright -- while the + last threads are still being created, so a row labelled 16 or 32 producers may never have had 16 or 32 + contenders. The measured interval also includes spawn cost. This is not a cosmetic inaccuracy: the + module's own header says these numbers decide whether two speculative queue shapes get written at all + and whether the two shipped shapes merge. Hold every participant -- producers *and*, in the drained + runs, the consumer -- at a start barrier, and start the clock when it releases. + +- [x] **SH-8.2** -- **A failed backup write leaves a truncated file under the canonical name.** + `write_backup_to_new_file` reserves the name with `create_new` and then `write_all`s through `?`, so a + disk-full or quota failure returns an error while leaving a zero-length or partial `.json` behind. That + file is indistinguishable from a real record to whoever collects it, and the next run's collision + suffix steps politely around it. Publish by rename: write the bytes to an exclusively-created temporary + in the same directory, flush, and move it onto the reserved name only once the write has succeeded. + +- [x] **SH-8.3** -- **`places_from_topology` drops processors and invents NUMA membership.** + Two defects in one conversion, both reachable only through a hand-built or deserialized `Topology` -- + which is exactly the input this seam exists to accept (D-12). + It iterates `class_of`, which is populated only from `DomainKind::Core` domains, so an online processor + with no core domain is **silently absent from the result** -- and the documented core-id fallback + beneath it, written to keep group 1's cpu5 distinct from group 0's, is unreachable dead code as a + direct consequence. + It then defaults absent NUMA membership to `unwrap_or(0)`. That is the right answer only when the + topology names no memory domain at all; when it names nodes 1 and 2, it **fabricates node 0** and files + a processor under a node the machine does not have -- the precise failure this crate's own rule + ("a seam that only moves data is safe; a seam that lets fabricated labels reach real hardware is not") + exists to prevent. + Iterate the online processors so every one is placed, and refuse a topology that names memory domains + but not this processor's, rather than inventing one. + +### M9: PR #56 fourth review round + +- [x] **SH-9.1** -- **Both bounded shapes could report a length larger than their capacity.** + `len` reads the producer-side position and then `head`, which are two instants; a consumer draining + past the sampled position makes the wrapping subtraction yield a number near the integer maximum. The + comment beside it claimed the overestimate was "safe in the direction that matters for a backpressure + gauge", which is true of a *bounded* overestimate and not of `usize::MAX`. Both are now clamped to the + capacity, so the skew still resolves towards full -- the safe direction -- while the impossible value + is gone. + +- [x] **SH-9.2** -- **`reserving_mpsc` inherited a `remaining()` that counted reserved slots as room.** + `Bounded::remaining` defaults to `capacity - len`, and this shape's `len` excludes reservations by + design, so an empty queue of four holding one reservation answered four while only three items fit -- + promising room for a push guaranteed to be refused. Overridden on both handles and both trait impls, + reading the packed claim word **once** so the position and the reservation count cannot be sampled at + different instants; `is_full` is now defined in terms of it rather than restating the rule. + +- [x] **SH-9.3** -- **The pull request description described the release plumbing, not the product.** + The body framed the change as CI and provenance work and mentioned `windows-waitable-queues` only + under release tracking, while the majority of the diff is that crate's public API and its three + lock-free queue implementations. Rewritten to lead with the shipped surface. + +### M10: PR #56 fifth review round + +- [x] **SH-10.1** -- **`BOUNDS_MAX` does not compile on a 32-bit target.** `reserving_mpsc`'s maximum + was a flat `1 << 31`, derived from the packed position's width alone. On a 32-bit target the + crate-wide `WRAPPING_MAX_CAPACITY` is `usize::MAX / 2`, which is `2^31 - 1` -- *narrower* than the + packing -- so the const assertion that no shape may exceed it fails the build outright, for every + capacity including the small valid ones. Now the narrower of the two limits, kept a power of two so + the value stays one a caller could actually pass. Verified in both directions against a real + `i686-pc-windows-msvc` check: the old constant fails with `E0080`, the new one compiles. + +- [x] **SH-10.2** -- **The backup's final name was visible empty for the whole write.** The previous + round reserved the destination with `create_new` and renamed onto it, which fixed the truncated-file + case and left a worse one: an empty file under the record's own name for the duration of the write, + and permanently if the process was killed in that window -- contradicting the absent-or-complete + guarantee its own doc comment claimed. Publication is now a single atomic no-replace `MoveFileExW` + from a fully-written temporary. `std::fs::rename` cannot express this: on Windows it always passes + `MOVEFILE_REPLACE_EXISTING`, so it would clobber a record a concurrent run had placed. + +- [x] **SH-10.3** -- **The tool discovered the topology three times.** The plan used one reading, the + fingerprint another, and `core_affinity::measure` a third, so a processor going offline mid-run could + have the announced plan, the recorded host, and the measured rows describing different machines with + nothing saying which. The plan and the fingerprint now derive from one `Topology::discover`. + `measure` still discovers its own, and deliberately so: its documentation refuses a + `measure_with(places)` seam because a supplied list's processor *numbers* stay valid on the real host + while its node labels need not, so every pin would succeed and real timings would be filed under + fabricated labels. Its rows carry their own places, so each row states what it measured. + +- [x] **SH-10.4** -- **`spsc` had the same `remaining()` defect, and it was missed.** The previous round + corrected `reserving_mpsc` and stopped there, but `spsc` implements `Reserving` too -- so reserving + every slot left it reporting the full capacity as available while both `push` and `reserve` refused. + Its `Bounded` impls now override `remaining` on the producer *and* the consumer, its `len` is clamped + to the capacity like the other two shapes', and `is_full` is defined in terms of `remaining` rather + than restating the rule. The trait's default now documents that a `Reserving` shape must override it, + so the next shape to reserve does not inherit the same wrong answer silently. + +- [x] **SH-10.5** -- **The high-water depth could record a peak the queue never reached.** + `reserving_mpsc`'s `publish` sampled the depth from its own position and a relaxed load of `head`, + ungated and unclamped. `slotwise_mpsc`'s twin is bounded by construction -- its producer's acquire + load of the slot's sequence synchronizes-with the consumer freeing that slot, so `head` cannot be + older than `position - capacity + 1` -- but this shape has a second entry point with no such edge: + `Reservation::send` redeems without a room check, so the only `head` its thread is ordered against is + the one *`reserve`* read, which may be arbitrarily old by the time the reservation is redeemed. The + sample is now gated on tracking (parity with the twin), read before publication, and clamped to the + capacity. + `Observable::high_water`'s contract is corrected to match what all three shapes actually deliver: an + **upper bound** on the true peak, never below it and never above the capacity, with the reason the + cheap sample is preferred to an exact count. Counting exactly would put a read-modify-write on a line + shared by every producer and the consumer into every push and every pop -- the line this crate pads + its positions apart to keep out of the hot path. + +### M11: PR #56 sixth review round + +Three findings against `places_from_topology`, all of the same shape, plus three against the +mutation wrapper. The conversion's three silent fallbacks are replaced by one rule. + +- [x] **SH-11.1** -- **Three fallbacks each invented an answer that reads as a real one.** + `places_from_topology` accepted a topology whose domains do not cover every processor, and filled + each gap with a value indistinguishable from a measured one. A processor absent from every core + domain was given a synthetic core id derived from its group and number, which can equal a real + core domain's id -- `classify` then reports two processors as SMT siblings when one's core is + merely unknown. Its efficiency class became `0`, which is also a genuine Windows class, so + `within_class_pair` reports a same-class pair against a real class-0 core. Its cache domain became + `None`, which the type already means "no cache level partitions this machine" -- so two processors + omitted from an incomplete partition compare equal and serialize a confident same-cache + measurement. + The three share one cause: an absence was read as a value. The rule now distinguishes *uniform* + absence from a *gap*. A machine that reports no core domains at all, or no partitioning cache + level, has told us something true about itself and still converts. A machine that places every + other processor but not this one has told us nothing about this one, and the conversion refuses: + `places_from_topology` returns `Err(UnplacedProcessor)` naming the processor and, in a new + `MissingPlacement` field, which of core / cache domain / NUMA node was missing. + `MissingPlacement` is `#[non_exhaustive]`. + Core and efficiency class are two spellings of one rule -- `Topology::cores()` filters to + `DomainKind::Core`, so a processor's class is known exactly when its core is -- and an + `EfficiencyClass` variant written for the second was removed on discovering it is unreachable. + Sabotage confirms the pair behaves that way: removing either refusal alone leaves the suite green, + because the other still fires; removing both fails two tests. + +- [x] **SH-11.2** -- **The mutation wrapper's output directory could collide.** The stamp has + one-second resolution, so two runs launched in the same second -- a script starting several scopes + at once, which is exactly the case that wants separate output -- selected the same directory and + interleaved their results. A short random suffix now follows the stamp, which still sorts + chronologically. + +- [x] **SH-11.3** -- **The wrapper terminated fault handlers it did not start.** Cleanup matched + `WerFault` / `WerFaultSecure` / `vsjitdebugger` by name across the whole session, so a crash report + the user was reading or a debugger attached to an unrelated process was killed by a mutation sweep. + The wrapper now records the ones already running at startup and skips them. + +- [x] **SH-11.4** -- **The `mutants.out` nesting finding does not hold; documented in place.** + The report was that `Join-Path $OutputDirectory 'mutants.out'` doubles a path cargo-mutants already + appends. It does not: cargo-mutants treats `--output` as the parent and creates `mutants.out` + inside it. Verified on disk -- a run with `--output .scratch\mutants-encoding-` produced + `\mutants.out\caught.txt` with 22 lines, matching the 22 caught the wrapper reported. A + comment now records the evidence, since the path reads like a duplication and has been challenged + once already. + +- [x] **SH-11.5** -- **A hard-killed run could block the next one's backup entirely.** The temporary + was named for the record plus this process's id and nothing else, and created with `create_new`. A + run killed mid-write leaves that file behind, and Windows reuses process ids -- so a later run + issued the same id found the corpse under the only name it would ever try. The resulting + `AlreadyExists` left `write_temporary` *before* the caller's suffix loop was reached, so the whole + backup failed rather than landing under a next-best name. The temporary now carries its own + attempt counter, matching the final name's budget; a stale file is stepped around rather than + overwritten, since it belongs to whatever left it. + +- [x] **SH-11.6** -- **Both tools bypassed their own single-output-sink contract.** `run-mutants.ps1` + emitted its per-category summary directly to the success stream, and `run-sabotage.ps1` did the + same for the `-List` output, every blank line, the result table, and the injected patch text -- + each contradicting the `Write-Report` doc comment directly above them. All now route through the + sink, which gained pipeline binding so a formatted table can flow into it. Verified: the `-List` + path emits zero objects to the success stream. + +- [x] **SH-11.7** -- **The sabotage harness silently narrowed its own sweep.** Found while checking + SH-11.6's output: the harness prepends the `test` subcommand to a manifest's `testArgs`, but nine + of the eleven manifests already begin with `test`. The result was `cargo test test -p ...`, in + which the second word is not a subcommand but a TESTNAME filter -- a sweep claiming to run a + package's suite while running a subset of it, the same false-green this tool exists to prevent. + The vector is now normalised so either manifest spelling produces one `test`. + **No prior verification was weakened**: every crate swept so far keeps its tests under a + `mod tests`, so the accidental filter matched all of them, and all fourteen recorded baselines + report "0 filtered out". The defect was latent, and would have appeared on the first crate laid + out differently. + +### M12: PR #56 seventh review round + +- [x] **SH-12.1** -- **The banner undercounted the machine it was about to measure.** + `Fingerprint::processors` is documented as the logical-processor count but was summed over + core-domain membership, which agreed with that meaning only while every processor was guaranteed to + sit in a core domain. SH-11.1 stopped guaranteeing it: `places_from_topology` now explicitly accepts + a topology naming no cores and places every online processor. The banner consequently read + `0p/0c` for a machine the measurement was about to use four processors on -- a defect this branch + created rather than inherited. + The count is now read off `topology.processors` with the same `online` filter the placement applies, + so the summary counts exactly what the measurement will use. `cores` deliberately still counts core + domains: zero there is the honest report that the topology named none. + Sabotage-verified. Two new tests pin both directions -- an uncored processor is still counted, an + offline slot is not -- and each asserts equality against `places_from_topology`'s own output rather + than a literal, so the two cannot drift apart again. + +- [x] **SH-12.2** -- **Two consequences of that fix, found by sweeping it rather than reported.** + `cache_domain_sizes` fills itself with the processor count when no cache level partitions the host, + so the bare-machine render silently improved from `L-[0]` to `L-[4]`. + `numa_node_sizes` did not, and now does not sum to `processors` in that one case: it reports the + nodes the topology *named*, and a bare topology names none, while every placement still reports the + documented node-`0` default. Keeping it that way is deliberate -- the cache list can afford to fill + itself because `L-` marks the absence, and `numa[4]` would be indistinguishable from a host that + genuinely reported one node of four. The behaviour is now documented on the field and pinned by a + test that asserts the whole render, including the `!!SYNTHETIC!!` provenance marker. The stronger + fix needs a marker, which is a serialized field and so a schema bump, tracked as + [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) `PT-6.2`. + +### M13: PR #56 eighth review round + +- [x] **SH-13.1** -- **A record could splice two machines together.** The tool announced a shape read + at one instant while `core_affinity::measure` discovered again at another, so a processor going + offline -- or moving group or node -- between them produced a record whose `host` described one + machine while every row was measured on a different one. Nothing in the file said so, and the host + is precisely what a reader interprets row sets *through*. + `measure` now reports the shape it actually ran on, as `Observation::host`, which is the fix that + keeps the anti-synthetic boundary intact: the measurement still discovers for itself and no seam + accepts a fabricated shape from outside. `SubmissionRecord::new` refuses when the announced and + measured hosts differ, so the splice is unrepresentable rather than merely avoided at the one + current call site; the tool checks first anyway and reports the disagreement in terms a runner can + act on. Refusing rather than silently recording the measured shape, because the notice is what the + runner consented to. + +- [x] **SH-13.2** -- **The tool wrote from 54 independent print sites.** The repository's + one-output-sink rule requires an output abstraction at the *first* output site so the storage + target and the formatting stay separable from the call sites that compose content. This binary had + none, which is why its collection notice -- a disclosure a runner reads before agreeing to publish + facts about their machine -- could only be exercised by running the process and capturing stdout. + A `Sink` trait now carries the two streams the tool genuinely has, `print_collection_notice` and + `print_plan` became `render_*` functions returning a `String` (matching the idiom the record report + already used), and `main` is the only place that names the real streams. + Verified as a pure refactor by comparing the built binary's output before and after: `--preview` + and `--help` are **byte-identical**, and `--version` differs only by the build identity correctly + reporting the working tree as `DIRTY`. Eight new tests cover what was previously unreachable, + including that the notice shows the model rather than describing it, that a withheld model reads + differently from one the host would not report, and that the two streams cannot satisfy each + other's assertions. + +- [x] **SH-13.3** -- **The two new probes wrote from 94 independent print sites between them.** Same + rule as SH-13.2, in [core_affinity.rs](crates/windows-platform-probes/src/bin/core_affinity.rs) + (67 sites) and [doorbell_cost.rs](crates/windows-platform-probes/src/bin/doorbell_cost.rs) (27). + A `Report` sink now lives in [report.rs](crates/windows-platform-probes/src/report.rs), shared by + both. One stream, not two: unlike the placement probe these have only ever written to stdout, and + inventing a diagnostic stream they do not use would be adding a distinction the tools do not make. + Each `main` is now three lines -- measure, render, emit -- and is the only place naming the real + stream. + One find during the conversion that the mechanical part would have missed: `render` called + `fingerprint::print_banner()`, which writes to stdout *itself*. Left alone it would have put the + identifying line on the terminal while leaving it out of the returned report, so a captured report + would be missing the one line saying which machine produced it -- and the `!!SYNTHETIC!!` taint + marker with it. `banner_line()` already existed for exactly this and is now used. + Verified as a pure refactor by running both probes before and after and comparing with numerals + masked (their output is timing-dependent, so byte equality is not available): 38 lines and 50 lines + respectively, **structurally identical** both times. + +- [x] **SH-13.4** -- **The other twelve probes still print directly, and now there is a sink to + adopt.** `probe-peer-index-cache` (55 sites), `probe-request-cost` (45), `probe-topology` (32), + `probe-queue-contention` (27), `probe-ioring` (24), `probe-completion-port` (22), + `probe-worker-context` (22), `probe-device-map` (21), `probe-cancel-io` (19), + `probe-pool-growth` (16), `probe-handle-state` (14), `probe-error-mode` (10) -- 307 sites. + Deliberately **not** done in the review round that introduced the sink: those probes predate it and + are outside that round's scope, and each conversion needs its own before/after comparison against + the probe's real output, which is what makes it a refactor rather than a rewrite. + Queued rather than left as a note precisely because a half-adopted abstraction is the state most + likely to be forgotten -- the next probe author will see twelve neighbours printing directly and + reasonably conclude that is the house style. \ No newline at end of file diff --git a/PLANS.md b/PLANS.md index 64cb4a19..33815138 100644 --- a/PLANS.md +++ b/PLANS.md @@ -20,7 +20,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | -| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settles the public surface before it is public (M31.8 may delete a public type, which is free now and a yank-and-migrate later; SH-1.5 gave `Reserving`'s associated type the bound a generic caller needs, which had to land before publication); M2 repairs the plumbing; M3 lands 54 unmerged commits; M4 releases; M5 verifies from outside the workspace. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | +| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14 and M15 are the two later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | | [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | in progress | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Only the crates.io publication is gated on releasing `windows-topology-sys` and `windows-waitable-queues`** -- an earlier version of this row gated the whole tool on them, which was wrong: the canonical artifact is a CI-built binary compiled from this repository through `path` dependencies, and it needs nothing published. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M1B keys a processor by `(group, number)` so a machine with more than 64 of them is not silently miscounted; M1C measures each NUMA hop in both directions and places the ring deliberately; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 distributes the binary; M5+ publishes to crates.io once the download path is established. | [crates/windows-placement-probe/DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | From 986f1d76204be2f5bfe0fe109606a76079b3fb37 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 14:00:08 -0400 Subject: [PATCH 217/361] chore: never publish either probe crate to a registry, and delete their version pins Neither is on crates.io (checked against the registry API, not assumed), so the decision is free to make now and costs nothing to have made. windows-platform-probes was never going anywhere: it is a development instrument run from a checkout, and publish = false was already the whole story. windows-placement-probe is different and this REVERSES PT-5.3, which had decided "yes, publish -- but not yet". The tool still ships; it ships as a CI-built binary attached to a GitHub release, which is what it always did and what M5 already called canonical. Two reasons the reversal is on new information rather than taste: The reach premise was backwards. A released binary needs no Rust toolchain, while cargo install needs a toolchain, a compiler, and a build of the whole dependency tree -- so crates.io reaches a SUBSET of the download path's audience. That is a convenience for Rust developers, bought by making the weakest-provenance path the most discoverable one. M5's own preamble had already said the download "needs no Rust toolchain" and is "the provenance"; the reach argument contradicted a conclusion this project had reached. And a cost PT-5.3 could not have known. A published crate cannot depend on a bare path, and cargo enforces the resulting version pins at EVERY BUILD rather than at publication -- which the old note assumed. Measured: topology at 0.2.0 against a "0.1.0" pin fails cargo metadata for the whole workspace. Publishing would have made pin maintenance a permanent tax on a tool whose value is being re-run and revised. So the pins are deleted rather than maintained: eight version fields across the two crates, all now path-only. That is more than the two topology ones -- every versioned path dep had the same latent failure, and the six others were simply pointing at crates that had not bumped yet. The effect on the release is concrete. Before this, a topology bump broke the workspace in three places and release-please could only fix one, because the other two crates are not in its packages map -- so merging its release PR would have landed a red main. Now exactly one pin remains, in windows-ioring-sys, which IS release-please-managed. Verified by simulation: with the two removed, cargo metadata against a bumped topology fails naming ioring and nothing else. Checklist and design-note updates follow the reversal rather than merely noting it: PT-5.3 records the reversal with both reasons, M5+ becomes a WITHDRAWN heading rather than a gap, PT-5.6 is checked as decided-against, and the two SH-4.x items that claimed to gate it now say the gate is void. A prerequisite that outlives the item needing it is how work gets blocked on nothing. One of PT-5.6's three "must not skip" points survived its withdrawal and is rescued as PT-5.7 rather than lost: the README should still say that a locally built copy produces records marked unofficial, because a runner can still build one from source. That point never depended on crates.io. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 75 +++++++++++-------- CHECKLIST-ship-topology-and-queues.md | 60 ++++++++------- crates/windows-placement-probe/Cargo.toml | 15 +++- .../windows-placement-probe/DESIGN-NOTES.md | 38 ++++++++++ crates/windows-platform-probes/Cargo.toml | 24 ++++-- .../windows-platform-probes/DESIGN-NOTES.md | 21 ++++++ 6 files changed, 163 insertions(+), 70 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 0cffbe22..43d6ba9d 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -457,6 +457,22 @@ build" distinction meaningful rather than decorative. claim something it cannot support. The publication itself is **PT-5.6** below; it is not part of this item, which was only ever a decision. + **REVERSED 2026-09-02: decided no, never publish to crates.io.** The GitHub release binary is the + only distribution, and `publish = false` is now permanent. Two reasons, the second of which was not + known when the above was written: + 1. **The reach premise was backwards.** A released binary needs no Rust toolchain; `cargo install` + needs a toolchain, a compiler, and a build of the whole dependency tree. crates.io therefore + reaches a *subset* of the download path's audience -- a convenience for Rust developers, bought + by making the weakest-provenance path the most discoverable. M5's own preamble had already said + the download "needs no Rust toolchain" and is "the provenance". + 2. **A published crate cannot use bare `path` dependencies, and cargo enforces the resulting + `version` pins at every build rather than at publication.** So a pin left stale by any workspace + bump breaks the entire workspace's resolution. Measured: topology at 0.2.0 against this crate's + `"0.1.0"` pin failed `cargo metadata` outright. Publishing would have made that a permanent tax; + not publishing let both pins be deleted. + Recorded in [DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), which keeps the + superseded reasoning because its provenance argument is still why the record marks unofficial + builds. - [x] **PT-5.4** -- Package metadata and a statement of what is and is not covered by semver. The **record's schema is a compatibility surface** the moment anyone stores one; the internal measurement @@ -471,39 +487,32 @@ build" distinction meaningful rather than decorative. else while looking like it had passed. The ARM64 development machine is the obvious first walker, and it doubles as the check that the unverified `aarch64` artifact from PT-5.1 actually runs. -## M5+: crates.io, once the strong path is established - -Gated on M5, and named `M5+` rather than given a number because the gate is a -deliverable in another checklist rather than a milestone here. Nothing in this section is an open -obligation of M5: M5 is complete when the GitHub release path works, and this section is pulled in -and numbered when that has happened. - -- [ ] **PT-5.6** -- **Publish `windows-placement-probe` to crates.io**, per PT-5.3's decision. - - > **-> CROSS-COMPONENT PREREQUISITE:** blocked on - > [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) -> `M4` -> - > `SH-4.3` (release `windows-waitable-queues` 0.1.0), and on `SH-4.1` if - > `windows-topology-sys` reaches 0.2.0 first. This is a hard blocker, not a preference: a published - > crate cannot depend on a `path`, and `windows-waitable-queues` is not on crates.io today. - - Also blocked on **PT-5.5** -- the whole point of the decision is that the download path is - established *first*, so publishing before someone has walked it would defeat the reasoning that - chose to publish at all. - - Three things this must not skip, each of which is invisible until it is too late: - - **Update the dependency pins to what is actually published.** During local development cargo uses - the `path` entry and never exercises the `version` entry, so a stale pin costs nothing until the - moment of publication, and then decides which version a downstream user compiles against. The - crate currently pins `windows-topology-sys = "0.1.0"`. - - **Say in the README what a crates.io build costs the data** -- that it produces records marked - unofficial with an unknown commit, and that the release download does not. A runner choosing the - convenient path should know what they are giving up, rather than discovering it in their own - output. - - **Run the tool from a `cargo install`ed copy and read the record**, confirming it marks itself - unofficial and names no commit. This is the negative case for the path being added, and PT-5.1's - lesson applies unchanged: a distinction nobody has watched fail is a distinction that does not - work. - +## M5+: crates.io -- WITHDRAWN, never to be published + +**PT-5.3's decision to publish here was reversed on 2026-09-02; this milestone will not be pulled in +and numbered.** Kept as a heading rather than deleted, so that a reader who remembers a plan to +publish finds the reversal instead of a gap. The reasoning is on PT-5.3 above and in +[DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md): the reach premise was backwards, +and a published crate would have owed permanent dependency-pin maintenance that cargo enforces at +every build rather than at publication. + +- [x] **PT-5.6** -- **WITHDRAWN: `windows-placement-probe` is never published to crates.io.** Checked + off as *decided against*, not as done. Its cross-component prerequisites on `SH-4.1` and `SH-4.3` + are void, and the reciprocal note in + [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) has been updated to + say so -- a prerequisite that outlives the item needing it is how work gets blocked on nothing. + Two of its three "must not skip" points are void with it: the dependency pins were **deleted** + rather than corrected (the crate is path-only now), and there is no `cargo install`ed copy to run. + The third survives on its own merit and is **not** lost: the README should still say that a + locally built copy produces records marked unofficial, because a runner can still build one from + source. That is **PT-5.7** below rather than a bullet inside a withdrawn item. + +- [ ] **PT-5.7** -- **Say in the README what a locally built copy costs the data.** Rescued from + PT-5.6, whose withdrawal would otherwise have taken it. The point never depended on crates.io: a + runner who clones and `cargo build`s gets a binary that marks its records `!!UNOFFICIAL!!` with no + commit, exactly as a `cargo install`ed one would have. They should learn that from the README + rather than from their own output, and it is the negative case that makes PT-3.5's "official build" + distinction mean something to a reader rather than only to CI. ## M6: is a set of "equivalent" processors actually equivalent? - [ ] **PT-6.1** -- **Give the fingerprint a placement signature, or keep saying it is not canonical.** diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index daf65337..f00e04dd 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -112,20 +112,21 @@ order is the authority and the numbers are only names. local directory as the location searched. So this cannot be done early even as a tidy-up. **GATED BY SH-3.4**, which is where release-please raises the release PR that bumps topology's manifest. Nothing here can proceed before that lands. - **And two of the three pins are not ours to update.** `release-please-config.json` enables the + **Two of the three pins no longer exist, as of 2026-09-02.** `windows-placement-probe` and + `windows-platform-probes` are now decided never to be published to a registry, so their workspace + dependencies are **path-only with no `version` at all** -- eight pins deleted between them, not + merely the two topology ones. A pin that names a version nobody consults, while still being able to + break the workspace, is pure liability. See those crates' DESIGN-NOTES and `PT-5.3` (reversed). + **What is left is one pin, and it is release-please's.** `release-please-config.json` enables the `cargo-workspace` plugin, whose job is exactly this -- rewriting intra-workspace version - requirements when a member is bumped -- but it only sees packages listed in its `packages` map: - - `windows-ioring-sys` -- **managed**, so the plugin should update this pin itself. `kind=dev` and - invisible to consumers, so the update is for accuracy only. **Verify rather than assume it - happened**: it is a dev-dependency, and a tool that only rewrote `[dependencies]` would silently - skip it. Watch for this on the release PR (SH-3.4). - - `windows-placement-probe` -- **not managed**, so the plugin will not touch it. `kind=normal`, - `publish = false` today, planned for publication at - [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) -> `PT-5.6`, which already names this - pin among the three things it must not skip. Ours to update; that item is the backstop, not the - owner. - - `windows-platform-probes` -- **not managed**, `kind=normal`, never published, pin inert. Ours to - update, and worth doing rather than leaving a manifest that misstates what it was built against. + requirements when a member is bumped -- and `windows-ioring-sys` is in its `packages` map. The + other two crates never were, which is precisely why their pins were the dangerous ones. + **Verify rather than assume the plugin handles it**: ioring's topology pin is a `[dev-dependencies]` + entry, and a tool that only rewrote `[dependencies]` would skip it silently. Measured on + 2026-09-02, this is the *only* remaining pin that breaks the workspace when topology bumps -- with + the other two removed, `cargo metadata` against a bumped topology fails naming `windows-ioring-sys` + and nothing else. So if the release PR does update it, this item is empty; if it does not, this + item is a one-line fix that must land in that PR. **One consequence to watch for, not to pre-empt.** The `cargo-workspace` plugin also *bumps* dependents of a bumped package. If it treats the dev-dependency as grounds to bump `windows-ioring-sys`, an ioring release will happen -- not because one is obliged (it is not; see @@ -202,34 +203,35 @@ order is the authority and the numbers are only names. cannot be raised while topology's own manifest still reads `0.1.0` -- a `path` dependency's `version` must be satisfied by the path crate, and attempting it fails the workspace's resolution outright. - **Read the release PR's diff for the pins, not only for the version number.** The `cargo-workspace` - plugin should rewrite `windows-ioring-sys`'s requirement itself, but that one is a *dev*-dependency - and a tool that only rewrote `[dependencies]` would skip it without saying so. `windows-placement-probe` - and `windows-platform-probes` are outside release-please's `packages` map entirely, so their pins - will certainly not be touched and are SH-2.2's to update by hand. Record which of the three the PR - actually changed, so SH-2.2 updates the remainder rather than guessing. + **This PR must not be merged until `windows-ioring-sys`'s topology pin reads 0.2.0 in it.** Not a + review preference -- a `^0.1.0` requirement cannot be satisfied by a path crate at 0.2.0, so + merging without it lands a `main` where `cargo metadata` fails outright and every job on every + branch goes red. Measured, not predicted. + The `cargo-workspace` plugin should rewrite that requirement itself, but **verify it in the diff + rather than assuming**: it is a `[dev-dependencies]` entry, and a tool that only rewrote + `[dependencies]` would skip it without saying so. If the PR is missing it, add it to the PR. + It is now the **only** such pin: `windows-placement-probe` and `windows-platform-probes` had theirs + deleted entirely on 2026-09-02 when both were decided never to be published (SH-2.2). ## M4: release - [ ] **SH-4.1** -- Release `windows-topology-sys` 0.2.0 and confirm it appears on crates.io and builds on docs.rs. Docs.rs builds under its own configuration, so a crate that documents locally can still fail there. - **UNBLOCKS half of [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) PT-5.3 -- publishing - the tool to crates.io -- which needs both releases. On completing this, update that file's gate - bullet to record that topology has shipped**; the gate lifts only when SH-4.3 lands too, and a - half-lifted gate that reads as lifted is how work starts against a dependency that is not there yet. - **It does not gate the GitHub binaries**, which CI builds from this repository through `path` - dependencies. + **The obligation to [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) is void as of + 2026-09-02.** This item used to half-unblock PT-5.3 -- publishing the tool to crates.io -- and that + decision was reversed: the tool is never published to a registry, so there is nothing here to + unblock and no gate bullet to edit. It never gated the tool's **GitHub binaries**, which CI builds + from this repository through `path` dependencies, and those remain the only distribution. - [ ] **SH-4.2** -- Update `windows-ioring-sys` to depend on the published 0.2.0 and release it, per the order settled in SH-2.2. - [ ] **SH-4.3** -- Release `windows-waitable-queues` 0.1.0, with SH-2.1's fix in place. Confirm the tag triggered a publish rather than assuming it did. - **LIFTS THE GATE ON [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) PT-5.3 only. On - completing this, edit that file's gate bullet to say the gate is lifted and name the two published - versions**, so a reader arriving there later does not have to reconstruct whether it still applies. - The tool's GitHub binaries never waited on this. + **The gate on [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) PT-5.3 is void as of + 2026-09-02** -- that decision was reversed and the tool is never published to a registry, so there + is no gate to lift and no bullet to edit. The tool's GitHub binaries never waited on this. Blocked by SH-1.1, and by M31.6 as well if SH-1.2 decided that it gates. ## M5: verify from outside the workspace diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index 762d4b36..aba0a7c5 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -57,8 +57,19 @@ default = ["serde"] serde = ["dep:serde", "windows-topology-sys/serde"] [dependencies] -windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys" } -windows-waitable-queues = { version = "0.1.0", path = "../windows-waitable-queues" } +# Path-only, with no `version`, because this crate is never published **to a +# registry**. It is still distributed: the tool ships as a CI-built binary +# attached to a GitHub release, which is its canonical distribution and needs no +# `version` here at all. Only a crates.io package would, and that was decided +# against -- see `publish = false` above and DESIGN-NOTES.md. +# +# A `version` beside a `path` is consulted only when the depending crate is +# packaged, but cargo requires the path crate's own version to satisfy it at +# every build regardless. So a pin left behind by a bump breaks the whole +# workspace's resolution rather than only this crate's: `windows-topology-sys` +# at 0.2.0 against a "0.1.0" pin here fails `cargo metadata` outright. +windows-topology-sys = { path = "../windows-topology-sys" } +windows-waitable-queues = { path = "../windows-waitable-queues" } serde = { version = "1.0", features = ["derive"], optional = true } # The tool emits the record itself, so a serializer is not a test-only concern. # It is also what derives the schema golden: the archived shape comes from diff --git a/crates/windows-placement-probe/DESIGN-NOTES.md b/crates/windows-placement-probe/DESIGN-NOTES.md index 84896079..fec12689 100644 --- a/crates/windows-placement-probe/DESIGN-NOTES.md +++ b/crates/windows-placement-probe/DESIGN-NOTES.md @@ -5,6 +5,44 @@ a record someone can paste into a discussion thread. ## crates.io is a second path, and it must not become the first one +**SUPERSEDED 2026-09-02: there is no second path. This crate is never published +to a registry.** The GitHub release binary is the only distribution, and +`publish = false` now states that permanently rather than temporarily. The +reasoning below is kept because it is still correct about *why* the crates.io +path is weaker, and because the decision it records was reversed on new +information rather than on a change of taste. + +**Why the reach argument no longer holds.** It rested on crates.io adding +reach. Measured against the path that actually exists, it subtracts: a released +binary needs **no Rust toolchain at all**, while `cargo install` needs a +toolchain, a compiler, and a successful build of this crate's whole dependency +tree. The audience crates.io adds is therefore a *subset* -- Rust developers who +would rather type a command than click a link. That is a convenience, not reach, +and it is bought by making the weakest-provenance path the most discoverable +one. M5's own preamble had already said the download "needs no Rust toolchain" +and is "the provenance"; the reach premise contradicted a conclusion this +project had already reached. + +**And a cost that was not known when the original decision was made.** A +published crate cannot depend on a bare `path`, so every dependency needs a +`version` that must be kept in lockstep with the workspace. That is not a +publication-time chore, as the note below assumed -- **cargo enforces it at +every build**, so a pin left stale by a bump breaks the entire workspace's +resolution. Measured on 2026-09-02: raising `windows-topology-sys` to 0.2.0 +while this crate pinned `"0.1.0"` failed `cargo metadata` outright, and would +have landed a broken `main` the moment the release PR merged. Publishing would +have made that a permanent tax on a tool whose value is being re-run and +revised; not publishing removes the pins entirely, which is what was done. + +**What is unchanged.** The tool still ships, still stamps its commit, and still +marks non-CI builds `!!UNOFFICIAL!!`. Nothing about the record's provenance +story depended on crates.io -- it only ever weakened it. + +--- + +*Superseded reasoning follows, preserved for the argument it makes about +provenance, which is why the record still marks unofficial builds.* + **Decided: publish to crates.io, but not yet.** The reasoning is about which path a runner meets first, not about whether the reach is worth having. diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index 1deb5b78..084f58be 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -72,32 +72,44 @@ name = "probe-peer-index-cache" path = "src/bin/peer_index_cache.rs" [dependencies] +# **Every workspace dependency below is path-only, with no `version`**, because +# this crate is never distributed at all -- not to a registry, and not as a +# released binary either, unlike `windows-placement-probe` next door. These +# probes are a development instrument, run from a checkout, and `publish = false` +# above is the whole story. +# +# A `version` beside a `path` is consulted only when the depending crate is +# packaged, but cargo still requires the path crate's own version to satisfy it +# at every build -- so a pin left behind by a bump breaks the whole workspace's +# resolution rather than only this crate's. With six such pins, that was six +# standing chances to break `main` in exchange for nothing. +# # The pool-growth probe measures the shipping API rather than a # reimplementation of the SDK's inline environment helpers, so it depends on the # real crate. -windows-threadpool-sys = { version = "0.1.3", path = "../windows-threadpool-sys" } +windows-threadpool-sys = { path = "../windows-threadpool-sys" } # Same reason: the topology probe measures what the shipping parse produces, not # a second parse written here, which would only measure itself. The raw Win32 # counters it cross-checks against are read independently through windows-sys. -windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys" } +windows-topology-sys = { path = "../windows-topology-sys" } # The placement measurement moved out to its own crate so it could be shared # with people running it on hardware this workspace does not own. The probes # here call into it rather than keeping a second copy: two renderings of one # measurement disagreeing is a defect this investigation has already hit. -windows-placement-probe = { version = "0.1.0", path = "../windows-placement-probe" } +windows-placement-probe = { path = "../windows-placement-probe" } # The request-cost probe measures the real request types the design would put on # a queue, not a stand-in, for the same reason. -windows-namespace-request-sys = { version = "0.2.0", path = "../windows-namespace-request-sys" } +windows-namespace-request-sys = { path = "../windows-namespace-request-sys" } # The contention probe measures the shipping queue shapes rather than a # reimplementation, for the same reason: a stand-in would only measure itself, # and the whole question is what the real tail claim costs. # The experimental permit claim is enabled here because this probe is what # decides its fate (SH-15.5): it must be measured against the shipping shapes # on the same host, in the same run, by the same harness. -windows-waitable-queues = { version = "0.1.0", path = "../windows-waitable-queues", features = [ +windows-waitable-queues = { path = "../windows-waitable-queues", features = [ "experimental-permit-claim", ] } -wtf-string = { version = "0.1.0", path = "../wtf-string" } +wtf-string = { path = "../wtf-string" } [dependencies.windows-sys] version = "0.61.2" diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index b3f521f2..954a8b2c 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -138,6 +138,27 @@ test suite for this workspace's crates: a probe answers "what does Windows do?", never "does our code work?". A probe that starts asserting our own behaviour belongs in the crate that owns that behaviour. +## This crate is never distributed, and its dependencies carry no versions + +Not to a registry, and not as a released binary either -- unlike +`windows-placement-probe`, which ships a CI-built binary to people running it on +hardware this workspace does not own. These probes are a development +instrument, run from a checkout by someone who has the checkout. `publish = +false` is the whole story, and it is permanent rather than "not yet". + +**The consequence is that every workspace dependency here is path-only.** A +`version` beside a `path` exists to tell a registry what to resolve when the +depending crate is packaged. Nothing packages this crate, so those pins named a +version no one would ever consult -- while still having to be correct, because +cargo requires the path crate's own version to satisfy the pin **at every +build**, not merely at publication. + +That is not a theoretical tidy-up. Measured on 2026-09-02: bumping +`windows-topology-sys` to 0.2.0 while this crate pinned `"0.1.0"` failed +`cargo metadata` for the whole workspace. Six such pins were six standing +chances to break `main` on someone else's release, in exchange for nothing, +and they are gone. + ## The earlier probes are migrated, and two of them corrected in the move From cab0bb7e34c4dc2ddcee868d31634076c739de2e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 14:09:14 -0400 Subject: [PATCH 218/361] chore(placement-probe): version the tool by date, not by semver YYYY.MMDD.N -- so 2026.902.0 today, with the third component reserved for a second release on the same day. Leading zeros are illegal in a semver numeric identifier, so September 2 is 902 rather than 0902; ordering within a year still holds, since January 5 is 105 and December 25 is 1225. Semver was the wrong scheme here rather than merely an unhelpful one. Its job is to communicate compatibility, and this crate offers nothing for it to describe: it is never published to a registry, nothing depends on it as a library (the workspace's own probes now depend on it by path with no version at all), and the one thing that genuinely IS a compatibility surface -- the record's shape -- already has its own SCHEMA_VERSION, a linearly increasing integer with an append-only golden per version, deliberately independent of the crate's. That separation is what frees this field. A version describing no compatibility can only be arbitrary, and an arbitrary version gets bumped on a whim or never at all. What a reader of a record actually needs is which build produced it: the commit answers that exactly and is stamped beside it, and the date answers it legibly -- whether a record pasted into a thread is from last week or last year. "0.1.0" answered neither, and nothing would ever have forced it to move. Release-please is unaffected, verified rather than assumed: this crate is absent from both release-please-config.json's packages map and .release-please-manifest.json, and carries no x-release-please-version marker. It is hands-off entirely, which is the only way a hand-maintained date could stay correct. The release workflow needed no change, also verified rather than assumed. Its check parses ${GITHUB_REF_NAME##*-v}, which yields 2026.902.0 from placement-probe-v2026.902.0; running the workflow's own logic against a CalVer tag matches the binary's identity, matches the commit, and still rejects a stale tag. Test fixtures updated from "0.1.0" to the real format. They are arbitrary sample data and would have kept passing untouched, but a fixture showing a version this crate can no longer have teaches the wrong convention to whoever reads the test next. The one assertion that compares against the real version uses env!("CARGO_PKG_VERSION") and adapts on its own. Confirmed end to end: the built binary reports "!!UNOFFICIAL!! v2026.902.0 986f1d76204b DIRTY [LOCAL]" -- CalVer, commit, and the unofficial marking all intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 +- crates/windows-placement-probe/Cargo.toml | 43 ++++++++++++++----- .../windows-placement-probe/DESIGN-NOTES.md | 40 +++++++++++++++++ .../src/build_identity/tests.rs | 4 +- .../src/record/tests.rs | 2 +- 5 files changed, 77 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9494305..08f1680e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,7 +226,7 @@ dependencies = [ [[package]] name = "windows-placement-probe" -version = "0.1.0" +version = "2026.902.0" dependencies = [ "serde", "serde_json", diff --git a/crates/windows-placement-probe/Cargo.toml b/crates/windows-placement-probe/Cargo.toml index aba0a7c5..d4ef21d5 100644 --- a/crates/windows-placement-probe/Cargo.toml +++ b/crates/windows-placement-probe/Cargo.toml @@ -2,15 +2,36 @@ [package] name = "windows-placement-probe" -version = "0.1.0" -# Publishing to crates.io is decided -- yes, but not yet (PT-5.3 in -# CHECKLIST-placement-tool.md, reasoning in DESIGN-NOTES.md). The distribution -# that matters is the CI-built binary attached to a GitHub release, because the -# download is the provenance: a binary built here is traceable to the commit -# that produced it in a way a local build of identical source is not. Publishing -# only after that path exists and has been walked end to end keeps the strong -# path the one a runner meets first. `false` until PT-5.6 performs the -# publication, so the crate cannot go out early by accident. +# **Calendar-versioned: `YYYY.MMDD.N`.** Today's date, plus a fourth-digit +# counter for a second release on the same day. Leading zeros are illegal in a +# semver numeric identifier, so September 2 is `902` rather than `0902`; that +# still orders correctly within a year, since January 5 is `105` and December 25 +# is `1225`. +# +# Semver would be the wrong scheme here, not merely an unhelpful one. Semver +# communicates *compatibility*, and this crate has no compatibility surface for +# it to describe: nothing depends on it as a library (see below), it is never +# published to a registry, and the one thing that genuinely is a compatibility +# surface -- the record's shape -- has its own `SCHEMA_VERSION` that moves +# independently. A semver here could only ever be arbitrary, which in practice +# means it is bumped on a whim or never bumped at all. +# +# What a reader of a record actually needs from this field is *which build +# produced it*. The commit already answers that precisely and is stamped +# alongside; the date answers it legibly, and tells them at a glance whether the +# record is from last week or last year. `0.1.0` answered neither. +version = "2026.902.0" +# **Never published to a registry.** Reversed from "yes, but not yet" on +# 2026-09-02; see PT-5.3 in CHECKLIST-placement-tool.md and DESIGN-NOTES.md for +# both reasons. The short form: `cargo install` reaches a *subset* of the people +# a downloadable binary reaches, because the binary needs no Rust toolchain -- +# so crates.io would have added no reach while making the weakest-provenance +# path the most discoverable one. +# +# The distribution that matters is the CI-built binary attached to a GitHub +# release, because the download is the provenance: a binary built there is +# traceable to the commit that produced it in a way a local build of identical +# source is not. publish = false authors.workspace = true edition.workspace = true @@ -21,7 +42,9 @@ homepage.workspace = true description = "Measures what thread placement costs on a Windows machine -- SMT siblings, cache domains, efficiency classes and NUMA hops -- and produces one structured record to send back." readme = "README.md" -# What semver covers here, stated because the two halves differ sharply. +# What versioning covers here, stated because the two halves differ sharply -- +# and this split is the reason the crate version itself is a date rather than a +# semver. # # **The record's schema is a compatibility surface** the moment anyone stores a # result, and it has its own versioning that does not move with the crate's: diff --git a/crates/windows-placement-probe/DESIGN-NOTES.md b/crates/windows-placement-probe/DESIGN-NOTES.md index fec12689..f00d15ab 100644 --- a/crates/windows-placement-probe/DESIGN-NOTES.md +++ b/crates/windows-placement-probe/DESIGN-NOTES.md @@ -89,6 +89,46 @@ later: the record schema becomes a semver surface the moment anyone stores one build produces records marked unofficial, so nobody chooses that path without knowing what it costs the data. +## The crate version is a date, because semver has nothing here to describe + +**`YYYY.MMDD.N`** -- the release date, plus a counter for a second release on +the same day. Leading zeros are illegal in a semver numeric identifier, so +September 2 is `2026.902.0` rather than `2026.0902.0`; that still orders +correctly within a year, since January 5 is `105` and December 25 is `1225`. + +Semver would be the wrong scheme, not merely an unhelpful one. Semver's job is +to communicate **compatibility**, and this crate offers nothing for it to +describe: + +- It is **never published to a registry**, so no dependency resolver ever reads + the number. +- Nothing **depends on it as a library**. The package metadata already says the + measurement code is an instrument rather than a surface to build on, and the + workspace's own probes now depend on it by `path` with no version at all. +- The one thing that genuinely *is* a compatibility surface -- the record's + shape -- **has its own version**. `SCHEMA_VERSION` is a linearly increasing + integer with an append-only golden per version, deliberately independent of + the crate's. That separation is what frees this field. + +A version that describes no compatibility can only be arbitrary, and an +arbitrary version is bumped on a whim or not at all. Neither serves the reader. + +**What a reader of a record actually needs from this field is which build +produced it.** The commit answers that exactly and is stamped beside it; the +date answers it *legibly*, telling someone at a glance whether a record in a +discussion thread is from last week or from last year. `0.1.0` answered +neither, and would have gone on answering neither indefinitely, because nothing +would ever have forced it to move. + +**Nothing automated fights this.** Release-please does not manage this crate -- +it is absent from both `release-please-config.json`'s `packages` map and +`.release-please-manifest.json`, and carries no `x-release-please-version` +marker -- so the version is maintained by hand, which is the only way a date +could be correct anyway. The release workflow's tag check needed no change: it +parses `${GITHUB_REF_NAME##*-v}`, which yields `2026.902.0` from +`placement-probe-v2026.902.0`, and still rejects a stale tag. Verified against +the workflow's own logic rather than assumed. + ## The schema freezes at the first release, not before **Decided: regenerate `schema/v1.txt` in place while the tool is unreleased, and diff --git a/crates/windows-placement-probe/src/build_identity/tests.rs b/crates/windows-placement-probe/src/build_identity/tests.rs index 9de29361..73659d8b 100644 --- a/crates/windows-placement-probe/src/build_identity/tests.rs +++ b/crates/windows-placement-probe/src/build_identity/tests.rs @@ -6,7 +6,7 @@ use super::{BuildIdentity, BuildSource}; /// An official build: CI, known commit, clean tree. fn official() -> BuildIdentity { BuildIdentity { - crate_version: "0.1.0", + crate_version: "2026.902.0", commit: Some("abcdef123456"), dirty: Some(false), source: BuildSource::Ci, @@ -83,7 +83,7 @@ fn an_official_build_renders_without_a_marker() { let rendered = official().to_string(); assert!(!rendered.contains("!!"), "got {rendered}"); - assert!(rendered.contains("v0.1.0"), "got {rendered}"); + assert!(rendered.contains("v2026.902.0"), "got {rendered}"); assert!(rendered.contains("abcdef123456"), "got {rendered}"); } diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index b4fd9dd3..c981d6fc 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -55,7 +55,7 @@ pub(crate) fn fully_populated() -> SubmissionRecord { recorded_at_epoch_seconds: 1_788_177_600, recorded_at_subsecond_millis: 250, build: BuildIdentity { - crate_version: "0.1.0", + crate_version: "2026.902.0", commit: Some("abcdef123456"), dirty: Some(false), source: BuildSource::Ci, From f1fc4ebeecb619bb2dd1a6bfa7e32fca600ef9dc Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 14:29:52 -0400 Subject: [PATCH 219/361] chore(ioring): drop the topology dev-dependency's version, closing SH-2.5 and SH-2.2 SH-2.5 asked me to choose between two ways of managing a divergence. Neither was needed, because the divergence it described cannot happen. The item's premise was that "at cargo publish the verification build resolves the version requirement from crates.io", so an example using a newer topology API would surface as a failed publish. Publish verification does not build examples or tests. Measured by packaging this crate: the verification step compiled the library and its real dependencies and nothing else -- not even serde_json, a versioned dev-dependency the examples need. The pin was never exercised at the one moment it was supposed to matter, so no publish could ever have failed for this reason. What the pin did do was the opposite of the filed hazard: it broke the workspace. Cargo enforces path + version agreement at every build regardless of dependency kind, so this requirement reading "0.1.0" against a topology bumped to 0.2.0 failed cargo metadata for every crate here -- turning a release of an unrelated crate into a red main. So the fix is deletion, and cargo cooperates: a versionless dev-dependency is omitted from the published manifest entirely. Verified by packaging with the version removed and reading the generated Cargo.toml, where windows-topology-sys appears nowhere; full cargo package with verification then succeeds. There is no crates.io requirement left to diverge from. What is given up is that the examples are not buildable from the packaged tarball -- which they were only ever incidentally, since publish never checked them and anyone reading an example does it from a checkout. This was the last pin a topology bump could break, so SH-2.2 closes with it: all three of its pins are now deleted rather than maintained, and bumping topology to 0.2.0 leaves cargo metadata resolving cleanly. Before today it failed in three places and release-please could only have fixed one. The gate SH-3.4 held over SH-2.2 is lifted, and SH-3.4's "must not merge without the pin" warning is moot. Records the invariant that keeps this gone: every crate still carrying a versioned path dependency is one release-please manages, thirteen pins across seven crates, all of which the cargo-workspace plugin exists to rewrite. Every pin the plugin could not see is now gone. A future pin outside its packages map is the shape that breaks main and should be questioned rather than maintained; noted that check-publishable.ps1 would be the natural place to enforce it, and deliberately not done here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 118 +++++++++++++------------- crates/windows-ioring-sys/Cargo.toml | 20 ++++- 2 files changed, 79 insertions(+), 59 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index f00e04dd..b80fdbd7 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -12,7 +12,7 @@ arrived during it, and M15 carries the largest open question in the file. | Milestone | State | What it is waiting on | |---|---|---| | M1 settle the public surface | **done, archived** | -- | -| M2 repair the release plumbing | 2 of 5 open | SH-2.2 is blocked on SH-3.4; SH-2.3 needs the merge commit; SH-2.5 needs a decision | +| M2 repair the release plumbing | 1 of 5 open | only SH-2.3, which needs the merge commit | | M3 land the branch | open | the pull request is still a draft | | M4 release | open | M3 | | M5 verify from outside | open | M4 | @@ -91,7 +91,7 @@ order is the authority and the numbers are only names. `windows-placement-probe` is today. Verified by removing the trigger again and watching the check fail with the crate named. -- [ ] **SH-2.2** -- Update the three `windows-topology-sys = "0.1.0"` pins when 0.2.0 ships. +- [x] **SH-2.2** -- Update the three `windows-topology-sys = "0.1.0"` pins when 0.2.0 ships. **RE-PLANNED: this item asked for a decision whose premise was false, and the decision it demanded does not arise.** It said `windows-ioring-sys` is published against the old topology, "so the breaking bump obliges updating that dependency and releasing `windows-ioring-sys` too", and that @@ -104,52 +104,59 @@ order is the authority and the numbers are only names. `windows-threadpool-sys` and `windows-overlapped-io-sys`, and no topology crate. There is no resolution conflict to avoid and **no ioring release is obliged**, so the ordering question this item existed to settle is empty. - **BLOCKED, and the blocker is real rather than a preference: the pins cannot be updated until - topology 0.2.0 actually exists.** A `path` dependency carrying a `version` must be satisfied by the - version in the path crate's own manifest, and topology's still reads `0.1.0`. Verified rather than - assumed -- setting one pin to `"0.2.0"` today fails the whole workspace's resolution with - `error: failed to select a version for the requirement windows-topology-sys = "^0.2.0"`, naming the - local directory as the location searched. So this cannot be done early even as a tidy-up. - **GATED BY SH-3.4**, which is where release-please raises the release PR that bumps topology's - manifest. Nothing here can proceed before that lands. - **Two of the three pins no longer exist, as of 2026-09-02.** `windows-placement-probe` and - `windows-platform-probes` are now decided never to be published to a registry, so their workspace - dependencies are **path-only with no `version` at all** -- eight pins deleted between them, not - merely the two topology ones. A pin that names a version nobody consults, while still being able to - break the workspace, is pure liability. See those crates' DESIGN-NOTES and `PT-5.3` (reversed). - **What is left is one pin, and it is release-please's.** `release-please-config.json` enables the - `cargo-workspace` plugin, whose job is exactly this -- rewriting intra-workspace version - requirements when a member is bumped -- and `windows-ioring-sys` is in its `packages` map. The - other two crates never were, which is precisely why their pins were the dangerous ones. - **Verify rather than assume the plugin handles it**: ioring's topology pin is a `[dev-dependencies]` - entry, and a tool that only rewrote `[dependencies]` would skip it silently. Measured on - 2026-09-02, this is the *only* remaining pin that breaks the workspace when topology bumps -- with - the other two removed, `cargo metadata` against a bumped topology fails naming `windows-ioring-sys` - and nothing else. So if the release PR does update it, this item is empty; if it does not, this - item is a one-line fix that must land in that PR. - **One consequence to watch for, not to pre-empt.** The `cargo-workspace` plugin also *bumps* - dependents of a bumped package. If it treats the dev-dependency as grounds to bump + **RESOLVED 2026-09-02: there are no pins left to update.** All three were deleted rather than + maintained, once it turned out none of them was doing anything a reader would want: + - `windows-placement-probe` and `windows-platform-probes` -- decided never to be published to a + registry, so **every** workspace dependency is path-only now. Eight version fields deleted + between them, not merely the two topology ones. See those crates' DESIGN-NOTES and `PT-5.3` + (reversed). + - `windows-ioring-sys` -- a `[dev-dependencies]` entry, and cargo omits a versionless + dev-dependency from the published manifest entirely. Deleted as SH-2.5, which also records why + the pin was never checked by anything. + **The whole hazard class is gone, verified rather than argued.** Bumping topology to 0.2.0 now + leaves `cargo metadata` resolving cleanly; before today it failed in three places, and + release-please could only have fixed one of them. + **The gate on SH-3.4 is therefore lifted** -- there is nothing here that had to wait for topology's + manifest to move, because nothing here needs a version at all. + **One consequence to watch for, not to pre-empt.** The `cargo-workspace` plugin bumps dependents of + a bumped package. If it treats the (now versionless) dev-dependency as grounds to bump `windows-ioring-sys`, an ioring release will happen -- not because one is obliged (it is not; see above) but because the tooling produced one. That is acceptable if it occurs; it is only a problem if it is mistaken for evidence that the obligation existed after all. - **The one genuine hazard the original item was reaching for is real but different**, and it is - SH-2.5 rather than this: ioring's examples are developed against the `path` topology and would be - verified against the *crates.io* one at its next publish. - -- [ ] **SH-2.5** -- **Nothing checks that `windows-ioring-sys`'s examples still compile against the - topology version its manifest pins.** Inside this workspace the `path` entry always wins, so the - examples are developed against whatever topology is on the branch; at `cargo publish` the - verification build resolves the `version` requirement from crates.io instead. The two have been - free to diverge and nothing would say so until a publish failed. - **Currently dormant, and the check is what makes that a fact rather than a hope**: the examples use - only `Topology`, `Domain`, `DomainKind`, `ProcessorSet` and `discover`, all of which pre-date - 0.2.0, and none mentions `provenance`. So they would still build against `^0.1.0` today. That is - luck holding, not a guarantee -- the moment an example uses anything 0.2.0 added, the failure - appears at publish time, which is the worst moment to find it. - Decide between the two honest fixes rather than leaving it implicit: either keep the pin current so - the two never diverge (and say in the manifest that this is why), or add a CI job that builds - ioring's examples against the *published* topology rather than the path one. The first is cheaper; - the second is what actually enforces it. + +- [x] **SH-2.5** -- **Resolved by deleting the pin, and neither of the two fixes this item proposed + was needed -- because its premise was wrong.** It asserted that "at `cargo publish` the + verification build resolves the `version` requirement from crates.io", so a divergence would + surface as a failed publish. **Publish verification does not build examples or tests.** Measured by + packaging this crate: the verification step compiled the library and its real dependencies and + nothing else -- not even `serde_json`, which is a versioned dev-dependency the examples need. So + the pin was never exercised at the one moment it was supposed to matter, and no publish could ever + have failed for this reason. + **What the pin did do was break the workspace**, which is the opposite of the hazard as filed. See + SH-2.2: cargo enforces `path` + `version` agreement at every build regardless of dependency kind, + so this pin against a bumped topology failed `cargo metadata` for every crate here. + **The fix is to delete it, and cargo cooperates**: a versionless dev-dependency is omitted from the + published manifest entirely -- verified by packaging with the version removed and reading the + result, where `windows-topology-sys` appears nowhere. Full `cargo package` with verification then + succeeds. So there is no crates.io requirement left to diverge *from*, and the residual hazard the + item was really about disappears rather than being managed. + What is given up is that the examples are not buildable from the packaged tarball, which they were + only ever incidentally: publish never checked them, and anyone reading an example does it from a + checkout, where they build as before. + **This was the last version pin in the workspace that a topology bump could break.** Verified: with + it gone, `cargo metadata` against a topology bumped to 0.2.0 resolves cleanly. + **The workspace now holds an invariant worth naming, because it is what makes the hazard stay + gone:** every crate that still carries a versioned `path` dependency is one release-please manages + -- `windows-file-enumeration-sys`, `windows-file-watcher`, its example harness, + `windows-ioring-sys`, `windows-namespace-request-sys`, `windows-thread-ambient-sys` and + `windows-threadpool-sys`, thirteen pins between them. Those pins are genuinely needed (each is a + published crate depending on a published crate) *and* the `cargo-workspace` plugin exists to + rewrite exactly them. Every pin the plugin could **not** see is now gone. + A pin outside the plugin's `packages` map is the shape that breaks `main`, so if one is ever added, + it should be questioned rather than maintained. Checking that mechanically would suit + [tools/check-publishable.ps1](tools/check-publishable.ps1), which already reads both the + release-please config and the manifests -- **not done here**, since it adds CI surface and this + item was scoped to the pin. - [x] **SH-2.4** -- Clear the **eight rustdoc warnings** in `windows-waitable-queues` before it is published: an unresolved link to `MIN_CAPACITY`, six links from public documentation to private @@ -199,19 +206,14 @@ order is the authority and the numbers are only names. **0.2.0** for the topology crate. If it proposes 0.1.1, the breaking-change marker did not take and the version would silently understate the break -- fix the marker rather than editing the version by hand, or the next break will do the same thing. - **GATES SH-2.2**, which cannot run before this: the three `windows-topology-sys = "0.1.0"` pins - cannot be raised while topology's own manifest still reads `0.1.0` -- a `path` dependency's - `version` must be satisfied by the path crate, and attempting it fails the workspace's resolution - outright. - **This PR must not be merged until `windows-ioring-sys`'s topology pin reads 0.2.0 in it.** Not a - review preference -- a `^0.1.0` requirement cannot be satisfied by a path crate at 0.2.0, so - merging without it lands a `main` where `cargo metadata` fails outright and every job on every - branch goes red. Measured, not predicted. - The `cargo-workspace` plugin should rewrite that requirement itself, but **verify it in the diff - rather than assuming**: it is a `[dev-dependencies]` entry, and a tool that only rewrote - `[dependencies]` would skip it without saying so. If the PR is missing it, add it to the PR. - It is now the **only** such pin: `windows-placement-probe` and `windows-platform-probes` had theirs - deleted entirely on 2026-09-02 when both were decided never to be published (SH-2.2). + **The gate this used to hold over SH-2.2 is lifted** -- that item is closed, having had nothing + left to do once the pins were deleted rather than maintained. + **No longer carries a pin hazard.** An earlier version of this item warned that the PR must not be + merged until `windows-ioring-sys`'s topology pin read 0.2.0, because a `^0.1.0` requirement against + a path crate at 0.2.0 fails resolution and would have landed a red `main`. That was true when + written and is now moot: **all three such pins were deleted on 2026-09-02** (SH-2.2, SH-2.5), and a + topology bump was re-verified to resolve cleanly with none of them present. Nothing in the release + PR needs checking beyond the version number itself. ## M4: release diff --git a/crates/windows-ioring-sys/Cargo.toml b/crates/windows-ioring-sys/Cargo.toml index 8a84fda7..21604706 100644 --- a/crates/windows-ioring-sys/Cargo.toml +++ b/crates/windows-ioring-sys/Cargo.toml @@ -89,7 +89,25 @@ windows-threadpool-sys = { version = "0.1.3", path = "../windows-threadpool-sys" # crate rather than raw `GetLogicalProcessorInformationEx` calls. M7's # ring-copy sample additionally needs the `serde` feature, to load a fed-in # topology description (D-10 in windows-topology-sys's DESIGN-NOTES.md). -windows-topology-sys = { version = "0.1.0", path = "../windows-topology-sys", features = [ +# +# **Path-only, with no `version`, deliberately.** Cargo omits a versionless +# dev-dependency from the published manifest entirely -- verified by packaging +# this crate and reading the result -- and that is the right outcome here. A +# `version` would have been published as a requirement nothing ever checks: +# publish verification builds the library alone, not examples or tests, so the +# pin was never exercised at the one moment it was supposed to matter. +# +# What it *did* do was break the workspace. Cargo enforces `path` + `version` +# agreement at every build regardless of dependency kind, so this pin reading +# "0.1.0" against a `windows-topology-sys` bumped to 0.2.0 failed `cargo +# metadata` for every crate here -- turning a release of an unrelated crate into +# a red `main`. Deleting it removes the last of those; see SH-2.5 in +# CHECKLIST-ship-topology-and-queues.md. +# +# The examples still build from a checkout, which is where anyone reads them. +# They are not buildable from the packaged tarball, which they were only ever +# incidentally. +windows-topology-sys = { path = "../windows-topology-sys", features = [ "serde", ] } # M7's ring-copy sample deserializes a fed-in topology description (--topology). From e90985dd8ce111959b7e9dcf4a9fa02fc033a814 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 14:45:12 -0400 Subject: [PATCH 220/361] docs: fix an ordering that put the PR review after the merge that closes it The file read top to bottom as M3 (open PR, merge) then M7-M15 (review rounds on that PR). That is backwards, and the engineer caught it. What actually happened: PR #56 opened on 2026-08-31, nine review rounds arrived while it sat open, and the merge has still not occurred. Review rounds are reactive -- they cannot be scheduled after SH-3.4, because merging ends the pull request they are rounds of. Their position at the end of the file is numbering order, not a running order. Two staleness findings fell out of checking it. SH-3.1 said "Open the pull request" and sat unchecked, describing work already done two days earlier; and it described the branch as "54 commits", against a branch now 221 commits ahead. SH-3.1 is checked as superseded by events, and its surviving instruction -- review the diff rather than a memory of having written it -- becomes SH-3.1.1 with the real number. SH-3.1.1 also names a step the file never had: taking the PR out of draft. That is the actual gate on SH-3.4, since a draft cannot be merged, and nothing above said who decides it is ready. Records that none of the open M14/M15 items gates the merge, as a decision rather than an accident: SH-14.1 ships disclosed rather than fixed (D-36), and the disclosure -- which was the release blocker -- landed at SH-15.8. The corollary is an obligation, not a free pass: the pull request description must say what is knowingly unfinished, or a reviewer reads open milestones as oversight. SH-3.1.1 owns that, and names the three things it must state. Both ends are marked, per this file's own rule that an unmarked gate is a defect: M3 says it interleaves with M7 onwards, and the block above M14 says everything below it happens inside M3. Status table corrected -- it had asserted the very ordering that was wrong, and listed M3's blocker as "still a draft" without saying whose job it was to change that. No code changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 75 ++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 14 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index b80fdbd7..f02a21aa 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -6,25 +6,32 @@ against and other people can run it on hardware this workspace does not own. ## Where this stands -The release has not happened. Nothing below M6 is release work -- M14 and M15 are review rounds that -arrived during it, and M15 carries the largest open question in the file. +The release has not happened. PR #56 has been open as a **draft** since 2026-08-31 and is 221 commits +ahead of `main`. + +**Milestone numbers are not a running order.** M7 through M15 are *review rounds on PR #56*, so they +happened -- and continue to happen -- **inside M3**, between the pull request opening and a merge +that has not occurred. Reading the file top to bottom puts the review of a pull request after the +merge that closes it, which is backwards. Only M1 through M6 are a sequence. | Milestone | State | What it is waiting on | |---|---|---| | M1 settle the public surface | **done, archived** | -- | | M2 repair the release plumbing | 1 of 5 open | only SH-2.3, which needs the merge commit | -| M3 land the branch | open | the pull request is still a draft | +| M3 land the branch | 4 of 5 open | **SH-3.1.1: review the diff and take the PR out of draft** | | M4 release | open | M3 | | M5 verify from outside | open | M4 | | M6 long-running validation | open | gates SH-4.3, so it gates the queue crate's publication | | M7-M13 review rounds | **done, archived** | -- | -| M14 ninth review round | 1 open | SH-14.1 is the ABA defect itself; it is disclosed (SH-15.8) and its fix is M15 | -| M15 the claim protocol | 5 open | SH-15.6 is the decision; it is gated on SH-15.5.1 | +| M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | +| M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | | M-inf parked | ungated | not scheduled, deliberately | -**The critical path to a release is M3 -> M4, and it is not blocked on M15.** SH-14.1 ships disclosed -rather than fixed ([D-36](crates/windows-waitable-queues/DESIGN-NOTES.md#d-36)), so M15 can conclude -after 0.1.0 without holding it up. What *does* block the queue crate specifically is M6. +**The critical path is SH-3.1.1 -> SH-3.4 -> M4, and none of it is blocked on M14 or M15.** SH-14.1 +ships disclosed rather than fixed ([D-36](crates/windows-waitable-queues/DESIGN-NOTES.md#d-36)) and +the disclosure -- which was the actual release blocker -- landed at SH-15.8. So M14 and M15 conclude +after 0.1.0 without holding it up, provided the pull request **says** that is deliberate; SH-3.1.1 +owns that. What *does* block the queue crate specifically is M6. ## Before checking anything off in this file @@ -61,8 +68,11 @@ release-blocking rather than restating the decision itself. - `windows-ioring-sys` **0.2.0 is published and pins `windows-topology-sys = "0.1.0"` -- but as a dev-dependency**, so consumers never resolve it and the pin obliges no release. Corrected at SH-2.2, which was written on the opposite assumption. -- This branch is **54 commits ahead of `main` with no pull request**, and release automation runs on - `main`. Nothing ships until it merges. +- This branch was **54 commits ahead of `main` with no pull request** when this file was written. + **As of 2026-09-02 it is 221 commits ahead, and PR #56 has been open (as a draft) since + 2026-08-31.** Release automation runs on `main`, so nothing ships until it merges -- but the + pull request itself is no longer the thing to create, and the review rounds in M7 onwards all + happened on it while it sat open. > **M1 -- settling the public surface before publication -- is complete and archived.** Moved to > [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md) on 2026-09-02. @@ -187,10 +197,35 @@ order is the authority and the numbers are only names. ## M3: land the branch -- [ ] **SH-3.1** -- Open the pull request, and **review it as a diff rather than as a memory of having - written it**. 54 commits across the topology crate, the queue crate and the probes is more than fits - in a session's recollection, and the branch contains at least one deliberate breaking change plus - several documented reversals of earlier conclusions. +**Read this milestone as interleaved with M7 onwards, not before them.** The file's linear order +implies the review rounds follow the merge, which is backwards and was noticed on 2026-09-02: PR #56 +opened on 2026-08-31, nine review rounds arrived while it sat open, and the merge has still not +happened. Review rounds are **reactive** -- they cannot be scheduled after SH-3.4, because merging +ends the pull request they are rounds *of*. + +**Which of those rounds gate the merge: none of them, and that is a decision rather than an +accident.** SH-14.1 is a real defect that ships **disclosed rather than fixed** +([D-36](crates/windows-waitable-queues/DESIGN-NOTES.md#d-36), delivered by SH-15.8), and everything +open in M15 is follow-on work on the fix. What *did* gate the release was the disclosure, and that +landed. So SH-3.4 may proceed with M14 and M15 still open -- but a reviewer must be told that is +deliberate, which is SH-3.1's job below. + +- [x] **SH-3.1** -- ~~Open the pull request~~ **-- already open since 2026-08-31 as a draft.** Checked + off as *superseded by events*, not as done: the item asked for something that had already happened + by the time anyone read it, and it stated "54 commits" against a branch now **221 commits** ahead. + Its surviving instruction is **SH-3.1.1** below, which is the part that was never done. + +- [ ] **SH-3.1.1** -- **Review the PR as a diff rather than as a memory of having written it, then + mark it ready.** 221 commits across the topology crate, the queue crate and the probes is far more + than fits in a session's recollection, and the branch contains at least one deliberate breaking + change plus several documented reversals of earlier conclusions -- D-18 amended and then + superseded, PT-5.3 reversed, SH-14.3 absorbed, and a crate's version scheme changed from semver to + a date. + **Taking it out of draft is a step this file never named**, and it is the real gate on SH-3.4 + rather than a formality: a draft cannot be merged, and nothing above says who decides it is ready. + **The description must state what is knowingly unfinished**, so a reviewer does not read open + milestones as oversight: SH-14.1 ships disclosed per D-36, M15 is follow-on work on its fix, and + `permit_mpsc` is an experimental non-default module exempt from the crate's semver promise. - [ ] **SH-3.2** -- Run the full gate on the merge result, not merely on the branch tip: `cargo fmt --check`, `cargo clippy --all-targets`, `cargo check --all-targets` in **both** debug and release, @@ -326,6 +361,18 @@ D-31 says cannot be supported. > [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md) on 2026-09-02. M14 and M15 stay below because they > carry open work. +**Everything from here down happened, and happens, *inside* M3 rather than after it.** These are +rounds of review on PR #56, which opened on 2026-08-31 and has not merged; they are reactive work +that arrives while a pull request is open, so their position at the end of this file is numbering +order and not a schedule. Reading it as a schedule would put the review of a pull request after the +merge that closes it. + +**None of the open items below gates SH-3.4**, which is a decision rather than an oversight: the +defect they concern ships **disclosed rather than fixed** +([D-36](crates/windows-waitable-queues/DESIGN-NOTES.md#d-36)), and the disclosure -- which *was* the +release blocker -- landed at SH-15.8. SH-3.1.1 is responsible for saying so in the pull request +description, so a reviewer does not mistake open milestones for unfinished business. + ## M14: PR #56 ninth review round -- an ABA hole the wrap test would not have caught Two findings, both verified by derivation against the source. They are **not** the wrap gap SH-6.1 From e1673378abac3b1a343835b660f77e0c3da888b3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 15:17:56 -0400 Subject: [PATCH 221/361] docs: record M16, the SH-3.1.1 diff review, and its seven findings The first round that read the branch as a diff rather than reacting to a reviewer's comment. Five reviewers took non-overlapping crate scopes across all 200 changed files. Two findings justify the round on their own. SH-16.1 is a regression this branch introduced two commits ago. f1fc4eb made ioring's topology dev-dependency path-only, which is correct, but cargo then reports the requirement as `*` and publish-crate.yml derives a concrete version with a sed that only strips a caret. windows-topology-sys is in workspace_crates, so the wait loop is entered with the literal `*` and can never match: twenty minutes of retries, then an error naming a cause that is not the cause. Verified against cargo metadata rather than inferred. The guard added in this same branch to catch "release-managed but unpublishable" does not catch it. SH-16.2 is a soundness hole in the crate about to freeze its API. Reservation::send wrote a slot with no happens-before edge to the consumer's read of the previous occupant -- push gets that edge from its room check, and send deliberately has no room check. The claim proves the slot is logically free, which is not a synchronization edge, and the SAFETY comment cited a room check that does not exist on that path. The default configuration was the unsound one: tracking_high_water() happened to repair it. Neither was reachable from a memory of having written the code, which is what SH-3.1.1 predicted about a 222-commit branch. The remaining five are recorded worst-first: a CancelIo that frees an OVERLAPPED the kernel may still write to, in the crate whose STATUS_STACK_BUFFER_OVERRUN history is exactly that; an empty cache domain counted as a partition; two crates stating opposite rules about the same return value; a NUMA spike probing the wrong end of its own filler; and a test whose name claims a failure it never injects. Recorded before fixing so none is lost if this session ends early. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 90 +++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index f02a21aa..4d3dcae5 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -25,6 +25,7 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | +| M16 tenth review round | 5 of 7 open | the SH-3.1.1 diff review; **SH-16.1 is critical and open** | | M-inf parked | ungated | not scheduled, deliberately | **The critical path is SH-3.1.1 -> SH-3.4 -> M4, and none of it is blocked on M14 or M15.** SH-14.1 @@ -705,6 +706,95 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. closely and should **not** approach the permit claim's numbers. A wide claim that measured as fast as the permit claim would mean D-35's explanation is wrong. +## M16: PR #56 tenth review round -- the SH-3.1.1 diff review + +**This round is the one [SH-3.1.1](#m3-land-the-branch) asked for**, and it is the first that read the +branch as a *diff* rather than reacting to a reviewer's comment. Five reviewers took non-overlapping +crate scopes across all 200 changed files; seven findings came back, listed here worst-first rather +than by crate. + +**Two of them are the reason the round was worth running.** SH-16.1 is a regression this branch +introduced *two commits ago* -- it would have failed the next `windows-ioring-sys` publish, twenty +minutes in, with an error naming the wrong cause. SH-16.2 is a soundness hole in the crate that is +about to freeze its API, in a shape whose selling point is that its ordering arguments are written +down and checked. + +**Neither was reachable from a memory of having written the code**, which is exactly what SH-3.1.1 +predicted about a 222-commit branch. + +- [ ] **SH-16.1** -- **`publish-crate.yml`'s sibling-dependency wait cannot handle a `*` requirement, + so `windows-ioring-sys` can no longer publish.** The wait step derives a concrete version with + `sed -E 's/^\^//'`, which handles only a caret. Commit `f1fc4eb` on this branch made ioring's + topology dev-dependency path-only, so `cargo metadata` now reports `req=*` -- **verified, not + assumed**. `windows-topology-sys` is in `workspace_crates`, so the loop is entered, `dep_version` + becomes the literal `*`, and `select(.vers == "*")` can never match: 60 attempts x 20 s, then an + error telling the operator to re-run once the dependency is available, which will never help. + A versionless path dev-dependency is **stripped from the published manifest entirely**, so there is + nothing to wait for and the right answer is to skip it. + Note that `tools/check-publishable.ps1`, added in this same branch to catch "release-managed but + unpublishable", does **not** catch this -- ioring passes all three of its checks. + +- [x] **SH-16.2** -- **`reserving_mpsc::Reservation::send` wrote a slot with no happens-before edge to + the consumer's read of the previous occupant.** A slot is freed only by `Consumer::pop`'s + `head.store(Release)`, and the matching acquire lives in `has_room_beyond_reservations`. + `Producer::push` gets its edge from that room check; `send` deliberately has none -- the code says + so -- and its claim CAS is `Relaxed` on every path, so there was no release sequence to inherit + either. The claim proves the slot is *logically* free, which is not the same as a synchronization + edge, and the `SAFETY` comment cited "the room check that permitted the claim" on the one path + where no room check exists. + **The default configuration was the unsound one**: `Options::tracking_high_water()` accidentally + repaired it, because the metric's `head.load(Acquire)` sat just before the write. Fixed by making + that load unconditional, which is where it belonged. + +- [ ] **SH-16.3** -- **`CancelIo` does not wait, so a test frees an `OVERLAPPED` and an I/O buffer the + kernel may still write to.** In + [reopen_by_id_cannot_be_watched.rs](crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs), + an overlapped `ReadDirectoryChangesW` is issued into a **stack-local** `OVERLAPPED` and a heap + buffer, then `CancelIo` is called and both are dropped immediately. `CancelIo` only *requests* + cancellation; the IRP still completes asynchronously and writes `Internal`/`InternalHigh` into a + frame that has been reclaimed. Two safety comments assert the opposite of what the code guarantees. + Aggravated by the helper being called twice back-to-back, so the second call's `overlapped` likely + lands on the same stack address the first IRP will write into. + **This crate has already been bitten by this exact class of corruption** -- the + `STATUS_STACK_BUFFER_OVERRUN` history recorded on the now-removed `reopen_via_existing_handle`. + Fix by calling `GetOverlappedResult(..., bWait = TRUE)` and accepting `ERROR_OPERATION_ABORTED` + before either buffer leaves scope. + +- [x] **SH-16.4** -- **`cache_partitions_at_level` counted a domain covering no processors as a + partition.** An empty `ProcessorSet` is not *equal* to any non-empty one, so deduplication kept it, + and `is_disjoint` is vacuously true on it, so the pairwise check passed it. A level with one real + cache plus one empty domain therefore reported two partitions and was treated as dividing a machine + it does not divide. `Domain` is publicly constructible and `ProcessorSet` has `empty()`, so this is + reachable by hand and by deserialization -- precisely the input the method promises not to trust. + Fixed by dropping empty domains, with the contrast against `memory_domains` (which deliberately + keeps a processor-less domain, D-5) recorded at the filter. + +- [ ] **SH-16.5** -- **`windows-placement-probe` refuses a partially-covering cache level that + `windows-topology-sys` deliberately hands back.** `outermost_partitioning_cache` documents that + "full coverage of the online processors is deliberately *not* required"; `places_from_topology` + treats any online processor the chosen level does not name as `MissingPlacement::CacheDomain` and + fails the **entire run** with `InvalidData`. Two crates state opposite rules about the same return + value -- a [CONTRACT INTEGRITY](.github/copilot-instructions.md) defect, not merely a bug. + Decide the rule **once**, in the crate that owns the topology, and have the consumer ask rather than + restate. Note the asymmetry that makes the NUMA arm different and correct: for NUMA, `None` has no + honest value, whereas `cache_domain` is already `Option`. + +- [ ] **SH-16.6** -- **The thread-stack NUMA spike's `deep_probe` measures the shallow end of its own + filler, so the discrimination it exists to make is inert.** The stack grows down, so `filler[0]` is + the deepest address and `filler[last]` sits immediately below the caller's frame -- but the probe + takes `&raw const filler[last]`, landing very likely on the same page as the shallow probe rather + than 64 KiB away. The spike would then report "not first touch" on a machine where placement *is* + by first touch: a confident wrong answer, in a file whose whole point is avoiding those. Both ends + are already touched, so probing `filler[0]` is a one-token change. + +- [ ] **SH-16.7** -- **A `windows-thread-ambient-sys` test claims a restore-failure it never + injects.** `release_reports_a_genuine_restore_failure_and_restores_on_drop_even_without_it` asserts + the *opposite*: it `expect`s the release to succeed and both closing assertions check that restore + worked. Its siblings in `declared/tests.rs` and `error_mode/tests.rs` do force genuine failures; this + one inherited the name without the failure-injection half, so `TransactionGuard::release`'s + error-reporting path reads as covered when it is not. Either inject the failure or rename the test + to what it checks. + ## M-inf: parked, ungated - [ ] **SH-inf.1** -- **The per-cell cycle claim (SCQ's shape), which is the non-blocking one.** The From 3f4cc7a90861eee57a0ecca847c7716ebe5c2447 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 15:18:51 -0400 Subject: [PATCH 222/361] fix(topology): stop counting a cache domain over no processors as a partition An empty ProcessorSet passed both filters that were supposed to stop it. It is not equal to any non-empty set, so deduplication in cache_partitions_at_level kept it as distinct; and is_disjoint iterates the left-hand set's groups, so an empty set is disjoint from everything vacuously and are_pairwise_disjoint passed it too. A level with one real cache plus one empty domain therefore counted two partitions, cleared partitions.len() > 1, and was reported as dividing a machine it does not divide. A caller sharding work across the result gets a shard covering no processors. This is inside the threat model the method already declares. Its own doc says a Topology is deliberately constructible by hand and by deserialization, so it cannot assume hardware produced it -- and Domain is public with a public ProcessorSet field, while ProcessorSet has empty() and derives Default. The overlap case was tested; this direction was not. Dropping empty domains at cache_partitions_at_level rather than at outermost_partitioning_cache, because a cache over no processors is not a partition for either caller. Both are new on this branch and unpublished, so the contract is still free to choose. Not to be confused with memory_domains, which deliberately keeps a processor-less domain because a memory domain with no CPUs is real hardware (D-5). The contrast is recorded at the filter so the next reader does not "fix" one into the other. Found by the SH-3.1.1 diff review. Completed item: SH-16.4: cache_partitions_at_level counted a domain covering no processors as a partition Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/src/topology.rs | 17 ++++++++- .../src/topology/tests.rs | 38 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index b3e0bacc..bcd9f46b 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -257,9 +257,24 @@ impl Topology { /// both survive this, so the result is a set of domains rather than a /// proven partition. [`Self::outermost_partitioning_cache`] is where that /// stronger property is required and checked. + /// + /// **A domain covering no processors is dropped**, because it partitions + /// nothing and every consumer of this list wants pieces of the machine. It + /// would otherwise survive both filters here and in + /// [`Self::outermost_partitioning_cache`]: it is not *equal* to any + /// non-empty set, so deduplication keeps it, and it is disjoint from + /// everything vacuously, so the pairwise check passes it. A level with one + /// real cache plus one empty domain would then count two partitions and be + /// reported as dividing a machine it does not divide. Contrast + /// [`Self::memory_domains`], which deliberately keeps a processor-less + /// domain because a memory domain with no CPUs is real hardware (D-5); a + /// *cache* over no processors is not. pub fn cache_partitions_at_level(&self, level: u8) -> Vec<&Domain> { let mut partitions: Vec<&Domain> = Vec::new(); - for domain in self.caches_at_level(level) { + for domain in self + .caches_at_level(level) + .filter(|domain| !domain.processors.is_empty()) + { if !partitions .iter() .any(|kept| kept.processors == domain.processors) diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 1ad1dcb5..aebfb026 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -747,3 +747,41 @@ fn a_level_whose_domains_are_disjoint_but_incomplete_still_partitions() { assert_eq!(level, 2); assert_eq!(partitions.len(), 2); } + +#[test] +fn a_domain_covering_nothing_is_not_a_partition() { + // The other end of the same threat model as the overlap test above. An + // empty processor set is *disjoint from everything*, vacuously, so it + // passes the pairwise check; and it is not equal to any non-empty set, so + // deduplication keeps it. A level with one real cache plus one empty domain + // therefore counts two "partitions" and is reported as dividing a machine + // it does not divide -- a caller sharding across the result gets a shard + // covering no processors at all. + // + // `Domain` is publicly constructible and `ProcessorSet` has `empty()`, so + // this is reachable by hand and by deserialization, which is precisely the + // input this method promises not to trust. + let mut topo = split_l1_machine(1, 3); + topo.domains.pop(); + for (id, processors) in [ + (400u32, ProcessorSet::from_group_mask(0, 0b111)), + (401, ProcessorSet::empty()), + ] { + topo.domains.push(Domain { + kind: DomainKind::Cache { + level: 2, + associativity: 8, + line_size: 64, + size_bytes: 1024 * 1024, + cache_type: CacheKind::Unified, + }, + id, + processors, + }); + } + + assert!( + topo.outermost_partitioning_cache().is_none(), + "a level whose only second domain covers nothing does not divide the machine" + ); +} From 0cf3cdc4d2ff601beaccca582d2ecce4aec7d35b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 15:19:15 -0400 Subject: [PATCH 223/361] fix(waitable-queues): give Reservation::send the acquire edge its slot write needs In reserving_mpsc a slot is freed by exactly one thing: Consumer::pop's head.store(position + 1, Release). pop's own comment names the acquire it expects to pair with -- "a producer reads head with an acquire load to count free slots" -- and that load lives in has_room_beyond_reservations. Producer::push gets its edge there. Reservation::send does not: it has no room check at all, deliberately, and says so. Its only atomics were a consumer_live.load on a different location and a Relaxed/Relaxed CAS on claim. Since every claim CAS on every path is Relaxed, there was no release sequence to inherit an edge from either, so making the redeem CAS Acquire would not have fixed it on its own. The result was a non-atomic write in publish racing the consumer's non-atomic assume_init_read of the occupant a lap earlier, with no happens-before edge between them: a data race, and undefined behaviour, inside an unsafe block. The reserve-time acquire does not cover it. A reservation carries no position -- the code states this -- so a redeem lands at whatever position the queue has reached, and one intervening push is enough to put that position past the head the reserving thread acquired. At capacity 2: acquire head = 2, then two push/pop pairs, then redeem at position 4 into slot 0, whose previous occupant at position 2 was freed by a head.store(3) this thread never acquired. The claim was doing real work, just not this work. It establishes that the slot is logically free (occupied + reserved <= capacity with reserved >= 1), which is what the SAFETY comment leaned on -- but a non-atomic write racing a non-atomic read needs a synchronization edge, not a logical guarantee that the read is over. The comment cited "the room check that permitted the claim" on the one path where no room check exists. Fixed by hoisting publish's head.load(Acquire) out of the tracks_high_water() gate, so it runs on every path. It must follow the claim exchange and does; head never moves backwards, so a load afterwards is only ever fresher than the edge the write requires. One load covers both entry points. Worth recording that the default configuration was the unsound one: Options::tracking_high_water() accidentally repaired this, because the metric's head load sat immediately before the write. Turning on a metric made the queue sound. That inversion is why this was invisible. Independent of SH-14.1 -- it needs no wrap, and reproduces on a 2-slot queue within the first few pushes. Found by the SH-3.1.1 diff review, at the last moment before the API freezes. Completed item: SH-16.2: Reservation::send wrote a slot with no happens-before edge to the consumer's read of the previous occupant Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/reserving_mpsc.rs | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 85d7fa51..f2498d02 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -588,16 +588,40 @@ impl Shared { // for what that bound is contracted to mean. // // [`Observable::high_water`]: crate::Observable::high_water + // **This load is unconditional because the slot write below needs it, + // not because the metric does.** It is the acquire half of the pair + // [`Consumer::pop`] describes: freeing a slot is `head.store(Release)`, + // and a producer may only write that slot after acquiring a `head` that + // has passed it. [`Producer::push`] gets that edge from the room check + // in [`Self::has_room_beyond_reservations`]; [`Reservation::send`] + // deliberately has no room check, so without this load its non-atomic + // write would race the consumer's non-atomic read of the previous + // occupant -- a data race, and undefined behaviour, however reliably a + // given target's codegen happens to order it today. + // + // Placing it here rather than in `send` covers every path with one + // load. It must follow the claim exchange, and does: the invariant + // `occupied + reserved <= capacity` holds at that exchange with + // `reserved >= 1`, so `head >= position - capacity + 1` there, and + // `head` never moves backwards. Reading it afterwards can therefore + // only be fresher, never staler, than the edge the write requires. + let head = self.head.0.load(Ordering::Acquire); if self.metrics.tracks_high_water() { - let head = self.head.0.load(Ordering::Acquire); let depth = position.wrapping_sub(head).wrapping_add(1) as usize; self.metrics.record_depth(depth.min(self.capacity)); } let slot = &self.slots[position as usize & self.mask]; // SAFETY: the caller's claim makes this thread the only writer, and the - // room check that permitted the claim means the consumer has finished - // with whatever the slot held a lap ago. + // acquire load of `head` above synchronizes-with the `head.store` by + // which the consumer freed this slot a lap ago, so its read of the + // previous occupant happens-before this write. + // + // The claim alone is not enough. It establishes that the slot is + // *logically* free -- `occupied + reserved <= capacity` with + // `reserved >= 1` -- but a non-atomic write racing a non-atomic read + // needs a happens-before edge, not merely a logical guarantee that the + // read is over. The load above is that edge. unsafe { (*slot.value.get()).write(item); } From 4593b33ee81456dc19eec8279180a8bf6e06aaeb Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 15:22:41 -0400 Subject: [PATCH 224/361] ci: skip a versionless sibling in the publish wait instead of timing out on it f1fc4eb made windows-ioring-sys's windows-topology-sys dev-dependency path-only, which was the right fix for a stale pin that broke cargo metadata for the whole workspace. But cargo then reports that dependency's requirement as `*`, and the publish workflow's wait step derives a concrete version with a sed that only strips a leading caret. windows-topology-sys is in workspace_crates, so it is not skipped; dep_version becomes the literal `*`; and `select(.vers == "*")` matches nothing in the index however long it waits. The step burned all 60 attempts -- twenty minutes -- and then failed telling the operator to re-run once the dependency was available, which would never have helped. So this branch converted a working publish path into one that always failed, in the most misleading way available, and it would have surfaced on ioring's next release with the cause two commits in the past. A versionless path dependency is stripped from the published manifest entirely, so there is nothing on crates.io to wait for. Skip it, and say why. Also fails fast on any requirement that does not reduce to something comparable against the index -- ~1.2, >=1 <2, =1.2.3 all previously reached the same twenty-minute timeout by a different route. That guard turned out to cover more than intended. Getting the test harness wrong first (it omitted the `tr -d '\r'` the step itself applies) showed that a returning CR makes `0.1.3\r` fail immediately and by name, where before it was another silent timeout -- the exact failure the tr was added for, now caught rather than merely prevented. Verified rather than reasoned: the run: block was extracted from the YAML and exercised under bash against real cargo metadata. Ioring's seven dependencies resolve to two waits, four non-sibling skips and one versionless skip, and the step exits 0. bash -n passes on the extracted block. Note that tools/check-publishable.ps1, added in this same branch to catch "release-managed but unpublishable", does not catch this: ioring passes its tag, dispatch and workspace_crates checks. The class is now fixed at the source rather than guarded, so no new check is needed. Found by the SH-3.1.1 diff review. Completed item: SH-16.1: publish-crate.yml's sibling-dependency wait cannot handle a * requirement, so windows-ioring-sys can no longer publish Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/publish-crate.yml | 28 +++++++++++++++++++++++++++ CHECKLIST-ship-topology-and-queues.md | 12 ++++++++++-- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml index 741483cc..ed918a49 100644 --- a/.github/workflows/publish-crate.yml +++ b/.github/workflows/publish-crate.yml @@ -126,7 +126,35 @@ jobs: *" $dep_name "*) ;; *) continue ;; esac + # `*` is what cargo reports for a `path` dependency carrying no + # `version` field. Cargo strips such a dependency from the published + # manifest entirely, so there is nothing on crates.io to wait for. + # Waiting anyway is worse than useless: the literal `*` can never + # equal a `.vers`, so the loop below burns all 60 attempts -- twenty + # minutes -- and then fails telling the operator to re-run once the + # dependency is available, which will never help. + # + # `windows-ioring-sys` reached exactly this state when its + # `windows-topology-sys` dev-dependency became path-only: the + # sibling is in `workspace_crates`, so it is not skipped by the + # `case` above, and this step converted a working publish path into + # one that always failed, in the most misleading way available. + if [ "$dep_req" = "*" ]; then + echo "skipping ${dep_name}: no version requirement, so it is absent from the published manifest" + continue + fi dep_version="$(printf '%s' "$dep_req" | sed -E 's/^\^//')" + # Only a caret or bare requirement reduces to something comparable + # against the index's `.vers`. Anything else -- `~1.2`, `>=1, <2`, + # `=1.2.3` -- would compare unequal forever, which is the same + # twenty-minute failure with a different cause. Fail immediately and + # name the requirement instead. + case "$dep_version" in + ''|*[!0-9.]*) + echo "::error::cannot wait for ${dep_name}: version requirement '${dep_req}' is not a form this step can resolve against the index" >&2 + exit 1 + ;; + esac # Probe the *sparse index*, not the REST API: a version can appear in # api/v1 before the index has propagated, and `cargo publish --locked` # resolves dependencies through the index -- so an API-only check can diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 4d3dcae5..a54a68d1 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -25,7 +25,7 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | -| M16 tenth review round | 5 of 7 open | the SH-3.1.1 diff review; **SH-16.1 is critical and open** | +| M16 tenth review round | 4 of 7 open | the SH-3.1.1 diff review; SH-16.3 is the worst still open | | M-inf parked | ungated | not scheduled, deliberately | **The critical path is SH-3.1.1 -> SH-3.4 -> M4, and none of it is blocked on M14 or M15.** SH-14.1 @@ -722,7 +722,7 @@ down and checked. **Neither was reachable from a memory of having written the code**, which is exactly what SH-3.1.1 predicted about a 222-commit branch. -- [ ] **SH-16.1** -- **`publish-crate.yml`'s sibling-dependency wait cannot handle a `*` requirement, +- [x] **SH-16.1** -- **`publish-crate.yml`'s sibling-dependency wait cannot handle a `*` requirement, so `windows-ioring-sys` can no longer publish.** The wait step derives a concrete version with `sed -E 's/^\^//'`, which handles only a caret. Commit `f1fc4eb` on this branch made ioring's topology dev-dependency path-only, so `cargo metadata` now reports `req=*` -- **verified, not @@ -733,6 +733,14 @@ predicted about a 222-commit branch. nothing to wait for and the right answer is to skip it. Note that `tools/check-publishable.ps1`, added in this same branch to catch "release-managed but unpublishable", does **not** catch this -- ioring passes all three of its checks. + **Done:** `*` is skipped with the reason stated, and any requirement that does not reduce to a + comparable version (`~1.2`, `>=1, <2`, `=1.2.3`) now fails **immediately** naming the requirement, + rather than reaching the same twenty-minute timeout by a different route. Verified by extracting the + `run:` block and exercising it under `bash` against real `cargo metadata` output: ioring's seven + dependencies now resolve to two waits, four skips and one versionless skip, and the step exits 0. + A side benefit worth recording, found by getting the harness wrong first: the new check also + catches a returning CR corruption -- the failure the `tr -d '\r'` above was added for -- because + `0.1.3\r` is no longer a comparable version. That failure used to be a silent timeout too. - [x] **SH-16.2** -- **`reserving_mpsc::Reservation::send` wrote a slot with no happens-before edge to the consumer's read of the previous occupant.** A slot is freed only by `Consumer::pop`'s From ea733d2cd475cbe6c8715575071d04220a47cf22 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 15:26:31 -0400 Subject: [PATCH 225/361] fix(file-watcher): wait for the cancelled read instead of freeing it out from under the kernel read_directory_changes_accepted issued an overlapped ReadDirectoryChangesW into a stack-local OVERLAPPED and a heap buffer, called CancelIo, and returned -- dropping both immediately. CancelIo only requests cancellation. It returns once the request is marked, not once the operation ends, so the IRP still completes asynchronously and, when it does, the kernel writes Internal/InternalHigh through the OVERLAPPED pointer and may copy FILE_NOTIFY_INFORMATION bytes into the buffer -- both by then reclaimed. MSDN states the rule directly: the application must not free or reuse the OVERLAPPED structure until the cancelled operations have completed. Nothing here enforced it: hEvent is null, there is no completion port and no APC. The two safety comments asserted the opposite of what the code guaranteed -- "the kernel owns until the operation is cancelled" and "cancelling is what keeps the buffer from outliving this frame". Cancelling is precisely what does not do that. Aggravated by the caller invoking the helper twice back to back, so the second call's overlapped almost certainly lands on the stack address the first, possibly still-pending, IRP will write into. Verified load-bearing rather than assumed: a probe on the control path returned completed=0 with error 995, ERROR_OPERATION_ABORTED. An IRP really was outstanding when CancelIo returned, and it completed during the wait -- so without the wait that completion landed on a dead frame. The same wait is what makes Owned's later CloseHandle safe, since closing a handle with I/O outstanding is another cancellation request rather than a wait. This crate has already paid for this class of corruption once: the STATUS_STACK_BUFFER_OVERRUN history recorded on the removed reopen_via_existing_handle. Test-only code, but the window is real, and a test that corrupts its own stack fails in a way that looks like anything but its cause. Found by the SH-3.1.1 diff review. Completed item: SH-16.3: CancelIo does not wait, so a test frees an OVERLAPPED and an I/O buffer the kernel may still write to Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 7 ++- .../tests/reopen_by_id_cannot_be_watched.rs | 63 ++++++++++++++++--- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index a54a68d1..5f37c64c 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -754,7 +754,7 @@ predicted about a 222-commit branch. repaired it, because the metric's `head.load(Acquire)` sat just before the write. Fixed by making that load unconditional, which is where it belonged. -- [ ] **SH-16.3** -- **`CancelIo` does not wait, so a test frees an `OVERLAPPED` and an I/O buffer the +- [x] **SH-16.3** -- **`CancelIo` does not wait, so a test frees an `OVERLAPPED` and an I/O buffer the kernel may still write to.** In [reopen_by_id_cannot_be_watched.rs](crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs), an overlapped `ReadDirectoryChangesW` is issued into a **stack-local** `OVERLAPPED` and a heap @@ -767,6 +767,11 @@ predicted about a 222-commit branch. `STATUS_STACK_BUFFER_OVERRUN` history recorded on the now-removed `reopen_via_existing_handle`. Fix by calling `GetOverlappedResult(..., bWait = TRUE)` and accepting `ERROR_OPERATION_ABORTED` before either buffer leaves scope. + **Done, and the wait is measured to be load-bearing rather than assumed.** A probe on the control + path returned `completed=0, err=995` -- `ERROR_OPERATION_ABORTED` -- proving an IRP really was + outstanding at the moment `CancelIo` returned and completed only during the wait. Without it, that + completion landed on a reclaimed frame. The same wait is what makes `Owned`'s later `CloseHandle` + safe, since closing a handle with I/O outstanding is another cancellation request and not a wait. - [x] **SH-16.4** -- **`cache_partitions_at_level` counted a domain covering no processors as a partition.** An empty `ProcessorSet` is not *equal* to any non-empty one, so deduplication kept it, diff --git a/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs b/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs index bec61f9b..ba9f57b8 100644 --- a/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs +++ b/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs @@ -23,7 +23,8 @@ use std::path::Path; use std::ptr; use windows_sys::Win32::Foundation::{ - CloseHandle, ERROR_INVALID_PARAMETER, GetLastError, HANDLE, INVALID_HANDLE_VALUE, + CloseHandle, ERROR_INVALID_PARAMETER, ERROR_OPERATION_ABORTED, GetLastError, HANDLE, + INVALID_HANDLE_VALUE, }; use windows_sys::Win32::Storage::FileSystem::{ BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, @@ -31,7 +32,7 @@ use windows_sys::Win32::Storage::FileSystem::{ FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FileIdType, GetFileInformationByHandle, OPEN_EXISTING, OpenFileById, ReadDirectoryChangesW, }; -use windows_sys::Win32::System::IO::{CancelIo, OVERLAPPED}; +use windows_sys::Win32::System::IO::{CancelIo, GetOverlappedResult, OVERLAPPED}; /// Closes its handle on drop, so a failing assertion cannot leak one into the /// rest of the suite. @@ -118,15 +119,19 @@ fn reopen_by_id(volume_hint: HANDLE, file_id: u64) -> Owned { } /// Issue the exact read `watcher.rs` issues, and report whether Windows accepted -/// it. A pending read is cancelled before returning, so nothing is left armed. +/// it. A pending read is cancelled **and waited to completion** before +/// returning, so nothing is left armed and nothing the kernel may still write +/// to leaves scope. fn read_directory_changes_accepted(handle: HANDLE) -> Result<(), u32> { // `u32`-typed so the buffer is DWORD-aligned, which the API requires. let mut buffer = vec![0u32; 1024]; let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; - // SAFETY: issues one overlapped read into this test's own buffer, which the - // kernel owns until the operation is cancelled below. `lpBytesReturned` is - // null, which the SDK requires for an asynchronous call, and no completion - // routine is used. + // SAFETY: issues one overlapped read into this test's own buffer. The kernel + // owns both the buffer and `overlapped` until the operation *completes* -- + // which is not the same as until it is cancelled, and is why the cancel + // below is followed by a blocking `GetOverlappedResult`. `lpBytesReturned` + // is null, which the SDK requires for an asynchronous call, and no + // completion routine is used. let ok = unsafe { ReadDirectoryChangesW( handle, @@ -140,12 +145,52 @@ fn read_directory_changes_accepted(handle: HANDLE) -> Result<(), u32> { ) }; if ok == 0 { + // The call failed, so no IRP was queued and nothing is outstanding + // against `buffer` or `overlapped`; both may leave scope freely. + // // SAFETY: called immediately after the failing call above. return Err(unsafe { GetLastError() }); } - // SAFETY: `handle` is live, and cancelling is what keeps the (kernel-owned) - // buffer from outliving this frame. + + // A nonzero return here means the read was *queued*, so an IRP is + // outstanding against this frame's `overlapped` and this function's + // `buffer`. + // + // SAFETY: `handle` is live and this thread issued the read above. unsafe { CancelIo(handle) }; + + // **`CancelIo` alone would be a use-after-free.** It only *requests* + // cancellation, returning as soon as the request is marked rather than when + // the operation ends; the IRP still completes asynchronously, and on + // completion the kernel writes `Internal`/`InternalHigh` through the + // `OVERLAPPED` pointer and may copy `FILE_NOTIFY_INFORMATION` bytes into the + // buffer. Both would by then be reclaimed -- `overlapped` is a stack local, + // and the caller invokes this helper twice in a row, so the second call's + // frame lands where the first one's was. MSDN states the rule directly: the + // application must not free or reuse the `OVERLAPPED` structure until the + // cancelled operations have completed. + // + // Nothing else here would enforce that: `hEvent` is null, there is no + // completion port and no APC. So wait, explicitly. This is also what makes + // `Owned`'s `CloseHandle` safe later, since closing a handle with I/O still + // outstanding is another cancellation request rather than a wait. + // + // This crate has already paid for this exact mistake once -- see the + // `STATUS_STACK_BUFFER_OVERRUN` history recorded on the removed + // `reopen_via_existing_handle`. + let mut transferred: u32 = 0; + // SAFETY: `overlapped` still names the pending operation and both it and + // `buffer` are alive across this call. `bWait` is `TRUE`, so this returns + // only once the kernel has finished writing through both. + let completed = unsafe { GetOverlappedResult(handle, &overlapped, &mut transferred, 1) }; + if completed == 0 { + // SAFETY: called immediately after the failing call above. + let err = unsafe { GetLastError() }; + assert_eq!( + err, ERROR_OPERATION_ABORTED, + "a cancelled ReadDirectoryChangesW must complete as aborted, got {err}" + ); + } Ok(()) } From f1d1ad649b0a1e3adf2baf50260387df2463c436 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 15:29:13 -0400 Subject: [PATCH 226/361] fix(ioring): probe the deep end of the NUMA spike's filler, not the shallow one thread-stack-numa-spike exists to answer whether a thread's stack pages are placed by the creator's affinity or by first touch, and it answers that by comparing the NUMA node of a shallow stack page against a deep one. It took the deep probe from filler[last]. The stack grows downward, so filler[last] is the array's *highest* address -- the end nearest the caller's frame -- and filler[0] is the deep one, 65,535 bytes lower by construction whatever the compiler does with the rest of the frame. Measured before and after rather than reasoned about, and the defect was worse than it looked. Printing all three addresses on all three spike threads shows the old probe was not merely likely to share a page with the shallow probe; it shared it every time, 209 bytes away: thread A shallow 0x..dff7c0 filler[last] 0x..dff6ef same page filler[0] 0x..def6f0 16 pages away thread B shallow 0x..effd80 filler[last] 0x..effcaf same page thread C shallow 0x..fffb20 filler[last] 0x..fffa4f same page So `slot.shallow.node != slot.deep.node` was comparing one page against itself. It could not fire in principle, and the spike would have reported "not first touch" on a machine where placement is by first touch -- a confident wrong answer, in a file whose entire method is refusing to give those. Both ends are already written to, so the deep page is resident and reading it costs nothing extra. Still vacuous on a single-node development host, so this cannot be confirmed end to end here; what is confirmed is that the two probes now land 16 pages apart on every thread, which is the property the comparison needs. Both spikes build and run through tools/run-numa-spikes.ps1, exit 0. Found by the SH-3.1.1 diff review. Completed item: SH-16.6: The thread-stack NUMA spike's deep_probe measures the shallow end of its own filler, so the discrimination it exists to make is inert Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 9 ++++++++- .../spikes/thread-stack-numa-spike.rs | 13 ++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 5f37c64c..94611d2d 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -792,13 +792,20 @@ predicted about a 222-commit branch. restate. Note the asymmetry that makes the NUMA arm different and correct: for NUMA, `None` has no honest value, whereas `cache_domain` is already `Option`. -- [ ] **SH-16.6** -- **The thread-stack NUMA spike's `deep_probe` measures the shallow end of its own +- [x] **SH-16.6** -- **The thread-stack NUMA spike's `deep_probe` measures the shallow end of its own filler, so the discrimination it exists to make is inert.** The stack grows down, so `filler[0]` is the deepest address and `filler[last]` sits immediately below the caller's frame -- but the probe takes `&raw const filler[last]`, landing very likely on the same page as the shallow probe rather than 64 KiB away. The spike would then report "not first touch" on a machine where placement *is* by first touch: a confident wrong answer, in a file whose whole point is avoiding those. Both ends are already touched, so probing `filler[0]` is a one-token change. + **Done, and the defect was worse than reported.** Printing the three addresses on all three spike + threads showed the old probe was not merely *likely* on the shallow probe's page -- it was on the + **same page every time**, 209 bytes away, where the review had estimated "at worst adjacent". So + `shallow.node != deep.node` compared one page against itself and could not fire even in principle. + After the change the two probes are 16 pages apart on every thread. Measured on all three threads + (`0x...dff7c0` vs `0x...dff6ef` -> same page; vs `0x...def6f0` -> 16 pages), then the instrumentation + was removed. - [ ] **SH-16.7** -- **A `windows-thread-ambient-sys` test claims a restore-failure it never injects.** `release_reports_a_genuine_restore_failure_and_restores_on_drop_even_without_it` asserts diff --git a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs index b911c2cd..63f20fb5 100644 --- a/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs +++ b/crates/windows-ioring-sys/design-sessions/spikes/thread-stack-numa-spike.rs @@ -162,6 +162,17 @@ struct Slot { /// Forces a page deeper in the stack to be committed, then probes it. The /// array is written to, because a page that is merely reserved is not resident /// and its reported node would be meaningless. +/// +/// **Probes `filler[0]`, and which end is used decides whether this function +/// measures anything at all.** The stack grows downward, so `filler[0]` is the +/// array's *lowest* address -- unconditionally 65,535 bytes below `filler[last]` +/// however the compiler arranges the rest of the frame -- while `filler[last]` +/// sits just under the caller's own frame, very likely on the same page as the +/// shallow probe. Taking the shallow end would leave the two probes +/// indistinguishable, so `slot.shallow.node != slot.deep.node` could essentially +/// never fire and the spike would report "not first touch" on a machine where +/// placement *is* by first touch. Both ends are touched above, so the deep end +/// is resident and costs nothing extra to read. #[inline(never)] fn deep_probe() -> Probe { let mut filler = [0_u8; 64 * 1024]; @@ -171,7 +182,7 @@ fn deep_probe() -> Probe { let last = filler.len() - 1; filler[last] = 1; std::hint::black_box(&filler); - Probe::take((&raw const filler[last]).cast::()) + Probe::take((&raw const filler[0]).cast::()) } unsafe extern "system" fn entry(param: *mut c_void) -> u32 { From a6dbb6e87abd69454666043783f1ab62f12c77e0 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 15:33:27 -0400 Subject: [PATCH 227/361] test(thread-ambient): name the transaction restore test for what it checks release_reports_a_genuine_restore_failure_and_restores_on_drop_even_without_it never observed a restore failure. It expects the release to succeed, and both closing assertions check that restore worked; an in-body comment already conceded the point, "checked by state rather than by return value". The name came from its siblings in declared and error_mode, which do force genuine failures -- a null WOW64 redirection cookie, and SEM_NOALIGNMENTFAULTEXCEPT. Neither has an analogue here: a transaction restore either sets a real handle or clears to "none", and both succeed, so there is no naturally-rejecting value to provoke and this test never had that half. The review that found it reported the hazard as "TransactionGuard::release's error-reporting path reads as covered when it is not". Checked rather than taken, and that part does not hold: the path is covered, by explicit_release_reports_an_injected_restore_failure in the same file, through the FaultPoint::TransactionSet injection built for exactly this. Both tests were run to confirm it. So the defect was only ever the name -- there is no coverage gap to close, and injecting a second failure here would duplicate the test twelve lines above. Renamed to release_and_drop_each_restore_a_real_entry_transaction, which is what it does: a real transaction as the entry state, so that "restored" and "still cleared" are two distinguishable things, then release and Drop each checked. The comment now records why the sibling naming does not apply and points at the test that does cover the failure path, so the missing half is not re-attempted by someone reading the three names side by side. 151 lib tests pass. Found by the SH-3.1.1 diff review. Completed item: SH-16.7: A windows-thread-ambient-sys test claims a restore-failure it never injects Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 15 +++++++++++---- .../src/transaction/tests.rs | 17 ++++++++++++++++- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 94611d2d..4a59301b 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -807,13 +807,20 @@ predicted about a 222-commit branch. (`0x...dff7c0` vs `0x...dff6ef` -> same page; vs `0x...def6f0` -> 16 pages), then the instrumentation was removed. -- [ ] **SH-16.7** -- **A `windows-thread-ambient-sys` test claims a restore-failure it never +- [x] **SH-16.7** -- **A `windows-thread-ambient-sys` test claims a restore-failure it never injects.** `release_reports_a_genuine_restore_failure_and_restores_on_drop_even_without_it` asserts the *opposite*: it `expect`s the release to succeed and both closing assertions check that restore worked. Its siblings in `declared/tests.rs` and `error_mode/tests.rs` do force genuine failures; this - one inherited the name without the failure-injection half, so `TransactionGuard::release`'s - error-reporting path reads as covered when it is not. Either inject the failure or rename the test - to what it checks. + one inherited the name without the failure-injection half. + **Done, by renaming -- and the review's stated hazard did not hold.** It reported that + `TransactionGuard::release`'s error path "reads as covered when it is not". Checked rather than + taken: the path **is** covered, by `explicit_release_reports_an_injected_restore_failure` in the + same file, via a `FaultPoint::TransactionSet` injection built for it. Verified by running both. + So the defect was only ever the name. Renamed to + `release_and_drop_each_restore_a_real_entry_transaction`, and the comment now records *why* the + sibling naming does not apply -- a transaction restore either sets a real handle or clears to + "none", and both succeed, so unlike a null WOW64 cookie or `SEM_NOALIGNMENTFAULTEXCEPT` there is no + naturally-rejecting value to provoke. Written down so the missing half is not re-attempted. ## M-inf: parked, ungated diff --git a/crates/windows-thread-ambient-sys/src/transaction/tests.rs b/crates/windows-thread-ambient-sys/src/transaction/tests.rs index 32af20d2..f34743e5 100644 --- a/crates/windows-thread-ambient-sys/src/transaction/tests.rs +++ b/crates/windows-thread-ambient-sys/src/transaction/tests.rs @@ -408,7 +408,22 @@ fn source_is_present_only_when_an_os_error_was_wrapped() { // not re-investigate it as a gap. #[test] -fn release_reports_a_genuine_restore_failure_and_restores_on_drop_even_without_it() { +fn release_and_drop_each_restore_a_real_entry_transaction() { + // **Named for what it checks.** Its siblings in `declared` and `error_mode` + // carry `release_reports_a_genuine_restore_failure_...` because those + // aspects have a naturally-rejecting value to restore -- a null WOW64 + // redirection cookie, and `SEM_NOALIGNMENTFAULTEXCEPT`. A transaction has + // no equivalent: `restore` either sets a real handle or clears to "none", + // and both succeed, so there is no genuine failure to provoke here and this + // test never had that half. It carried the name anyway, which made + // `release`'s error path read as covered by *this* test. + // + // That path **is** covered, by `explicit_release_reports_an_injected_restore_failure` + // above, through the `FaultPoint::TransactionSet` injection built for + // exactly this -- so the gap was in the name, not in the suite. Do not go + // looking for a non-injected transaction restore failure to add here; there + // isn't one. + // // `TransactionGuard::release -> Ok(())`, `::drop -> ()`, and the `!` deleted from that same // `drop` all survived: an ordinary test thread starts with no transaction, From f16497260fc7d255f1e392687ebb1881f075475a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 17:28:46 -0400 Subject: [PATCH 228/361] docs: open a design session on the cache-locality model, blocking SH-16.5 Stopped mid-fix at the engineer's direction. SH-16.5's contradiction is real, and the fix for it was written and working -- but writing it surfaced a larger question that the fix would have answered silently, so it is reverted rather than landed. The objection: in a crate named "topology" we have a network that assumes three members, and having two confuses it. Why three cache levels and not one, or five, and where are write buffers modeled. Checked rather than agreed with, and it is half right in a way that matters, because the halves are in different files. The base model hardcodes nothing. `level` is a u8, `cache_levels()` is whatever firmware reported, and a regression test already guards against a consumer sweeping 1..=4. One level is a distinct, handled answer. DomainKind has seven variants and Die and Module are genuinely populated from RelationProcessorDie/Module. The crate does model a network. The collapse is downstream, in three places: outermost_partitioning_cache picks one level and discards the rest -- and that one is inside the topology crate, which is where the irony actually lands -- ProcessorPlace::cache_domain is one scalar, and Placement carries three tiers with Package, Die and Module absent entirely. Three consequences, all verified. "Same cache" denotes a different boundary on different machines, so a label is not portable across records. CrossCache conflates "different L2, same L3" with "different L3" wherever two boundaries are live. And it has already cost a row in this project's own matrix: the x64 host's recorded inability to express "same cache, same class" is attributed to hardware, but those sixteen processors do share one L3, so a per-level model would express it. Write buffers are not modelable from this source at all -- GetLogicalProcessorInformationEx does not report them -- which is an OS-surface limit worth stating rather than leaving as an implied gap. The session records the evidence, four options from "name the projection" to "generalise past caches", and six open questions. It takes no decision. SH-16.5 is marked blocked, with the reason recorded as the design question and explicitly not as absence of a consumer. The prototype is preserved outside the repository. SH-16.8 is added for the collapse itself so it is queued rather than living only in a design note. Completed item: SH-16.8 recorded; SH-16.5 blocked pending the session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 24 ++- PLANS.md | 2 +- ...SESSION-2026-09-02-cache-locality-model.md | 161 ++++++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 4a59301b..8928535b 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -25,7 +25,7 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | -| M16 tenth review round | 4 of 7 open | the SH-3.1.1 diff review; SH-16.3 is the worst still open | +| M16 tenth review round | 2 of 8 open | 6 fixed; SH-16.5 and SH-16.8 are blocked on a design session | | M-inf parked | ungated | not scheduled, deliberately | **The critical path is SH-3.1.1 -> SH-3.4 -> M4, and none of it is blocked on M14 or M15.** SH-14.1 @@ -791,6 +791,28 @@ predicted about a 222-commit branch. Decide the rule **once**, in the crate that owns the topology, and have the consumer ask rather than restate. Note the asymmetry that makes the NUMA arm different and correct: for NUMA, `None` has no honest value, whereas `cache_domain` is already `Option`. + **BLOCKED on + [DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) + -- and *not* for want of a consumer.** The fix was implemented; implementing it surfaced a design + question the fix would have silently answered. The primitive it adds is a single "which cache domain + is this processor in", which **is** the single-boundary collapse that session is about, so landing it + would prejudge the outcome. The prototype compiled, and its topology-side tests passed and were + sabotage-verified; it was reverted deliberately and preserved outside the repository as + `sh-16.5-prototype.patch`. The contradiction is real and stays unfixed until the session concludes. + +- [ ] **SH-16.8** -- **The locality model collapses a seven-kind, any-depth topology onto one cache + boundary, and nothing records that as a choice.** Raised by the engineer during the SH-16.5 fix, and + confirmed: `windows-topology-sys` hardcodes no level count (`level` is a `u8`, and a regression test + already guards against a consumer sweeping `1..=4`) and models `Group`, `Package`, `Die`, `Module`, + `Core`, `Cache` and `Memory` -- but `outermost_partitioning_cache` selects one level and discards the + rest, `ProcessorPlace::cache_domain` is one scalar, and `Placement` carries three tiers. + Three consequences, all verified: "same cache" denotes **a different boundary on different machines**, + so a label is not portable across records; `CrossCache` conflates "different L2, same L3" with + "different L3" on any machine with two live boundaries; and it has already cost a row in this + project's own matrix -- the x64 host's "cannot express `same cache, same class`" note in + [DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) is attributed to hardware, but + those sixteen processors do share one L3, so a per-level model would express it. + Gated on the session above, which carries the design space and the open questions. - [x] **SH-16.6** -- **The thread-stack NUMA spike's `deep_probe` measures the shallow end of its own filler, so the discrimination it exists to make is inert.** The stack grows down, so `filler[0]` is diff --git a/PLANS.md b/PLANS.md index 33815138..4d78cd4e 100644 --- a/PLANS.md +++ b/PLANS.md @@ -20,7 +20,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | -| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14 and M15 are the two later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | +| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14, M15 and M16 are the three later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. M16 is the SH-3.1.1 diff review, the first to read the branch as a diff rather than react to a comment: seven findings, six fixed, including a publish-workflow regression this branch had introduced two commits earlier and a soundness hole in the crate about to freeze its API. Its remaining two are blocked on [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), which asks whether collapsing a seven-kind, any-depth topology onto a single cache boundary is the right projection. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | | [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | in progress | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Only the crates.io publication is gated on releasing `windows-topology-sys` and `windows-waitable-queues`** -- an earlier version of this row gated the whole tool on them, which was wrong: the canonical artifact is a CI-built binary compiled from this repository through `path` dependencies, and it needs nothing published. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M1B keys a processor by `(group, number)` so a machine with more than 64 of them is not silently miscounted; M1C measures each NUMA hop in both directions and places the ring deliberately; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 distributes the binary; M5+ publishes to crates.io once the download path is established. | [crates/windows-placement-probe/DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md new file mode 100644 index 00000000..5698ba31 --- /dev/null +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -0,0 +1,161 @@ +# Design session: the cache-locality model + +**Status: OPEN. No decisions taken yet.** This file records the question, the evidence +gathered while framing it, and the design space. It deliberately stops short of choosing, +because the choice affects measurement output and the conclusions already drawn from it. + +Prompted during PR #56's tenth review round while fixing +[SH-16.5](../CHECKLIST-ship-topology-and-queues.md). That item is **blocked on this +session** and must not be implemented before it concludes: the primitive SH-16.5 was about +to add is itself the thing under design. + +A working prototype of the SH-16.5 fix was written and then reverted so as not to prejudge +the outcome. It is preserved outside the repository, in this session's agent workspace, as +`sh-16.5-prototype.patch`. It compiled, and its topology-side tests passed and were +sabotage-verified; it is evidence about one option, not a commitment to it. + +## How the question arose + +SH-16.5 reported a contract contradiction: `windows-topology-sys` documents that a +partitioning cache level is *not* required to cover every online processor, while +`windows-placement-probe` treats any uncovered processor as corruption and fails the whole +run. The agreed direction was to state the rule once, in the crate that owns the topology, +and have the consumer ask. + +Mid-implementation the engineer raised a broader objection, which is the actual subject +here: + +> i think the problem is that the model has to acknowledge more than exactly 3 levels of +> caching. why not 1? 5? where are the write buffers modeled? i am not saying we have to +> start over from scratch, but it is somewhat ironic that in a crate named "topology", we +> have a network where we assume 3 members and only having 2 confuses it + +## What the code actually does, verified rather than assumed + +The objection is half right, and the halves point at different files. + +**The base model hardcodes no level count, and that was deliberate.** `DomainKind::Cache` +carries `level: u8`; `Topology::cache_levels()` returns whatever the firmware reported, +sorted and deduplicated; `caches_at_level` takes any `u8`. There is already a regression +test, `a_partitioning_cache_above_level_four_is_found`, whose comment reads: "`level` is a +`u8`. A consumer sweeping a hard-coded `1..=4` reports this machine as having no +partitioning cache at all." One level works too, and is a distinct answer +(`outermost_partitioning_cache` returns `None`, meaning "nothing divides this machine", +which the docs are explicit is a real answer and not a failure). + +**The base model is also richer than caches.** `DomainKind` has seven variants -- `Group`, +`Package`, `Die`, `Module`, `Core`, `Cache`, `Memory` -- and `Die` and `Module` are +genuinely populated, from `Record::ProcessorDie` and `Record::ProcessorModule`. So the +crate models a multi-tier locality graph, not a three-level cache. + +So the crate named "topology" does model a network. The collapse is downstream of it. + +## Where the collapse actually lives + +Three sites, in increasing severity: + +1. **`Topology::outermost_partitioning_cache()`** -- selects exactly one level (outermost + first, requiring more than one pairwise-disjoint domain) and discards every other level. + This one sits *inside* the topology crate, which is where the engineer's irony lands + squarely: the crate offers a rich model and then a lossy convenience view that consumers + bind to instead. + +2. **`ProcessorPlace::cache_domain: Option`** -- one scalar, therefore one level. + +3. **`core_affinity::Placement`** -- three tiers of locality: `SameCoreSiblings`, one cache + boundary (`{Same,Cross}Cache` x `{Same,Cross}Class`), and `CrossNumaNode`. `Package`, + `Die`, and `Module` are absent entirely, and every cache level except the selected one is + absent. + +## Consequences + +**A. The label is not portable across machines.** "Same cache" means *same L2* on the x64 +development host (eight L2 domains, a single non-partitioning L3) and would mean *same L3* +on a two-CCD part where L3 has two disjoint domains. The same word denotes a different +boundary depending on the machine. `HostFingerprint` does record `partitioning_cache_level` +alongside `cache_domain_sizes`, so a reader *can* disambiguate -- but only by consulting a +different field, and nothing in the label says to. + +**B. It has already cost this project a row in its own measurement matrix.** +[DESIGN-NOTES.md](../crates/windows-waitable-queues/DESIGN-NOTES.md) records, of the x64 +host: + +> Conversely this host cannot express `same cache, same class` at all: its outermost +> partitioning cache is L2, shared by exactly the two siblings of one core, so any two +> processors sharing a cache domain are siblings. + +That is attributed to hardware. It is at least half the model: those sixteen processors +**do** all share one L3. A per-level model would express "different L2, same L3, same class" +on that very host, which is precisely the row the note reports as inexpressible. The +neighbouring claim that "neither host alone can produce the full table" is therefore partly +self-inflicted, and worth re-checking against whatever this session concludes. + +**C. Two different localities are conflated on any machine with two live boundaries.** On a +part with several L3 domains and several L2 domains within each, `CrossCache` covers both +"different L2, same L3" and "different L3", which are very different costs. Separating costs +by locality is the probe's entire purpose, and D-28's conclusions about peer-index caching +are keyed to these labels. + +## What is outside the model entirely, and why + +Write buffers, store buffers, and line-fill buffers are **not modelable from this source**. +`GetLogicalProcessorInformationEx` reports caches, cores, modules, dies, packages, groups +and NUMA nodes; it does not report store-buffer topology at all. This is a limit of the OS +surface rather than an omission in the crate, and it should be stated somewhere rather than +left as an implied gap -- the question is reasonable and its absence currently reads as an +oversight. + +Whether a *measured* locality tier (something the placement probe establishes empirically +rather than reading from firmware) belongs in this model is a separate and open question. +Note that `Provenance` already exists to distinguish measured from reported claims, so the +crate has a place to put such a thing if the answer is yes. + +## Design space + +Not mutually exclusive; roughly increasing in cost. + +**Option 1 -- name the projection, change nothing else.** Document that +`outermost_partitioning_cache` is one view and that `Placement`'s three tiers are a +deliberate projection, with the portability caveat (consequence A) stated at both. Cheapest, +and it converts an apparent assumption into a recorded choice. Does not address B or C. + +**Option 2 -- add a level-agnostic primitive beside the projection.** Something in the shape +of `shared_cache_levels(a, b) -> Vec` or `deepest_shared_cache(a, b) -> Option`, so +that "do these share a cache?" becomes "at which levels do these share?". The projection +stays for callers that want one boundary to shard on, which is a legitimate scheduler +question. Unblocks SH-16.5 without deepening the collapse. Does not by itself change what the +probe reports. + +**Option 3 -- make a measurement row name its own boundary.** Reshape `Placement` (or the +row that carries it) so "same cache" is qualified by level. Fixes A and C. Changes +measurement output, so it touches D-28's recorded conclusions and the fingerprint's +comparability across existing records -- which is exactly why it is a decision and not a +refactor. + +**Option 4 -- generalise past caches.** `Package`, `Die`, and `Module` are modeled and +discarded. If locality tiers are the real subject, the projection is arguably +"which is the tightest domain these two share, over all kinds" rather than anything +cache-specific. Largest change; also the one that most directly answers "we have a network, +stop assuming three members". + +## Open questions for the session + +1. Is the single-boundary projection *right for the probe's purpose* and merely + under-documented, or is it wrong? A scheduler sharding work does want exactly one + boundary; a probe characterising a machine may not. +2. If a row names its level, what happens to existing records and to D-28's conclusions? + Are they re-derivable from what was recorded, or would they need re-measuring? +3. Does the matrix hole in consequence B actually close under a per-level model, on the + hardware available? That is checkable and should be checked before it is claimed. +4. Should `Die` / `Module` / `Package` participate, or is cache-level generality enough? +5. Does a measured (as opposed to firmware-reported) locality tier belong in + `windows-topology-sys` at all, given `Provenance` exists? +6. Where should the note about write buffers being outside the OS surface live? + +## Status of dependent work + +- **SH-16.5 is blocked on this session.** The contradiction it reports is real and still + unfixed; `windows-placement-probe` still refuses a partially-covering level that + `windows-topology-sys` deliberately permits. +- No other M16 item is affected. The other six findings from that round are fixed and + committed. From e061c6029d18c80b2d75de6ca2b132e37f5656de Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 18:03:18 -0400 Subject: [PATCH 229/361] docs: record the settled direction on the locality model, and two findings it exposed The engineer separated the subject into two questions and answered both, then gave a constraint on the representation. Recorded in the session file. The operative decision: where a level of hierarchy may or may not be present, do not model it as a second-class Option. That conflates "Win32 did not provide data" with "the level was specifically found not to be present". Choose a representation designed for the topology to represent observed connectivity. This reframes the whole session. The single-boundary collapse is a symptom; the cause is that presence and observation are not modeled at all. It also rules out the reverted SH-16.5 prototype, whose Unknown arm merges "not reported" with "reported, does not name this processor" -- the same conflation one level down. Three findings came out of checking the direction against the code. The principle is already in-house and applied inconsistently. MachineDescription::cpu_model records suppression in a separate field "rather than left to be inferred from absence: a field withheld by the runner and a field the host would not answer are different facts, and a collector that cannot tell them apart will eventually read one as the other." Reached independently, for a different field, and solved there with the weaker Option-plus-flag form. The reasoning is settled precedent here, not a new claim. Win32 is not fully consumed. All seven GetLogicalProcessorInformationEx relations are, but GetSystemCpuSetInformation appears nowhere in the workspace, and it is a second parallel model carrying LastLevelCacheIndex -- Windows's own LLC grouping, a different answer from "outermost partitioning cache" and directly comparable against it -- plus SchedulingClass, AllocationTag and per-processor Parked/Allocated/RealTime state. Filed as SH-16.10. So the answer to "do we expose everything a real system would reveal through Win32" is no. Nothing infers a hierarchy level from measurement. The engineer suspected the -probe crates might already and flagged the uncertainty; checked, and they do not. They measure cost per firmware-reported placement, and the NUMA spikes infer policy rather than structure. Provenance has a Measured variant but it qualifies a whole Topology, not one relation. So that capability is new work, not a retrofit, and the per-relation provenance it needs does not exist. Separately, the outermost-partitioning-cache rule turns out to be stated three times with two different meanings: windows-platform-probes omits the pairwise disjointness check that windows-topology-sys requires, over a summary it builds itself despite already depending on that crate. The two disagree on any topology with overlapping domains. Filed as SH-16.9, with the sequencing hazard noted -- fixing it against today's method means redoing it after the model lands. A candidate representation is proposed for reaction, with its own open sub-questions. No shape is decided. Completed item: SH-16.9 and SH-16.10 recorded; direction added to the session Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 31 +++- ...SESSION-2026-09-02-cache-locality-model.md | 132 +++++++++++++++++- 2 files changed, 156 insertions(+), 7 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 8928535b..60b41f3b 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -25,7 +25,7 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | -| M16 tenth review round | 2 of 8 open | 6 fixed; SH-16.5 and SH-16.8 are blocked on a design session | +| M16 tenth review round | 4 of 10 open | 6 fixed; SH-16.5/16.8/16.9/16.10 wait on a design session | | M-inf parked | ungated | not scheduled, deliberately | **The critical path is SH-3.1.1 -> SH-3.4 -> M4, and none of it is blocked on M14 or M15.** SH-14.1 @@ -813,6 +813,35 @@ predicted about a 222-commit branch. [DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) is attributed to hardware, but those sixteen processors do share one L3, so a per-level model would express it. Gated on the session above, which carries the design space and the open questions. + **Direction now settled** by the engineer: presence and observation must be modeled, not + collapsed into an `Option`. "Win32 did not report it" and "it was found not to be present" are + different facts, and the representation must be built for **observed connectivity** rather than + for a ladder of levels with optional rungs. That rules out the SH-16.5 prototype's `Unknown` arm, + which merges both. Shape still open. + +- [ ] **SH-16.9** -- **The "outermost partitioning cache" rule is stated three times, and two of the + three disagree.** `Topology::outermost_partitioning_cache` requires more than one partition **and** + pairwise disjointness. `Observation::outermost_partitioning_cache` in `windows-platform-probes` is + `caches.iter().filter(|c| c.domains > 1).max_by_key(|c| c.level)` -- **no disjointness check** -- + computed over a `CacheLevel` summary that crate builds itself, even though it already depends on + `windows-topology-sys`. On a hand-built or deserialized topology with overlapping domains the two + crates give different answers to the same question. `windows-placement-probe` restates it a third + time by rebuilding the map from the partition list, which is SH-16.5. + A [CONTRACT INTEGRITY](.github/copilot-instructions.md) defect of the exact shape the rules name: + a rule re-encoded by a consumer rather than derived from the owner. Note the ordering -- fixing + this by pointing both consumers at today's method would have to be redone once SH-16.8 lands, so + either fix it now and accept the rework, or sequence it after the design session. + +- [ ] **SH-16.10** -- **`GetSystemCpuSetInformation` is not consumed anywhere, so a whole Win32 + topology model is unexposed.** The crate consumes all seven `GetLogicalProcessorInformationEx` + relations, but `SYSTEM_CPU_SET_INFORMATION` is a *second, parallel* model carrying at least + `LastLevelCacheIndex` -- Windows's own LLC grouping, which is a **different answer** from + "outermost partitioning cache" and would be directly comparable against it -- plus + `SchedulingClass`, `AllocationTag`, `EfficiencyClass`, and per-processor `Parked` / `Allocated` / + `RealTime` state. + Raised by the engineer's question of whether we expose everything a real system would reveal + through the Win32 API set. Today the answer is **no**. Verify the field list against the SDK + before relying on it. Gated on SH-16.8, since what shape it lands in depends on the model. - [x] **SH-16.6** -- **The thread-stack NUMA spike's `deep_probe` measures the shallow end of its own filler, so the discrimination it exists to make is inert.** The stack grows down, so `filler[0]` is diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md index 5698ba31..e2abd075 100644 --- a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -1,8 +1,10 @@ # Design session: the cache-locality model -**Status: OPEN. No decisions taken yet.** This file records the question, the evidence -gathered while framing it, and the design space. It deliberately stops short of choosing, -because the choice affects measurement output and the conclusions already drawn from it. +**Status: OPEN, with direction settled.** The engineer has taken sides on both underlying +questions (see "Direction taken" below); what remains is the concrete representation. The +options section further down predates that direction and is kept as a record of what was +considered -- Options 1 and 2 are now insufficient on their own, because both preserve the +`Option`-shaped absence the direction rejects. Prompted during PR #56's tenth review round while fixing [SH-16.5](../CHECKLIST-ship-topology-and-queues.md). That item is **blocked on this @@ -30,6 +32,119 @@ here: > start over from scratch, but it is somewhat ironic that in a crate named "topology", we > have a network where we assume 3 members and only having 2 confuses it +## Direction taken + +The engineer separated the subject into two questions and answered both, then gave a +constraint on the representation. Recorded in their terms: + +**1. What model does the Windows API set represent?** Do we expose all the topological +nuance that a real system, described through that API, would reveal -- "not every mechanical +combination of representable values: a reasoned set of logical derived models that would be +represented through the Win32 API set". So the target is completeness with respect to what +Win32 can express about a real machine, not an arbitrary product of enum values. + +**2. What memory-hierarchy concepts might a system we encounter have, whether or not Win32 +exposes them?** These affect analysis regardless of API exposure, and the `-probe` crates +exist partly to establish them by measurement. + +**3. The representation constraint, which is the operative decision.** Where a level of +hierarchy may or may not be present, do **not** model it as a second-class `Option`, because +that conflates two different facts: *Win32 did not provide data* and *the level was +specifically found not to be present*. Instead choose a representation that is designed for +the topology to **represent the observed connectivity**. + +This supersedes the framing further down that treated "collapse to one boundary" as the whole +problem. The collapse is a symptom; the cause is that presence and observation are not +modeled. + +### What this rules in and out + +- Out: `Option`, and equally `Option` plus a side boolean, as the way to say a level is + missing. Also out: the `CachePlacement` prototype from SH-16.5, whose `Unknown` arm merges + "not reported" with "reported, does not name this processor". +- In: a representation where a sharing relation that was *observed not to exist* and one that + was *never observed* are different values, and where a measured relation can sit beside a + firmware-reported one. +- Still open: the concrete shape. See "Proposed representation" below. + +## New evidence gathered after the direction was set + +**The principle is already in-house, written down, and applied inconsistently.** +`MachineDescription::cpu_model` in `windows-placement-probe` records: + +> Suppression is recorded in `model_suppressed` rather than left to be inferred from absence: +> a field withheld by the runner and a field the host would not answer are different facts, +> and a collector that cannot tell them apart will eventually read one as the other. + +That is the engineer's point exactly, reached independently for a different field. It is +solved there with `Option` plus a side boolean, which is the weaker form the direction above +rules out -- but the *reasoning* is settled precedent in this repository, not a new claim. + +**Win32 is not fully consumed: CPU Sets is entirely absent.** The crate consumes seven +`GetLogicalProcessorInformationEx` relations (`ProcessorCore`, `ProcessorPackage`, +`ProcessorDie`, `ProcessorModule`, `Cache`, `NumaNode`, `Group`), which is essentially all of +that API. But `GetSystemCpuSetInformation` / `SYSTEM_CPU_SET_INFORMATION` is not referenced +anywhere in the workspace, and it is a **second, parallel topology model** Windows offers, +carrying at least: `LastLevelCacheIndex` (Windows's own LLC grouping, which is a *different* +answer from "outermost partitioning cache"), `SchedulingClass`, `AllocationTag`, +`EfficiencyClass`, and per-CPU `Parked` / `Allocated` / `RealTime` state. Verify the exact +field list against the SDK before relying on it. This is directly responsive to question 1: +the answer today is **no**, there is a whole Win32 model unexposed. + +**Nothing currently infers a hierarchy level from measurement.** The engineer suspected the +`-probe` crates might already do this and flagged uncertainty. Checked: they do not. The +probes measure *cost per firmware-reported placement* (`core_affinity` times handoffs between +pairs already classified from the topology), and the NUMA spikes infer *policy* -- first-touch +versus creator affinity, per-volume versus per-file -- not structure. `Provenance` already has +a `Measured` variant, but it qualifies a whole `Topology`, not an individual relation. So the +capability question 2 describes is **new work, not a retrofit**, and the per-relation +provenance it needs does not exist yet. + +**The "outermost partitioning cache" rule is stated three times, and two of the three +disagree.** `Topology::outermost_partitioning_cache` requires more than one partition **and** +pairwise disjointness. `Observation::outermost_partitioning_cache` in +`windows-platform-probes` is `caches.iter().filter(|c| c.domains > 1).max_by_key(|c| c.level)` +-- no disjointness check -- over a `CacheLevel` summary it builds itself, even though that +crate does depend on `windows-topology-sys`. On a hand-built or deserialized topology with +overlapping domains the two answer differently. `windows-placement-probe` restates it a third +time by rebuilding the map from the partition list, which is SH-16.5. Tracked separately as +SH-16.9. + +## Proposed representation, for reaction + +Offered as a starting shape, not a conclusion. + +Stop modeling a *ladder of levels with optional rungs* and model the *observed sharing +relations* directly. A topology becomes a set of relations, each carrying: + +- **what is shared** -- cache at level N, module, die, package, memory domain; +- **which processors share it** -- the `ProcessorSet` already used; +- **how it was established** -- reported by a named Win32 source, measured by a named probe, + or determined absent. + +Presence then stops being an `Option`. A machine with no L3 has *no L3 relation in the set*, +and that is an observation rather than a missing value; a machine whose firmware was not +queried for L3 carries a *not-observed* record for it. The two are different members, not the +same `None`. + +Connectivity queries follow from it: "at which relations do A and B share?" returns the +observed set, and the difference between *they share nothing* (an empty answer over complete +observations) and *we do not know* (incomplete observations) is representable rather than +collapsed. `outermost_partitioning_cache` survives as one named projection over that set, for +the scheduler question of "give me exactly one boundary to shard on", and is documented as a +projection rather than as the model. + +Open sub-questions this raises: + +- Does provenance belong per-relation, or per-source with relations pointing at a source? + Per-relation is simpler to consume; per-source is honest about the fact that one Win32 call + produced many relations at once. +- Should "determined absent" be a relation with an empty processor set, or a distinct record? + An empty set already means something else in this crate (`memory_domains` deliberately + keeps a processor-less memory domain, D-5), so overloading it looks like a trap. +- Does `Topology`'s existing whole-object `Provenance` stay, become derived from the + per-relation provenances, or get superseded? + ## What the code actually does, verified rather than assumed The objection is half right, and the halves point at different files. @@ -140,9 +255,14 @@ stop assuming three members". ## Open questions for the session -1. Is the single-boundary projection *right for the probe's purpose* and merely - under-documented, or is it wrong? A scheduler sharding work does want exactly one - boundary; a probe characterising a machine may not. +**Settled by the direction above:** question 1 below (the projection is kept, but as a named +projection over a connectivity model, not as the model); question 6 (the write-buffer note +belongs with question 2's measured tier, since that is the only mechanism that could ever +establish one). + +1. ~~Is the single-boundary projection right, or wrong?~~ **Settled: it survives as one + projection among others, and is documented as such.** A scheduler sharding work does want + exactly one boundary; the error was letting that answer be the model. 2. If a row names its level, what happens to existing records and to D-28's conclusions? Are they re-derivable from what was recorded, or would they need re-measuring? 3. Does the matrix hole in consequence B actually close under a per-level model, on the From 5b3b78d015acaac379ef134f63f88c5ae85a7844 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 18:12:08 -0400 Subject: [PATCH 230/361] docs: settle provenance placement, absence, and the object-level scalar Three sub-questions answered, and the answers open two sharper ones. Provenance is per-relation. Asked for a case where it could differ per source and could not produce one worth having: any per-source fact is expressible per-relation by repetition, and the reverse is not. What decides it runs the other way -- two sources describing the same relation. Win32 reporting that two processors share L3 while a probe measures otherwise is expressible only if one relation holds both observations; per-source would force two whole topologies and a diff. The per-source instinct was reaching for something real but misnamed: completeness of an observation attempt. "Source S was queried about dies and said nothing" cannot attach to a relation because there is no relation. That is the absence record, which is now a distinct record rather than a relation with an empty processor set -- an empty set already means something else here, since memory_domains deliberately keeps a processor-less memory domain (D-5). The whole-object Provenance is superseded and is not replaced by another whole-object scalar. With trust per-relation such a scalar could only be the minimum, which is useless -- ninety-nine measured relations and one synthetic would read SYNTHETIC -- or the maximum, which is dishonest. Trust belongs to an answer, which falls out of modeling connectivity: the model exists to answer queries, so the query result is what needs the label. Two questions those answers open, both recorded rather than decided. Whether provenance is a scalar or a chain. Today it is a scalar and deserialization is lossy: downgraded_to is min against a Restored ceiling, so a measured relation that round-trips loses that it was ever measured. That matters more per-relation, where a measured relation is expensive to establish. The scalar was conflating trust assertable now with origin history. Whether one relation can hold several observations. It probably must, and the project already reasons this way -- file-handle-numa-spike argues that agreement between two sources is consistent with a hypothesis but does not establish it, and only disagreement is decisive. That is an adjudication rule over coexisting observations, and a model storing one winning value per relation cannot express it. Detecting a hypervisor that misreports topology is the same shape. Nothing is carried over from the old model for its own sake. Two of its properties are kept only because they re-derive independently: the default is the untrusted value, an argument that gets stronger under per-relation because there are more places to forget; and trust never upgrades, since a file still cannot establish it describes the machine you are on. Completed item: session sub-questions settled; SH-16.8 shape converging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...SESSION-2026-09-02-cache-locality-model.md | 83 ++++++++++++++++--- 1 file changed, 71 insertions(+), 12 deletions(-) diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md index e2abd075..4a7b2829 100644 --- a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -1,7 +1,11 @@ # Design session: the cache-locality model -**Status: OPEN, with direction settled.** The engineer has taken sides on both underlying -questions (see "Direction taken" below); what remains is the concrete representation. The +**Status: OPEN, with direction settled and the representation converging.** The engineer has +taken sides on both underlying questions (see "Direction taken" below) and settled three +sub-questions about the proposed shape: provenance is **per-relation**, "determined absent" +is a **distinct record**, and the whole-object `Provenance` is **superseded** rather than +re-derived. Nothing is carried over from the old model for its own sake; two of its +properties are kept only because they re-derive independently. The options section further down predates that direction and is kept as a record of what was considered -- Options 1 and 2 are now insufficient on their own, because both preserve the `Option`-shaped absence the direction rejects. @@ -134,16 +138,71 @@ collapsed. `outermost_partitioning_cache` survives as one named projection over the scheduler question of "give me exactly one boundary to shard on", and is documented as a projection rather than as the model. -Open sub-questions this raises: - -- Does provenance belong per-relation, or per-source with relations pointing at a source? - Per-relation is simpler to consume; per-source is honest about the fact that one Win32 call - produced many relations at once. -- Should "determined absent" be a relation with an empty processor set, or a distinct record? - An empty set already means something else in this crate (`memory_domains` deliberately - keeps a processor-less memory domain, D-5), so overloading it looks like a trap. -- Does `Topology`'s existing whole-object `Provenance` stay, become derived from the - per-relation provenances, or get superseded? +### Sub-questions, answered + +**Provenance is per-relation.** Asked for a case where it could differ per *source*; there +is none worth having. Any per-source fact is expressible per-relation by repetition, and the +reverse is not true -- so per-relation strictly subsumes it. The case that decides it runs the +other way: **two sources describing the same relation**. Win32 reporting that A and B share +L3 while a probe measures otherwise is expressible only if one relation can hold both +observations; per-source would force two whole topologies and a diff. + +What the per-source instinct was actually reaching for is not provenance but **completeness +of an observation attempt** -- "source S was queried about dies and said nothing" cannot +attach to a relation, because there is no relation. That is the absence record, settled +below. + +**"Determined absent" is a distinct record**, not a relation with an empty processor set. An +empty set already means something else here (`memory_domains` deliberately keeps a +processor-less memory domain, D-5), so overloading it would be a trap. + +**The whole-object `Provenance` is superseded, and should not be replaced by another +whole-object scalar.** Derivation: with trust per-relation, an object-level scalar can only be +the minimum (a topology with ninety-nine measured relations and one synthetic reads +`SYNTHETIC`, which is useless) or the maximum (which is dishonest). Trust belongs to an +**answer** -- "A and B share L3, established by these observations" carries its own -- and that +falls directly out of modeling observed connectivity, since a connectivity model exists to +answer queries and the query result is the thing needing a label. + +### Two questions those answers open + +**Is provenance a scalar or a chain?** Today it is a scalar, and deserialization is a *lossy* +downgrade: `downgraded_to` is `min` against a `Restored` ceiling. A measured relation that +round-trips through a file loses "originally measured, on this machine, at this time", which +matters more per-relation because a measured relation is expensive to establish. From base +principles these are two things one scalar was forced to conflate: **trust assertable now** +(never upgradeable) and **origin history** (recorded, conferring no trust). + +**Can one relation hold more than one observation?** It probably must, and the project already +reasons this way. From `file-handle-numa-spike.rs`: + +> **Agreement is consistent with volume locality; it does not establish it.** A genuinely +> per-file answer may equal its volume's node ... so one file agreeing rules nothing out. Only +> disagreement is decisive, because a per-volume answer cannot differ from itself. + +That is an asymmetric adjudication rule over two independent observations of one underlying +fact, and it only works if the observations coexist. A model storing one winning value per +relation cannot express it -- and detecting a hypervisor that misreports topology is exactly +this shape. + +### Two principles that re-derive rather than being inherited + +The direction is explicitly to find the right model rather than carry anything over. Two +properties of the old `Provenance` are principles rather than model, and both survive that +test on their own merits: + +- **The default is the untrusted value.** Under per-relation provenance this argument is + *stronger*, not weaker: there are far more places to forget. +- **Trust never upgrades.** Identical derivation -- a file still cannot establish that it + describes the machine you are on. + +### Still open + +- Scalar-versus-chain, and if a chain, what a consumer asks it. +- Whether multiple observations per relation are held as a set, or reduced on insert with the + reduction recorded. +- What a query returns when observations disagree: a value plus a conflict marker, or the + conflict itself, forcing the caller to adjudicate. ## What the code actually does, verified rather than assumed From 39aa2bfdfd004f8e48cf825abf4104d7a2f74c45 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 18:33:31 -0400 Subject: [PATCH 231/361] docs: fold the locality-model work into PR #56, correcting five stale gate claims Decision: #56 does not merge until the new locality model lands. M16's four gated items (SH-16.5, SH-16.8, SH-16.9, SH-16.10) are one piece of work -- replacing the model, consuming CPU Sets, collapsing three restatements of one rule -- and they move onto the critical path. This contradicts what the file said in five places, all of which stated that none of the later rounds gated SH-3.4. Swept rather than fixing only the status table: the summary at the top, the M3 preamble, the block above M14, SH-3.1.1's own body, and PLANS.md. Two of those were written earlier today, in the commit that fixed the review-round ordering, so this is a correction to a correction and worth being explicit about rather than quietly overwriting. The distinction that makes M16 different from M14 and M15, recorded so the exception does not read as inconsistency: M14 and M15 concern a defect in an implementation, which can ship disclosed and be fixed in 0.2.1. M16 concerns the shape of the public model windows-topology-sys 0.2.0 would publish, and a published model cannot be reshaped without another break. SH-3.1.1 moves after the model work rather than before it. Writing the PR description first would be wrong twice: it would omit the largest change in the branch, and it would list the locality model among the deferred things when it is not deferred. Ordering is now: design session concludes, then M16's model work, then SH-3.1.1 describes and promotes, then SH-3.4 merges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 53 ++++++++++++++----- PLANS.md | 2 +- ...SESSION-2026-09-02-cache-locality-model.md | 6 +++ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 60b41f3b..118da8f7 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -18,21 +18,31 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. |---|---|---| | M1 settle the public surface | **done, archived** | -- | | M2 repair the release plumbing | 1 of 5 open | only SH-2.3, which needs the merge commit | -| M3 land the branch | 4 of 5 open | **SH-3.1.1: review the diff and take the PR out of draft** | +| M3 land the branch | 4 of 5 open | now gated on M16; SH-3.1.1 runs after the model lands | | M4 release | open | M3 | | M5 verify from outside | open | M4 | | M6 long-running validation | open | gates SH-4.3, so it gates the queue crate's publication | | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | -| M16 tenth review round | 4 of 10 open | 6 fixed; SH-16.5/16.8/16.9/16.10 wait on a design session | +| M16 tenth review round | 4 of 10 open | **gates the merge**; 6 fixed, 4 wait on the design session | | M-inf parked | ungated | not scheduled, deliberately | -**The critical path is SH-3.1.1 -> SH-3.4 -> M4, and none of it is blocked on M14 or M15.** SH-14.1 -ships disclosed rather than fixed ([D-36](crates/windows-waitable-queues/DESIGN-NOTES.md#d-36)) and -the disclosure -- which was the actual release blocker -- landed at SH-15.8. So M14 and M15 conclude -after 0.1.0 without holding it up, provided the pull request **says** that is deliberate; SH-3.1.1 -owns that. What *does* block the queue crate specifically is M6. +**The critical path is M16's locality-model work -> SH-3.1.1 -> SH-3.4 -> M4.** M14 and M15 do not +block it: SH-14.1 ships disclosed rather than fixed +([D-36](crates/windows-waitable-queues/DESIGN-NOTES.md#d-36)) and the disclosure -- which was the +actual release blocker -- landed at SH-15.8, so both conclude after 0.1.0 provided the pull request +**says** that is deliberate. SH-3.1.1 owns saying it. + +**M16 is different, and this was decided rather than drifted into.** Its four gated items +(SH-16.5, SH-16.8, SH-16.9, SH-16.10) are one piece of work -- replacing the locality model, +consuming CPU Sets, and collapsing three restatements of one rule -- and the decision is that +**PR #56 does not merge until it lands**. They were briefly listed here as non-blocking; that is +corrected. It is gated in turn on +[DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), +which has open questions, so **design concludes before implementation starts**. + +What blocks the queue crate specifically, and separately, is M6. ## Before checking anything off in this file @@ -204,12 +214,18 @@ opened on 2026-08-31, nine review rounds arrived while it sat open, and the merg happened. Review rounds are **reactive** -- they cannot be scheduled after SH-3.4, because merging ends the pull request they are rounds *of*. -**Which of those rounds gate the merge: none of them, and that is a decision rather than an -accident.** SH-14.1 is a real defect that ships **disclosed rather than fixed** +**Which of those rounds gate the merge: M16 does, M14 and M15 do not.** SH-14.1 is a real defect +that ships **disclosed rather than fixed** ([D-36](crates/windows-waitable-queues/DESIGN-NOTES.md#d-36), delivered by SH-15.8), and everything open in M15 is follow-on work on the fix. What *did* gate the release was the disclosure, and that landed. So SH-3.4 may proceed with M14 and M15 still open -- but a reviewer must be told that is -deliberate, which is SH-3.1's job below. +deliberate, which is SH-3.1.1's job below. + +**M16 is the exception, by decision.** Its locality-model work (SH-16.5, SH-16.8, SH-16.9, +SH-16.10) is a merge blocker: the model it replaces is the one `windows-topology-sys` 0.2.0 would +publish, and shipping a public surface that is already known to be the wrong shape is what the +milestone exists to avoid. So SH-3.4 waits on it, and the design session it depends on must +conclude first. - [x] **SH-3.1** -- ~~Open the pull request~~ **-- already open since 2026-08-31 as a draft.** Checked off as *superseded by events*, not as done: the item asked for something that had already happened @@ -227,6 +243,10 @@ deliberate, which is SH-3.1's job below. **The description must state what is knowingly unfinished**, so a reviewer does not read open milestones as oversight: SH-14.1 ships disclosed per D-36, M15 is follow-on work on its fix, and `permit_mpsc` is an experimental non-default module exempt from the crate's semver promise. + **Gated on M16's locality-model work**, which is in scope for this PR by decision. The description + cannot be written before then without being wrong twice over: it would omit the largest change in + the branch, and it would list the locality model among the deferred things when it is not deferred. + So this item now runs *after* SH-16.5/16.8/16.9/16.10, not before them. - [ ] **SH-3.2** -- Run the full gate on the merge result, not merely on the branch tip: `cargo fmt --check`, `cargo clippy --all-targets`, `cargo check --all-targets` in **both** debug and release, @@ -368,11 +388,18 @@ that arrives while a pull request is open, so their position at the end of this order and not a schedule. Reading it as a schedule would put the review of a pull request after the merge that closes it. -**None of the open items below gates SH-3.4**, which is a decision rather than an oversight: the -defect they concern ships **disclosed rather than fixed** +**M14 and M15 do not gate SH-3.4; M16 does.** For M14 and M15 that is a decision rather than an +oversight: the defect they concern ships **disclosed rather than fixed** ([D-36](crates/windows-waitable-queues/DESIGN-NOTES.md#d-36)), and the disclosure -- which *was* the release blocker -- landed at SH-15.8. SH-3.1.1 is responsible for saying so in the pull request -description, so a reviewer does not mistake open milestones for unfinished business. +description, so a reviewer does not mistake those open milestones for unfinished business. + +**M16's locality-model work is a merge blocker**, by decision rather than by drift -- an earlier +revision of this file listed it alongside the others as non-blocking, and that is corrected here. +The reason it differs: M14 and M15 concern a defect in an *implementation*, which can ship +disclosed, whereas M16 concerns the *shape of the public model* `windows-topology-sys` 0.2.0 would +publish. A disclosed implementation defect can be fixed in 0.2.1; a published model cannot be +reshaped without another break. ## M14: PR #56 ninth review round -- an ABA hole the wrap test would not have caught diff --git a/PLANS.md b/PLANS.md index 4d78cd4e..30f02f22 100644 --- a/PLANS.md +++ b/PLANS.md @@ -20,7 +20,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | -| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14, M15 and M16 are the three later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. M16 is the SH-3.1.1 diff review, the first to read the branch as a diff rather than react to a comment: seven findings, six fixed, including a publish-workflow regression this branch had introduced two commits earlier and a soundness hole in the crate about to freeze its API. Its remaining two are blocked on [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), which asks whether collapsing a seven-kind, any-depth topology onto a single cache boundary is the right projection. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | +| [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14, M15 and M16 are the three later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. M16 is the SH-3.1.1 diff review, the first to read the branch as a diff rather than react to a comment: seven findings, six fixed, including a publish-workflow regression this branch had introduced two commits earlier and a soundness hole in the crate about to freeze its API. Its remaining four are blocked on [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), which began by asking whether collapsing a seven-kind, any-depth topology onto a single cache boundary is the right projection and has since settled that presence and observation must be modeled rather than collapsed into an `Option`. **That work now gates the merge**: unlike M14 and M15, which concern a defect in an implementation that can ship disclosed, M16 concerns the shape of the public model `windows-topology-sys` 0.2.0 would publish, and a published model cannot be reshaped without another break. So M3 waits on M16, and M16 waits on the session. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | | [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) | in progress | A small publishable Windows tool a stranger can install, run once, and send back one structured result, so this workspace can collect placement and NUMA-hop measurements from hardware it does not own. The gap is concrete: every host available here has one NUMA node, so the whole `cross NUMA node` row and the inter-node hop matrix are unmeasured and no local work will change that. Extracted rather than published in place, because [crates/windows-platform-probes](crates/windows-platform-probes) is deliberately "an experiment, not a component". **Only the crates.io publication is gated on releasing `windows-topology-sys` and `windows-waitable-queues`** -- an earlier version of this row gated the whole tool on them, which was wrong: the canonical artifact is a CI-built binary compiled from this repository through `path` dependencies, and it needs nothing published. M1 settles the name, what the record says about the machine beyond the fingerprint, and the fate of the existing probe binaries; M1B keys a processor by `(group, number)` so a machine with more than 64 of them is not silently miscounted; M1C measures each NUMA hop in both directions and places the ring deliberately; M2 moves the measurement modules; M3 defines a schema-versioned submission record; M4 covers the runner's experience and trust; M5 distributes the binary; M5+ publishes to crates.io once the download path is established. | [crates/windows-placement-probe/DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md), [crates/windows-platform-probes/DESIGN-NOTES.md](crates/windows-platform-probes/DESIGN-NOTES.md) | | [CHECKLIST-thread-ambient.md](CHECKLIST-thread-ambient.md) | in progress | M22-M23: extract the captured-context composite into `windows-thread-ambient-sys`, a standalone platform layer that captures a thread's ambient state and applies it on another thread. M24-M26: `windows-namespace-request-sys`, marshalable Win32 namespace call parameter sets, over a round-one entry list audited from three real consumers (this repository's watcher and enumeration crates, and `MikeGrier/Globazog-rs`) rather than guessed. M27: `windows-platform-probes`, a durable home for the measurements this workspace's designs rest on, under a three-tier scheme (asserted / ignored / binary-only) where every tier is compiled by an ordinary build. Feature-scoped and deleted when complete; it is the whole of the `mikegrier/thread-ambient` branch's work, and is deliberately separate from the deferred namespace-facility items in [CHECKLIST.md](CHECKLIST.md). | [crates/windows-thread-ambient-sys/DESIGN-NOTES.md](crates/windows-thread-ambient-sys/DESIGN-NOTES.md) | | [crates/windows-overlapped-io-sys/CHECKLIST.md](crates/windows-overlapped-io-sys/CHECKLIST.md) | not started | M14: finish the contract audit -- categories 1, 2, 6, 8, 9 were not examined -- and sweep `outstanding()` for the advisory-predicate hazard. | [crates/windows-overlapped-io-sys/DESIGN-NOTES.md](crates/windows-overlapped-io-sys/DESIGN-NOTES.md) | diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md index 4a7b2829..148fb6cd 100644 --- a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -10,6 +10,12 @@ options section further down predates that direction and is kept as a record of considered -- Options 1 and 2 are now insufficient on their own, because both preserve the `Option`-shaped absence the direction rejects. +**This session is on PR #56's critical path.** The work it gates is in scope for that PR by +decision -- #56 does not merge until the new model lands -- because the model being replaced is +the one `windows-topology-sys` 0.2.0 would publish, and a published model cannot be reshaped +without another break. So this session concludes before implementation starts, and +implementation lands before the PR is described or promoted. + Prompted during PR #56's tenth review round while fixing [SH-16.5](../CHECKLIST-ship-topology-and-queues.md). That item is **blocked on this session** and must not be implemented before it concludes: the primitive SH-16.5 was about From 3b6404205308f496040c1fea2f22245d40edc246 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 19:47:14 -0400 Subject: [PATCH 232/361] docs: settle the concreteness bar, and find the field that proves it Provenance is a scalar and the topology is a point in time, not a historical record. History is deliberately out of scope: allowing it drags in a much wider set of sources to reconcile and the enterprise becomes a mess. We work from the best data available now. That is explicitly not a licence to pare the model down to today's needs. This project has repeatedly found the information it had was inadequate -- the ARM64 host with no L3 that forced "outermost level that partitions" rather than "level 3", the guard test against a consumer sweeping 1..=4, group-awareness where a bare cpu5 cannot say whether the group was considered and was zero or never consulted, and machine.rs distinguishing a withheld field from an unanswerable one. The two positions are not in tension because they are different axes: breadth in structure, narrowness in time. The concreteness bar: the model must be usable for shaping memory allocations, thread counts and assignments, and ring topology shapes without further measurement. It answers from what was already observed, with no probing at decision time. Three consequences. Measured facts must live in the model rather than only in probe output, which is what makes per-relation provenance load-bearing rather than decorative. The not-observed record gains a second job, since a consumer needing an unmeasured fact cannot acquire it and must be told plainly so it can degrade deliberately. And -- architecturally -- there must be an explicit measurement phase on the real machine: combined with trust-never-upgrades this rules out shipping a pre-measured topology, because a file caps at Restored, and lazy measurement on first need is just probing at decision time wearing a hat. So the model acquires a lifecycle, and something has to own the middle of it. Then found the field that demonstrates all of it at once. Topology::distances exists and is always None -- discover() hardcodes it, every other construction sets it, no consumer reads it. Win32 cannot supply node distance; ACPI carries SLIT and no Win32 API surfaces it, so measurement is the only source. And windows-placement-probe already measures the equivalent through node_pairs_measured(), rendering per-node-pair handoff cost with ring placement into a table that goes nowhere else. So the fact is needed, the field exists, the measurement exists, and nothing connects them. Under the bar above a consumer shaping memory allocation must either run the probe at decision time, which is forbidden, or guess. Filed as SH-16.11. Also noted on SH-16.10 that CPU Sets' Parked and Allocated bear directly on thread counts and assignments -- one of the three named decisions -- so that gap is already costing a named use rather than being speculative completeness. Completed item: SH-16.11 recorded; concreteness bar and lifecycle settled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 17 ++++- ...SESSION-2026-09-02-cache-locality-model.md | 64 ++++++++++++++++++- 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 118da8f7..b4b11f83 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -25,7 +25,7 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | -| M16 tenth review round | 4 of 10 open | **gates the merge**; 6 fixed, 4 wait on the design session | +| M16 tenth review round | 5 of 11 open | **gates the merge**; 6 fixed, 5 wait on the design session | | M-inf parked | ungated | not scheduled, deliberately | **The critical path is M16's locality-model work -> SH-3.1.1 -> SH-3.4 -> M4.** M14 and M15 do not @@ -869,6 +869,21 @@ predicted about a 222-commit branch. Raised by the engineer's question of whether we expose everything a real system would reveal through the Win32 API set. Today the answer is **no**. Verify the field list against the SDK before relying on it. Gated on SH-16.8, since what shape it lands in depends on the model. + Note `Parked` and `Allocated` bear directly on **thread counts and assignments**, one of the three + decisions the model exists to serve, so this is a gap already costing a named use rather than + speculative completeness. + +- [ ] **SH-16.11** -- **`Topology::distances` is a field for a fact Win32 cannot supply, it is never + populated, and the measurement that would fill it already exists elsewhere.** `discover()` + hardcodes `distances: None`, every other construction sets `None`, and no consumer reads the + field. Windows exposes no API for NUMA node distance -- ACPI carries SLIT, Win32 does not surface + it -- so measurement is the only source. `windows-placement-probe` **already measures the + equivalent** through `node_pairs_measured()`, producing per-node-pair handoff cost with ring + placement, and renders it as a table that goes nowhere else. + This is the canonical case for the whole model: under the bar that the model must be usable + **without further measurement**, a consumer shaping memory allocation must today either run the + probe at decision time -- forbidden -- or guess. Gated on SH-16.8, and on the open question of + which component owns the measurement phase. - [x] **SH-16.6** -- **The thread-stack NUMA spike's `deep_probe` measures the shallow end of its own filler, so the discrimination it exists to make is inert.** The stack grows down, so `filler[0]` is diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md index 148fb6cd..74947942 100644 --- a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -202,13 +202,75 @@ test on their own merits: - **Trust never upgrades.** Identical derivation -- a file still cannot establish that it describes the machine you are on. +### Provenance is a scalar, and the topology is a point in time + +Settled. The topology describes **a particular instance at a point in time**, not a historical +record. There may be room for both eventually, but history is deliberately out of scope now: +once historical record is allowed, a much wider set of sources has to be reconciled and the +whole enterprise becomes a mess. The model works from the best data available right now. + +**This does not license paring the model down to what today's consumers need.** The engineer +was explicit, and the repository's history supports it: this project has repeatedly found the +information it had was inadequate -- the ARM64 host with no L3 that forced "outermost level +that partitions" rather than "level 3"; the guard test against a consumer sweeping `1..=4`; +group-awareness, where "a bare `cpu5` cannot tell a reader whether the group was considered and +was zero, or never consulted at all"; and `machine.rs` distinguishing a withheld field from an +unanswerable one. Foreclosing on any single moment's understanding risks losing exactly what is +needed next. + +The two are not in tension, because they are different axes: **breadth in structure, narrowness +in time.** A point-in-time snapshot can be structurally complete. History is the axis that +drags in multiple sources and reconciliation. + +### How concrete: "usable without further measurement" + +The bar, in the engineer's words, is that the abstract model be massaged into something usable +for shaping memory allocations, thread counts and assignments, and ring topology shapes, +**without further measurement -- the model answers from what was already observed, with no +probing at decision time.** + +Three consequences follow, and the third is architectural. + +**1. Measured facts must live in the model, not only in probe output.** If a decision needs a +fact only measurement can supply, and probing at decision time is forbidden, the measurement +has to already be there. This is why per-relation provenance is load-bearing rather than +decorative: a consumer must be able to see whether "these share L3" came from firmware or from +a probe, and cannot go and check. + +**2. The not-observed record gains a second job.** A consumer needing an unmeasured fact +*cannot acquire it*. So the model must say "not measured" plainly and let the caller degrade +deliberately, rather than presenting an absence the caller silently reads as a value. + +**3. There must be an explicit measurement phase on the real machine.** Combined with "trust +never upgrades", this rules out shipping a pre-measured topology: a file caps at `Restored`, so +its measurements cannot be trusted as *this* machine's. And lazy measurement on first need is +just probing at decision time. So the model acquires a lifecycle -- observe (cheap, firmware), +measure (expensive, on-machine), decide (no I/O) -- and something has to own the middle phase. + +### The canonical case, already half-built: NUMA distances + +`Topology::distances: Option` exists, and every path sets it to `None`. +`Topology::discover` hardcodes `distances: None`; no consumer reads the field. Meanwhile +**Win32 cannot supply it** -- ACPI carries SLIT, but no Win32 API surfaces node distances -- and +`windows-placement-probe` **already measures the equivalent**, via `node_pairs_measured()`, +producing per-node-pair handoff cost with ring placement and rendering it as a table. + +So the fact is needed, the field exists, the measurement exists, and nothing connects them. A +consumer shaping memory allocation today must either run the probe at decision time, which the +bar above forbids, or guess. This is the whole design in one field, and it is tracked as +SH-16.11. + ### Still open -- Scalar-versus-chain, and if a chain, what a consumer asks it. +- **Who owns the measurement phase, and what does it cost?** A `discover()` that measures is + expensive and surprising; a separate enrich step is honest but can be skipped; lazy is ruled + out by the bar above. - Whether multiple observations per relation are held as a set, or reduced on insert with the reduction recorded. - What a query returns when observations disagree: a value plus a conflict marker, or the conflict itself, forcing the caller to adjudicate. +- What a consumer does when a needed fact is `not measured` -- is degrading its choice, or does + the model offer a documented fallback? ## What the code actually does, verified rather than assumed From afcd789c77fef4a035f56f1e7bd9e24a3269973c Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 20:43:30 -0400 Subject: [PATCH 233/361] docs: file the execution-domain planner as its own component Working the Seastar case turned up a gap rather than an answer. Asked what representation is most useful to consumers, the honest way to answer was to name one and walk its construction: a shard-per-core runtime building SPSC/MPSC rings between pinned threads over NUMA-local buffers. The walk found the mapping from topology to shard plan was unowned. CHECKLIST-io-domains.md M32 lists the contracts "the runtime cannot be written without" and all four concern the queue -- ordering, correlation, backpressure, transcription. Meanwhile M33+.1 opens with "one pinned thread, its IoRing, its node-local registered pool, its shard", which presupposes a plan naming which thread, which node and which shard. Nothing computed that plan, and no item anywhere claimed it. It is now crates/windows-execution-plan, a component of its own, because it applies policy over the topology's facts and reasonable clients will choose differently. One domain per core or per thread, whether efficiency cores are peers, what proximity justifies SPSC -- none of those are facts about a machine. Fusing the two is what produced outermost_partitioning_cache: a policy answer living in the facts crate, which three consumers then re-derived differently. The walk also settled the vocabulary argument on use rather than aesthetics. The load-bearing query is pairwise proximity, asked once per pair of shards because that is what selects the channel, and the current model answers only the global question reduced to a boolean. Under ordering-by-inclusion that is one query; under a firmware ladder it is a fixed sequence of "same L1? same L2? same L3?" that breaks on the ARM64 host with no L3 and cannot express a measured tier. M1 is deliberately a requirements milestone and the only active one. The design session asked what shape is most useful to consumers, and this crate is the consumer -- so stating what it needs is what unblocks the model, not what waits on it. Its three queries map onto gaps already filed: the shard set needs parked and allocated state (SH-16.10), proximity has no answer at all (SH-16.8), and residency needs distances that exist as a field, are never populated, and are already measured somewhere else (SH-16.11). Reciprocal callouts on both sides, so neither is discoverable only by inference: M32 gains an item recording that the plan is not one of its contracts and where it went, and the handoff note says M33+ now has two prerequisites rather than one. Also corrected M32's heading, which said "all three" while listing four. The crate name is provisional and deliberately absent from the release-please config, the publish workflow tag patterns, and the manifest, since changing it is cheap now and expensive after any of those. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 16 ++- PLANS.md | 1 + crates/windows-execution-plan/CHECKLIST.md | 106 ++++++++++++++++++ crates/windows-execution-plan/COMPONENT.md | 66 +++++++++++ ...SESSION-2026-09-02-cache-locality-model.md | 23 ++++ 5 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 crates/windows-execution-plan/CHECKLIST.md create mode 100644 crates/windows-execution-plan/COMPONENT.md diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 7c6b9a2f..6c7b9edb 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -539,7 +539,8 @@ hardware the session could not obtain. What N>1 adds is additive, not a second m ## M32 -- Contracts the runtime cannot be written without These are decision items, not implementation. Each is open in the session record, and each would change -the runtime's shape, so all three land before M33+ begins. +the runtime's shape, so they land before M33+ begins. (The heading said "all three" while listing four; +it now lists five, and the count is dropped rather than maintained.) - [ ] **M32.1** -- **The ordering guarantee.** Open since the 2026-08-27 namespace session, which observed that `DeleteFile(X)` then `CreateFile(X)` on a pool does not execute in order and said the @@ -566,9 +567,22 @@ the runtime's shape, so all three land before M33+ begins. two-layer ring. **A decision recorded only in a session record steers nothing**, and this checklist is the mechanism that makes them binding. +- [ ] **M32.5** -- **Note that the shard plan is *not* one of these contracts, and where it went.** + M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard", + which presupposes a mapping naming which thread, which node and which shard -- and that mapping was + unowned: M32's other four contracts are all about the queue, and no item anywhere computed the plan. + It is now [crates/windows-execution-plan](crates/windows-execution-plan/COMPONENT.md), a component + of its own, because it applies **policy** over the topology's facts and reasonable clients will + choose differently. Nothing here needs to decide it; this item exists so a reader of M33+ does not + conclude the mapping is obvious, which is how it went missing. + > **-> CROSS-COMPONENT HANDOFF:** M33+ below spans `crates/windows-thread-ambient-sys`, > `crates/windows-namespace-request-sys`, and `crates/windows-ioring-sys`. Each has its own > [CHECKLIST.md](CHECKLIST.md); the items are held here until M32 settles, then move to the component that owns them. +> +> **The plan M33+ executes comes from +> [crates/windows-execution-plan/CHECKLIST.md](crates/windows-execution-plan/CHECKLIST.md)**, which is +> itself gated on the locality-model design session. So M33+ has two prerequisites, not one. ## M33+ -- The domain runtime (gated on M32) diff --git a/PLANS.md b/PLANS.md index 30f02f22..fb4cad4b 100644 --- a/PLANS.md +++ b/PLANS.md @@ -18,6 +18,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| | [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | +| [crates/windows-execution-plan/CHECKLIST.md](crates/windows-execution-plan/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a `Topology` to a set of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 is a *requirements* milestone and is the only active one -- it states what the topology must answer, feeding the open locality-model session, which asked what shape is most useful to consumers and is being answered by naming one. M2+ and M3+ are parked on that session concluding. | [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14, M15 and M16 are the three later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. M16 is the SH-3.1.1 diff review, the first to read the branch as a diff rather than react to a comment: seven findings, six fixed, including a publish-workflow regression this branch had introduced two commits earlier and a soundness hole in the crate about to freeze its API. Its remaining four are blocked on [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), which began by asking whether collapsing a seven-kind, any-depth topology onto a single cache boundary is the right projection and has since settled that presence and observation must be modeled rather than collapsed into an `Option`. **That work now gates the merge**: unlike M14 and M15, which concern a defect in an implementation that can ship disclosed, M16 concerns the shape of the public model `windows-topology-sys` 0.2.0 would publish, and a published model cannot be reshaped without another break. So M3 waits on M16, and M16 waits on the session. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/windows-execution-plan/CHECKLIST.md new file mode 100644 index 00000000..06a3aeda --- /dev/null +++ b/crates/windows-execution-plan/CHECKLIST.md @@ -0,0 +1,106 @@ +# Checklist: the execution-domain planner + +Plans the mapping from a `Topology` to a set of execution domains. See +[COMPONENT.md](COMPONENT.md) for what this crate is and why it is separate from both the topology +crate and the runtime. + +## Where this stands + +**Nothing is implemented.** M1 is the only active milestone, and it is deliberately a +*requirements* milestone rather than an implementation one: its output is the concrete statement +of what `windows-topology-sys` must answer, which the open design session needs in order to settle +the model. + +> **-> CROSS-COMPONENT PREREQUISITE:** M2 onwards cannot begin until +> [DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) +> concludes and `SH-16.8` lands in +> [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). The +> planner's central query has no answer in the current model. + +| Milestone | State | What it is waiting on | +|---|---|---| +| M1 the input contract | in progress | nothing -- it is what unblocks the others | +| M2+ the plan as a value | parked | M1, and the topology model landing | +| M3+ the policies | parked | M2+ | +| M-inf parked | ungated | not scheduled, deliberately | + +## M1: state what the planner needs from the topology + +The point of doing this first: the design session asks what representation is most useful to +consumers, and **this crate is the consumer**. Answering in the abstract has already produced one +wrong answer this session. Each item below states a query the planner makes, why it makes it, and +whether the topology can answer it today -- so the model is designed against a real caller. + +- [ ] **EP-1.1** -- **The shard-set query.** Which processors may host a domain: online, with + identity carried as `(group, number)` rather than a bare number, with efficiency class and SMT + structure available so a policy can choose one domain per core or per thread and can decide + whether efficiency cores are peers. **Gap already identified:** parked and allocated state is not + available at all, and pinning a domain to a parked processor is a defect a client cannot detect. + Tracked as `SH-16.10`. + +- [ ] **EP-1.2** -- **The proximity query, which is the crux.** For an *ordered pair* of + processors, how close are they -- because that is what chooses SPSC versus MPSC versus a routed + hop, and it is asked once per pair rather than once per machine. **The current model cannot + answer it**: `outermost_partitioning_cache` reports one global level and `same_cache_domain` + reduces it to a boolean at that level, so a client reconstructs the rest and, per `SH-16.9`, + reconstructs it differently each time. State the query precisely enough that the session can + design against it. + +- [ ] **EP-1.3** -- **The residency query.** Which memory domain each processor belongs to, and -- + for a pair spanning two of them -- what it costs to place a shared buffer on one side rather than + the other. **Gap already identified:** `Topology::distances` exists, is never populated, and Win32 + cannot populate it; the measurement exists in `windows-placement-probe` and reaches nothing. + Tracked as `SH-16.11`. The probe measures this per node pair with a dedicated ring-placement + column precisely because it was found to matter. + +- [ ] **EP-1.4** -- **What the planner does with an unanswered query**, given the model's bar is + that it answers without further measurement. A fact that was not observed cannot be acquired at + planning time, so decide per query whether the planner degrades to a documented weaker policy, + refuses to plan, or emits a plan carrying an explicit "this was chosen without knowing X" marker. + The third is the only one that survives review of a plan by a human, which is one of the reasons + a plan is a value. + +- [ ] **EP-1.5** -- **Hand the resulting requirements to the design session** as the consumer-side + input it asked for, and record in the session which of them the settled model answers and which + it deliberately does not. + > **-> CROSS-COMPONENT HANDOFF:** next work is in the repository root -> + > [DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) + > -> `SH-16.8` in + > [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). + +## M2+: the plan as a value + +Parked, not pending. Gated on the topology model landing. Shape recorded so it is not lost, per the +`M{n}+` convention. + +- [ ] **M2+.1** -- The plan type: domains, each with its processor, its memory domain and its + channels; inspectable and comparable, constructible against a synthetic topology so a machine + nobody has can be planned for and reviewed. + +- [ ] **M2+.2** -- Rendering a plan for a human to read before anything is pinned or allocated, + including which queries were unanswered and what was assumed in their place. + +- [ ] **M2+.3** -- Validation against synthetic topologies drawn from the shapes this repository has + actually met: the ARM64 host with no L3, the x64 host whose outermost partitioning cache is L2 + shared by SMT siblings, a hybrid part with efficiency classes, and a machine with more than 64 + processors so the group boundary is exercised rather than assumed. + +## M3+: the policies + +Parked. These are the choices the crate exists to make, and each is a decision item rather than an +implementation one. + +- [ ] **M3+.1** -- Domain-per-core versus domain-per-thread, and whether efficiency cores are peers, + excluded, or a second tier. + +- [ ] **M3+.2** -- The channel policy: what proximity justifies SPSC, what falls back to MPSC, and + whether any pair is deliberately not connected directly at all. + +- [ ] **M3+.3** -- Buffer residency for a channel spanning two memory domains, which the placement + probe already measures and which has no default that is right on both sides. + +## M-inf: parked, ungated + +- [ ] **M-inf.1** -- Re-planning at runtime, when processors are parked, hot-added, or the process + is given a different CPU-set allocation than it started with. Deliberately not scheduled: it needs + the static case to exist first, and it is a different problem. diff --git a/crates/windows-execution-plan/COMPONENT.md b/crates/windows-execution-plan/COMPONENT.md new file mode 100644 index 00000000..66afdafb --- /dev/null +++ b/crates/windows-execution-plan/COMPONENT.md @@ -0,0 +1,66 @@ +# windows-execution-plan + +**Planned, not built.** This directory currently holds a plan and no code. It becomes a crate +when [CHECKLIST.md](CHECKLIST.md) M2 begins; until then it exists so the work has an owner and a +place, rather than living as an assumption inside somebody else's milestone. + +## What it is + +Takes a `Topology` and produces a **plan for execution domains**: which processors get a domain, +where each domain's thread is pinned, which memory node each domain allocates from, what channel +connects each pair of domains, and where each channel's buffer lives. + +The shape it plans for is the one +[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M33+ describes -- "one pinned thread, its +`IoRing`, its node-local registered pool, its shard" -- which is a Seastar-style shard-per-core +runtime. + +## Why it is separate + +Because two different kinds of statement were being made by one crate. + +**`windows-topology-sys` states facts.** Which processors exist, what they share, at what +granularity, how that was established, and what was measured. It never says "use an SPSC ring +here", because that is not a fact about the machine. + +**This crate applies policy.** One domain per core or per thread? Are efficiency cores peers or +excluded? SPSC everywhere, or SPSC within a cache domain and something else across one? Those are +choices, they depend on the workload, and reasonable clients will differ. + +Keeping them in one crate has a specific failure mode, already observed: a policy answer gets +mistaken for a fact and consumers bind to it. `outermost_partitioning_cache` is that -- a single +policy choice ("give me one boundary to shard on") sitting in the facts crate, which three +consumers then re-derived differently. See +[CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md) SH-16.9. + +## The seam, and how to tell if it is in the right place + +**The planner must not re-derive anything.** If it has to work out for itself which cache level +partitions the machine, or reconstruct a mapping the topology already knows, the seam is wrong and +the missing query belongs in `windows-topology-sys`. + +That test is the reason this crate is being planned *before* the topology model is finished rather +than after: its input requirements are the concrete statement of what the model has to answer, and +they feed the open design session directly. + +## Why it is not the runtime either + +The runtime (M33+, spanning `windows-ioring-sys`, `windows-thread-ambient-sys` and +`windows-namespace-request-sys`) *executes* a plan: it creates the threads, binds them, allocates +the pools, constructs the rings. This crate decides what that plan should be. + +Separating them means a plan is a **value** -- inspectable, comparable, testable against a +synthetic topology for a machine nobody has, and reviewable by a human before anything is pinned +or allocated. A planner fused into the runtime can only be tested by running it on the machine it +plans for, which is exactly the class of test this repository has repeatedly found inadequate. + +## Status and gating + +Blocked on +[DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md). +The planner's central query -- "how close are these two processors?" -- has no answer in the +current topology model, and what shape it takes is the subject of that session. + +The crate name is provisional. Changing it is cheap now and expensive once it is in +`release-please-config.json`, the publish workflow's tag patterns, and the manifest -- so it is +deliberately absent from all three until the name is ratified. diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md index 74947942..79ed8aca 100644 --- a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -260,6 +260,29 @@ consumer shaping memory allocation today must either run the probe at decision t bar above forbids, or guess. This is the whole design in one field, and it is tracked as SH-16.11. +### The consumer this is being designed for + +"Most useful for consumers" was answered by naming one: a Seastar-style shard-per-core runtime +building SPSC/MPSC rings between pinned threads over NUMA-local buffers. Walking that construction +produced a requirements list, and it is now owned by +[crates/windows-execution-plan](../crates/windows-execution-plan/CHECKLIST.md) M1 rather than being +carried in this session as prose. + +The walk found the load-bearing query is **pairwise proximity** -- "how close are these two +processors" -- asked once per pair of shards, because that is what selects SPSC versus MPSC versus a +routed hop. The current model answers only the *global* question (`outermost_partitioning_cache`, +one level for the whole machine) reduced to a boolean at that level (`same_cache_domain`), so the +pairwise question has no answer at all today. + +That settles the vocabulary argument on use rather than on aesthetics. Under ordering-by-inclusion, +pairwise proximity is one query over the order. Under a firmware-anchored ladder it is a fixed +sequence of "same L1? same L2? same L3? same die? same node?", which breaks on the ARM64 host with +no L3 and cannot express a measured-only tier at all. + +The walk also found the mapping itself was **unowned**: `CHECKLIST-io-domains.md` M32 lists four +contracts "the runtime cannot be written without" and all four concern the queue, while M33+.1 +presupposes a plan naming which thread, which node and which shard. That is now a component. + ### Still open - **Who owns the measurement phase, and what does it cost?** A `discover()` that measures is From 0ef85549501934a04f5230fce3956e3412e324d3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 20:50:24 -0400 Subject: [PATCH 234/361] docs(execution-plan): state the shard-set query, and find a sentinel that collides EP-1.1 asks what the planner must know to choose which processors host a domain. Recorded as EP-D-1, with each of its five inputs checked against the model rather than assumed -- which is the point of doing requirements against a real caller instead of in the abstract. Three are answered cleanly. ProcessorId is (group, number) by construction, which matters because pinning is a GROUP_AFFINITY and a flattened index names a different processor in every group. Processor::online is exactly the "slot exists but nothing can run there" distinction. DomainKind::Core carries simultaneous_multithreading and the sibling set, so the per-core versus per-thread lever is a walk of cores(). Availability is not answered at all: GetSystemCpuSetInformation is consumed nowhere, so Parked and Allocated are unavailable and a planner cannot avoid pinning a domain to a parked processor. Already filed as SH-16.10. The fourth input turned up something the item had not anticipated. Processor::capacity is computed as online.then(|| find the owning Core domain).flatten().unwrap_or(0) so the value 0 means the processor is offline, or is online but named by no Core domain, or genuinely has efficiency class zero. The third is every processor on every non-hybrid machine, so this is not a rare collision between a sentinel and a valid value -- the collision is the usual case. It is worse for this consumer than for most. Windows orders efficiency class with 0 as least performant, so on a hybrid part an unknown processor is indistinguishable from an efficiency core: a policy that excludes efficiency cores silently drops a processor that may be a performance core, and a policy that tiers them puts it in the wrong tier. Neither failure shows up in a functional test, only in a percentile. Filed against the owning crate as SH-16.12. This is a third instance of the pattern SH-16.8 exists to fix and the one not previously swept, the others being ProcessorPlace::cache_domain's Option and MachineDescription::cpu_model. Note it is strictly worse than an Option: a sentinel colliding with a valid value cannot be distinguished even by a careful caller. Gated on SH-16.8 since the fix is the same question -- how absence is represented. Interim guidance recorded at the decision: read DomainKind::Core { efficiency_class }, which carries the firmware value with no sentinel and represents absence by the processor being in no Core domain, which is a distinguishable state rather than a value. Also recorded that a processor in no Core domain is a real state the topology tolerates by design, so EP-1.4 is not written as though the case were hypothetical. Completed item: EP-1.1: The shard-set query Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 23 +++- PLANS.md | 2 +- crates/windows-execution-plan/CHECKLIST.md | 15 ++- crates/windows-execution-plan/DESIGN-NOTES.md | 111 ++++++++++++++++++ 4 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 crates/windows-execution-plan/DESIGN-NOTES.md diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index b4b11f83..4b093495 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -25,7 +25,7 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | -| M16 tenth review round | 5 of 11 open | **gates the merge**; 6 fixed, 5 wait on the design session | +| M16 tenth review round | 6 of 12 open | **gates the merge**; 6 fixed, 6 wait on the design session | | M-inf parked | ungated | not scheduled, deliberately | **The critical path is M16's locality-model work -> SH-3.1.1 -> SH-3.4 -> M4.** M14 and M15 do not @@ -885,6 +885,27 @@ predicted about a 222-commit branch. probe at decision time -- forbidden -- or guess. Gated on SH-16.8, and on the open question of which component owns the measurement phase. +- [ ] **SH-16.12** -- **`Processor::capacity` uses `0` as both a legitimate efficiency class and a + sentinel for "not known", and the two collide on the common case.** It is computed + `online.then(|| find the owning Core domain).flatten().unwrap_or(0)`, so `0` means the processor is + offline, *or* is online but named by no `Core` domain, *or* genuinely has efficiency class zero. + The third is **every processor on every non-hybrid machine**, so the sentinel is not a rare + collision -- it is the usual value. + Found by [crates/windows-execution-plan](crates/windows-execution-plan/DESIGN-NOTES.md#ep-d-1) + EP-1.1 while checking what a shard planner can rely on, and it is worse for that consumer than for + most: Windows orders efficiency class with `0` as **least** performant, so on a hybrid part an + unknown processor is indistinguishable from an efficiency core. A policy excluding efficiency cores + would silently drop a processor that may be a performance core; a policy tiering them would put it + in the wrong tier. Neither shows up in a functional test. + **A third instance of the pattern SH-16.8 exists to fix**, and the one not previously swept -- the + others being `ProcessorPlace::cache_domain`'s `Option` (SH-16.5) and + `MachineDescription::cpu_model`, where the same conflation was noticed and solved with a side + boolean. Note this one is *worse* than an `Option`: a sentinel that collides with a valid value + cannot be distinguished even by a careful caller. Gated on SH-16.8, since the fix is the same + question -- how absence is represented -- and doing it twice would be doing it twice. + Note `DomainKind::Core { efficiency_class }` already carries the value without a sentinel, so the + interim guidance is to read that instead; the defect is that `capacity` exists and looks usable. + - [x] **SH-16.6** -- **The thread-stack NUMA spike's `deep_probe` measures the shallow end of its own filler, so the discrimination it exists to make is inert.** The stack grows down, so `filler[0]` is the deepest address and `filler[last]` sits immediately below the caller's frame -- but the probe diff --git a/PLANS.md b/PLANS.md index fb4cad4b..9ff16d3c 100644 --- a/PLANS.md +++ b/PLANS.md @@ -18,7 +18,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| | [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | -| [crates/windows-execution-plan/CHECKLIST.md](crates/windows-execution-plan/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a `Topology` to a set of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 is a *requirements* milestone and is the only active one -- it states what the topology must answer, feeding the open locality-model session, which asked what shape is most useful to consumers and is being answered by naming one. M2+ and M3+ are parked on that session concluding. | [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | +| [crates/windows-execution-plan/CHECKLIST.md](crates/windows-execution-plan/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a `Topology` to a set of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 is a *requirements* milestone and is the only active one -- it states what the topology must answer, feeding the open locality-model session, which asked what shape is most useful to consumers and is being answered by naming one. M2+ and M3+ are parked on that session concluding. EP-1.1 is done and already earned its keep: checking the shard-set query against the model found `Processor::capacity` using `0` as both a valid efficiency class and a "not known" sentinel, which collide on every non-hybrid machine (filed as SH-16.12). | [crates/windows-execution-plan/DESIGN-NOTES.md](crates/windows-execution-plan/DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14, M15 and M16 are the three later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. M16 is the SH-3.1.1 diff review, the first to read the branch as a diff rather than react to a comment: seven findings, six fixed, including a publish-workflow regression this branch had introduced two commits earlier and a soundness hole in the crate about to freeze its API. Its remaining four are blocked on [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), which began by asking whether collapsing a seven-kind, any-depth topology onto a single cache boundary is the right projection and has since settled that presence and observation must be modeled rather than collapsed into an `Option`. **That work now gates the merge**: unlike M14 and M15, which concern a defect in an implementation that can ship disclosed, M16 concerns the shape of the public model `windows-topology-sys` 0.2.0 would publish, and a published model cannot be reshaped without another break. So M3 waits on M16, and M16 waits on the session. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/windows-execution-plan/CHECKLIST.md index 06a3aeda..d8044695 100644 --- a/crates/windows-execution-plan/CHECKLIST.md +++ b/crates/windows-execution-plan/CHECKLIST.md @@ -19,7 +19,7 @@ the model. | Milestone | State | What it is waiting on | |---|---|---| -| M1 the input contract | in progress | nothing -- it is what unblocks the others | +| M1 the input contract | 1 of 5 done | nothing -- it is what unblocks the others | | M2+ the plan as a value | parked | M1, and the topology model landing | | M3+ the policies | parked | M2+ | | M-inf parked | ungated | not scheduled, deliberately | @@ -31,12 +31,23 @@ consumers, and **this crate is the consumer**. Answering in the abstract has alr wrong answer this session. Each item below states a query the planner makes, why it makes it, and whether the topology can answer it today -- so the model is designed against a real caller. -- [ ] **EP-1.1** -- **The shard-set query.** Which processors may host a domain: online, with +- [x] **EP-1.1** -- **The shard-set query.** Which processors may host a domain: online, with identity carried as `(group, number)` rather than a bare number, with efficiency class and SMT structure available so a policy can choose one domain per core or per thread and can decide whether efficiency cores are peers. **Gap already identified:** parked and allocated state is not available at all, and pinning a domain to a parked processor is a defect a client cannot detect. Tracked as `SH-16.10`. + **Done:** stated as [EP-D-1](DESIGN-NOTES.md#ep-d-1), with each of its five inputs checked against + the model rather than assumed. Three are answered cleanly; availability is not answered at all; + and the fourth turned up a defect the item had not anticipated. + **`Processor::capacity` is unsafe for reading efficiency class.** It is + `online.then(find owning Core).flatten().unwrap_or(0)`, so `0` means offline, *or* in no core + domain, *or* genuinely class zero -- and the third is every processor on every non-hybrid machine, + so the sentinel collides with the common legitimate value. Worse here than elsewhere, because + Windows orders class `0` as *least* performant: on a hybrid part an unknown processor is + indistinguishable from an efficiency core, so a policy excluding them silently drops a possible + performance core and a policy tiering them mis-tiers it. Neither fails a functional test. Filed + against the owning crate as `SH-16.12`; use `DomainKind::Core { efficiency_class }` meanwhile. - [ ] **EP-1.2** -- **The proximity query, which is the crux.** For an *ordered pair* of processors, how close are they -- because that is what chooses SPSC versus MPSC versus a routed diff --git a/crates/windows-execution-plan/DESIGN-NOTES.md b/crates/windows-execution-plan/DESIGN-NOTES.md new file mode 100644 index 00000000..9c4a6b01 --- /dev/null +++ b/crates/windows-execution-plan/DESIGN-NOTES.md @@ -0,0 +1,111 @@ +# Design notes: the execution-domain planner + +Current canonical decisions for this component. See [COMPONENT.md](COMPONENT.md) for what the +component is; see [CHECKLIST.md](CHECKLIST.md) for what is planned. + +While M1 runs, most entries here are **queries** rather than choices: the planner's requirements +on `windows-topology-sys`, stated precisely enough that the topology model can be designed against +a real caller instead of against a guess. + +## Decision index + +| ID | Decision | +|---|---| +| EP-D-1 | **The shard-set query**: what the planner must know to choose which processors host a domain, and what today's model cannot tell it. | + +## EP-D-1: the shard-set query + +*Recorded by [CHECKLIST.md](CHECKLIST.md) EP-1.1.* + +### What the planner is choosing + +Which processors may host an execution domain, and how to group them so a policy can pick between +one domain per core and one per logical processor, and can decide whether efficiency cores are +peers, a second tier, or excluded. + +This is the first step of the construction and it fixes the domain count, which everything +downstream is shaped by: the number of rings is quadratic in it, and each domain's memory pool is +sized against it. + +### What it must know, and why + +1. **Identity, as `(group, number)`.** Not a bare index. A processor number without its group names + a different processor in every group and the wrong one in all but the first, and pinning is a + `GROUP_AFFINITY` -- `SetThreadGroupAffinity`, not `SetThreadAffinityMask`, which cannot name + another group at all. A planner that flattens this produces a plan that is silently wrong above + 64 processors. + +2. **Whether the processor is online.** An offline slot exists and counts toward a group's maximum; + planning a domain onto one is planning a thread that cannot run. + +3. **Core membership and whether the core is SMT.** The choice between one domain per core and one + per logical processor is the single largest policy lever, and it needs the sibling grouping, not + just a count. + +4. **Efficiency class.** On a hybrid part, putting latency-sensitive domains on efficiency cores is + a defect the client will not see in a functional test, only in a percentile. + +5. **Whether the processor is available to this process at all** -- parked by the scheduler, or + outside the CPU-set allocation the process was given. + +### What today's model answers + +Points 1 through 3 cleanly. `ProcessorId` is `(group, number)` by construction and documents why +(D-7). `Processor::online` is exactly the distinction in point 2. `DomainKind::Core` carries +`simultaneous_multithreading` and the sibling set, so point 3 is a walk of `Topology::cores()`. + +Point 4 is answered, but **twice, in two shapes, and one of them is unsafe to use** -- see below. + +Point 5 is **not answered at all**. `GetSystemCpuSetInformation` is consumed nowhere in the +workspace, so `Parked`, `Allocated` and `AllocatedToTargetProcess` are unavailable. A planner +cannot currently avoid pinning a domain to a parked processor, and the client cannot detect that it +happened. Tracked as `SH-16.10` in +[CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). + +### `Processor::capacity` must not be used for point 4 + +**Use `DomainKind::Core { efficiency_class, .. }`. Do not use `Processor::capacity`.** + +`capacity` is computed as `online.then(|| find the owning Core domain).flatten().unwrap_or(0)`, so +the value `0` means any of three different things: + +- the processor is offline; +- the processor is online but no `Core` domain names it, which the topology tolerates by design + since firmware coverage is not guaranteed; +- the processor is online, has a core, and its efficiency class genuinely **is** `0`. + +The third is not an edge case. It is **every processor on every non-hybrid machine**, so the +sentinel collides with the overwhelmingly common legitimate value. + +For this planner the collision is worse than for most consumers, because Windows orders efficiency +class with `0` as the *least* performant. On a hybrid part an unknown processor is therefore +indistinguishable from an efficiency core, and a policy that excludes efficiency cores would +silently drop a processor that might be a performance core -- while a policy that tiers them would +place it in the wrong tier. Both failures are invisible in a functional test. + +`Core { efficiency_class }` carries the firmware value with no sentinel, and absence is represented +by the processor being in no `Core` domain, which is a distinguishable state rather than a value. + +**This is the same defect the locality-model session exists to fix, in a third place.** The others: +`ProcessorPlace::cache_domain: Option`, where `None` conflates "no level partitions this +machine" with "this processor was not named at the level that does" (`SH-16.5`); and +`MachineDescription::cpu_model`, where the same conflation was noticed and solved with a side +boolean. Recorded here so the sweep that fixes the model does not stop at the two already known. + +### Partial core coverage is a real state, not a corruption + +A processor in no `Core` domain is a firmware gap, not a contradiction, and the topology crate +tolerates it deliberately. The planner must therefore decide what to do with a processor it cannot +group -- it is a candidate host whose SMT relationships and class are unknown, which is exactly the +"unanswered query" case that [CHECKLIST.md](CHECKLIST.md) EP-1.4 owns. It is named here so that +item is not written as though the case were hypothetical. + +### What this asks of the topology model + +Nothing new in shape; three things in substance. + +- Availability (parked, allocated) has to become expressible, since no policy can be correct + without it. +- Efficiency class has to have exactly one representation, and it must distinguish "class zero" + from "not known". +- Core membership has to admit that a processor may be in no core, without that being an error. From 901a05e6a3d614b4c625fe063f475c3e42d3f592 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 21:07:22 -0400 Subject: [PATCH 235/361] docs(execution-plan): state the proximity query, and correct its own item EP-1.2 asks how close two processors are, which selects the channel between their domains. Recorded as EP-D-2. It is the only relational query the planner makes and the one the whole model question turns on. The item said "ordered pair" and was wrong. Corrected in place rather than quietly, because the repository had already settled it and I had not looked: windows-placement-probe documents that its placement labels are "deliberately symmetric", that "the relationship between two processors genuinely is symmetric -- two processors either are SMT siblings or are not, share a cache domain or do not", and that "direction therefore lives where it is real, not in the label". The measured side puts it as "a hop is not symmetric even though the link is". Proximity is the link and is unordered; direction is the hop and belongs to EP-1.3's residency question. Putting direction here would invent an asymmetry the topology does not have. Stating the query precisely produced three requirements that a naive design drops. The answer needs the membership of the shared granularity, not just its identity. Choosing MPSC is not enough -- the planner must size the fan-in, which is how many other domains sit at the same proximity, and without membership it would ask the query O(n^2) times and rebuild the grouping itself. That is the re-derivation the seam exists to prevent. The answer must distinguish "tightest shared is X" from "at most X, and finer was not observed". If L3 was observed and L2 was not, reporting L3 tells a planner to choose a slower channel than the machine supports, and under the model's bar it cannot go and check. And the order being by observed inclusion rather than by firmware numbering means two granularities can be incomparable, so the answer is a set of minimal shared granularities. Almost always one, but not by construction, and the cost is named here rather than discovered later: every caller either handles a multi-element answer or documents that it takes the first. The alternative, forcing a linear order, silently discards a real boundary on a machine whose levels do not nest. Also recorded that the query should be total, which needs an explicit "the machine" top granularity -- two processors always share one address space, one scheduler and one memory system, and without a top every caller writes the same empty-case branch for cross-node pairs. What today's model answers: nothing. outermost_partitioning_cache reports one level for the whole machine and same_cache_domain reduces it to a boolean at that level. No query in windows-topology-sys takes two processors. So the absence of this query is the cause of SH-16.9's three inconsistent reconstructions rather than a separate problem. Completed item: EP-1.2: The proximity query, which is the crux Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-execution-plan/CHECKLIST.md | 29 +++-- crates/windows-execution-plan/DESIGN-NOTES.md | 101 ++++++++++++++++++ 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/windows-execution-plan/CHECKLIST.md index d8044695..f587799f 100644 --- a/crates/windows-execution-plan/CHECKLIST.md +++ b/crates/windows-execution-plan/CHECKLIST.md @@ -19,7 +19,7 @@ the model. | Milestone | State | What it is waiting on | |---|---|---| -| M1 the input contract | 1 of 5 done | nothing -- it is what unblocks the others | +| M1 the input contract | 2 of 5 done | nothing -- it is what unblocks the others | | M2+ the plan as a value | parked | M1, and the topology model landing | | M3+ the policies | parked | M2+ | | M-inf parked | ungated | not scheduled, deliberately | @@ -49,13 +49,26 @@ whether the topology can answer it today -- so the model is designed against a r performance core and a policy tiering them mis-tiers it. Neither fails a functional test. Filed against the owning crate as `SH-16.12`; use `DomainKind::Core { efficiency_class }` meanwhile. -- [ ] **EP-1.2** -- **The proximity query, which is the crux.** For an *ordered pair* of - processors, how close are they -- because that is what chooses SPSC versus MPSC versus a routed - hop, and it is asked once per pair rather than once per machine. **The current model cannot - answer it**: `outermost_partitioning_cache` reports one global level and `same_cache_domain` - reduces it to a boolean at that level, so a client reconstructs the rest and, per `SH-16.9`, - reconstructs it differently each time. State the query precisely enough that the session can - design against it. +- [x] **EP-1.2** -- **The proximity query, which is the crux.** For an ~~*ordered pair*~~ + **unordered pair** of processors, how close are they -- because that is what chooses SPSC versus + MPSC versus a routed hop, and it is asked once per pair rather than once per machine. **The + current model cannot answer it**: `outermost_partitioning_cache` reports one global level and + `same_cache_domain` reduces it to a boolean at that level, so a client reconstructs the rest and, + per `SH-16.9`, reconstructs it differently each time. State the query precisely enough that the + session can design against it. + **Done:** stated as [EP-D-2](DESIGN-NOTES.md#ep-d-2). + **This item said "ordered pair" and was wrong**, corrected in place rather than quietly. The + repository had already settled it: `windows-placement-probe` documents that its placement labels + are "deliberately symmetric", that "the *relationship* between two processors genuinely is + symmetric", and that "direction therefore lives where it is real, not in the label" -- with the + measured side putting it as "a hop is not symmetric even though the link is". Proximity is the + link and is unordered; direction is the hop, and belongs to EP-1.3's residency question. + Three requirements came out of stating it. The answer needs the **membership** of the shared + granularity, not just its identity, or the planner re-derives the grouping to size an MPSC + fan-in. It needs to distinguish "tightest shared is X" from "**at most** X, and finer was not + observed", since under the model's bar the planner cannot go and check. And the order being by + inclusion rather than by firmware numbering means two granularities can be **incomparable**, so + the answer is a set of minimal shared granularities -- almost always one, but not by construction. - [ ] **EP-1.3** -- **The residency query.** Which memory domain each processor belongs to, and -- for a pair spanning two of them -- what it costs to place a shared buffer on one side rather than diff --git a/crates/windows-execution-plan/DESIGN-NOTES.md b/crates/windows-execution-plan/DESIGN-NOTES.md index 9c4a6b01..b0beb8f3 100644 --- a/crates/windows-execution-plan/DESIGN-NOTES.md +++ b/crates/windows-execution-plan/DESIGN-NOTES.md @@ -12,6 +12,7 @@ a real caller instead of against a guess. | ID | Decision | |---|---| | EP-D-1 | **The shard-set query**: what the planner must know to choose which processors host a domain, and what today's model cannot tell it. | +| EP-D-2 | **The proximity query**: how close two processors are, which selects the channel between their domains. Takes an **unordered** pair; the model has no answer today. | ## EP-D-1: the shard-set query @@ -109,3 +110,103 @@ Nothing new in shape; three things in substance. - Efficiency class has to have exactly one representation, and it must distinguish "class zero" from "not known". - Core membership has to admit that a processor may be in no core, without that being an error. + +## EP-D-2: the proximity query + +*Recorded by [CHECKLIST.md](CHECKLIST.md) EP-1.2.* + +### What the planner is choosing + +For two domains, what connects them: a dedicated SPSC ring, a shared MPSC ring fanning several +producers into one consumer, or a routed hop through an intermediate domain. That choice is made +once per pair, and it is made from how close the two processors are. + +This is the query the whole model question turns on. Everything else the planner asks is either +per-processor (EP-D-1) or per-memory-domain (EP-1.3); this is the only one that is *relational*, +and it is the one today's model cannot answer. + +### It takes an unordered pair. The checklist item said ordered, and was wrong. + +`windows-placement-probe` already settled this and stated the reasoning, which is worth quoting +because it is easy to get backwards: + +> These names are deliberately symmetric, and that is not an oversight left over from before hops +> became directed. The *relationship* between two processors genuinely is symmetric -- two +> processors either are SMT siblings or are not, share a cache domain or do not -- so there is no +> honest `CrossNumaNodeForward` to name. Splitting the labels by direction would invent a +> distinction the topology does not have. +> +> The *workload* is what is asymmetric: the producer writes and the consumer reads, so swapping +> them swaps which side pays. Direction therefore lives where it is real, not in the label. + +And on the measured side: "a hop is not symmetric even though the link is." + +So the split is clean, and the planner needs both halves in different places: + +- **Proximity is the link.** Symmetric, unordered pair, answered here. +- **Residency is the hop.** Asymmetric -- which side hosts the ring buffer, which the probe + measures with a dedicated ring-placement column because it was found to matter. That belongs to + EP-1.3, not here. + +Putting direction in the proximity query would invent an asymmetry the topology does not have, and +would double the size of an answer that has no second half to fill. + +### What the answer must contain + +Not a boolean, and not a bare identifier. Three things: + +1. **The tightest granularity the two share.** Comparable against other pairs' answers, because the + policy's threshold ("SPSC within this, MPSC beyond it") is a comparison. The *identity* of the + granularity matters less than its position. + +2. **The membership of that granularity.** Selecting MPSC is not enough -- the planner must size the + fan-in, which is "how many other domains sit at this same proximity". Without membership the + planner would ask the proximity query O(n^2) times and reconstruct the grouping itself, which is + the re-derivation the seam exists to prevent. + +3. **Whether a finer granularity went unobserved.** This is the part that a naive design drops. If + L3 was observed and L2 was not, "tightest shared is L3" is *not* the answer -- the answer is "at + most L3, and finer was not looked at". A planner told the first would choose a slower channel + than the machine can support and never learn why. Under the model's bar -- usable without further + measurement -- it cannot go and check, so the distinction has to be in the answer. + +### The query should be total, which needs a top element + +Two processors in the same machine always share *something*: one address space, one scheduler, one +memory system, however far apart. If the granularity order has no top, the query returns "nothing +in common" for a cross-node pair and every caller writes the same empty-case branch. + +Making "the machine" an explicit top granularity is honest -- it is a real, if loose, locality tier +-- and makes the query total. A bottom ("this processor alone") is the same argument at the other +end and makes `proximity(a, a)` answerable rather than a special case, though a planner has no +reason to ask it. + +### A partial order means the answer may not be a single granularity + +If the order is by observed set inclusion rather than by firmware numbering, two granularities can +be **incomparable** -- neither refines the other. The tightest shared granularity is then not +unique, and the honest answer is the set of *minimal* shared granularities, which is almost always +exactly one. + +This is a cost, and it is worth naming rather than discovering later: every caller either handles a +multi-element answer or documents that it takes the first. But the alternative -- forcing a linear +order -- means silently discarding a real boundary on a machine whose levels do not nest, and this +repository has been bitten specifically by structure that was assumed rather than checked. + +### What today's model answers: nothing + +`Topology::outermost_partitioning_cache` reports **one level for the whole machine**, and +`Slice::same_cache_domain` reduces that to a boolean at that one level. Neither is pairwise. There +is no query anywhere in `windows-topology-sys` that takes two processors. + +So a planner today reconstructs proximity from the partition list -- which is exactly what +`SH-16.9` records three consumers already doing, in two mutually inconsistent ways. The absence of +this query is the cause of that defect, not a separate problem. + +### What this asks of the model + +- A granularity order derived from **observed set inclusion**, not firmware level numbers, so a + measured-only tier and a machine with no L3 both have positions. +- A pairwise query over it, returning minimal shared granularities plus their membership. +- Unobserved granularities represented, so an answer can be an upper bound and say so. +- A top element, so the query is total. From 2065baf205f712d42b054c705e402b8239e174d8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 21:16:52 -0400 Subject: [PATCH 236/361] docs(execution-plan): state the residency query, and correct two claims about it EP-1.3 asks where a domain's pool lives and which side of a cross-domain pair hosts their shared ring. Recorded as EP-D-3. This is where the direction EP-D-2 refused lands: proximity is the link and symmetric, residency is the hop and is not. The processor-to-node half is answered, with one asymmetry worth preserving. An unknown cache domain costs an optimisation; an unknown memory domain has no honest fallback, because the pool must be allocated somewhere and guessing means quietly allocating remote memory for the life of the process. windows-placement-probe already refuses on the second while tolerating the first, and that judgement was right. The cost half required correcting SH-16.11, which I wrote earlier today and which read as though someone had forgotten to populate a field. Two sharper problems replace it. Distances can never carry Measured provenance, by construction. Its only inputs are hand construction, defaulting to Synthetic, and deserialization, capped at Restored by downgraded_to; discover() hardcodes None. So populating it would not help -- a planner on a real machine still could not obtain trustworthy distance for that machine. And even populated it answers a different question. The matrix is SLIT-shaped: one symmetric, workload-independent scalar per pair. The residency decision is directional. D-9 in the topology crate already anticipated this and deferred it, naming an attributed edge list that "would absorb HMAT, asymmetry, and multi-hop CXL fabrics", with the trigger being that a scalar "demonstrably mismodels a machine somebody is tuning for" -- and this planner is that machine-tuner. D-8 keeps the JSON schema outside semver precisely to make that revision cheap. The trigger is approached but not met, and the difference is a measurement nobody here can take: both development hosts are single-node, so every directional run prints VACUOUS ON THIS MACHINE. Recorded so D-9 is reopened on evidence rather than on argument. No work is queued against D-9 itself, which deliberately schedules none. Also recorded that a measured locality number must carry what it measured. The probe's figures are nanoseconds for one ring-handoff pattern at one message size; promoting them as "the distance" would bake one workload into a shared model, and a consumer streaming large buffers would read them as authoritative and be wrong. That is the concrete reason per-relation provenance must be more than a trust label. Separately, swept a stale claim this uncovered. CHECKLIST-io-domains.md M-inf.4 said node_pairs is "keyed (low, high) so a link is measured once rather than once per direction". The code keys the directed pair and its comment says both directions are kept, with by_node_pair adding that each hop is measured once per ring placement -- four measurements per undirected edge, not one. The code had already corrected this twice, in notes that reach the same link-versus-workload distinction EP-D-2 arrives at independently; the checklist restatement was the one nobody swept. It sat in a parked item whose subject is inter-node distance, so a reader would have taken it as evidence against EP-D-3. Completed item: EP-1.3: The residency query Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 11 +- CHECKLIST-ship-topology-and-queues.md | 22 ++++ crates/windows-execution-plan/CHECKLIST.md | 22 +++- crates/windows-execution-plan/DESIGN-NOTES.md | 109 ++++++++++++++++++ 4 files changed, 160 insertions(+), 4 deletions(-) diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 6c7b9edb..6841467e 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -768,8 +768,15 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio imply the rest were like it -- and which hop you got would depend on enumeration order. Real multi-node hardware is not equidistant: two nodes on one package are far closer than two across a socket link. `node_pairs` therefore selects one representative processor pair per *distinct* node - pair, keyed `(low, high)` so a link is measured once rather than once per direction, and `measure` - reports each hop separately in `by_node_pair`. The probe prints the resulting table, names the + pair, and `measure` reports each hop separately in `by_node_pair`. + **Corrected 2026-09-02:** this said the selection was "keyed `(low, high)` so a link is measured + once rather than once per direction", which the code has not done for some time -- it keys + `(producer.numa_node, consumer.numa_node)` and its comment states that "both *directions* are + kept", with `by_node_pair` adding that "each hop is measured once per ring placement, so there are + two". Four measurements per undirected edge, not one. Found while stating + [EP-D-3](crates/windows-execution-plan/DESIGN-NOTES.md#ep-d-3), whose whole subject is that + residency is directional, so a parked item asserting the opposite would have been read as evidence + against it. The probe prints the resulting table, names the cheapest and dearest hop, and says outright whether the spread is small enough for the single `cross NUMA node` row to be a fair summary. **These are measured hops, not a firmware distance matrix.** Windows exposes no NUMA distance table diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 4b093495..de5c41ab 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -884,6 +884,28 @@ predicted about a 222-commit branch. **without further measurement**, a consumer shaping memory allocation must today either run the probe at decision time -- forbidden -- or guess. Gated on SH-16.8, and on the open question of which component owns the measurement phase. + **Corrected while stating [EP-D-3](crates/windows-execution-plan/DESIGN-NOTES.md#ep-d-3): the + wording above reads as an oversight, and it is not one.** The field is documented as being for a + fed-in description, because Windows exposes no user-mode SLIT reader -- accurate, and deliberate. + Two sharper problems replace the one this item claimed. + **First, `distances` can never carry `Measured` provenance, by construction.** Its only inputs are + hand construction (defaulting to `Synthetic`) and deserialization (capped at `Restored` by + `downgraded_to`), and `discover()` hardcodes `None`. So populating it would not help: a planner on + a real machine still could not obtain trustworthy distance *for that machine*. + **Second, even populated it answers the wrong question.** The matrix is SLIT-shaped -- one + symmetric, workload-independent scalar per pair -- while the residency decision is directional, + since the producer writes and the consumer reads. `D-9` in + [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md) already + anticipated exactly this and deferred it, naming an attributed edge list that "would absorb HMAT, + **asymmetry**, and multi-hop CXL fabrics", with the trigger being that "scalar distance + demonstrably mismodels a machine somebody is tuning for". `D-8` keeps the JSON schema outside + semver specifically to make that revision cheap. + **The trigger is approached but not met, and the difference is a measurement nobody here can + take.** The probe treats direction as real -- four numbers per undirected edge, and its code says + "a hop is not symmetric even though the link is" -- but no run has *shown* those numbers differ, + because both development hosts report a single NUMA node and every such run prints "VACUOUS ON + THIS MACHINE". Take that measurement on multi-node hardware before reopening D-9 on asymmetry + grounds, not after. - [ ] **SH-16.12** -- **`Processor::capacity` uses `0` as both a legitimate efficiency class and a sentinel for "not known", and the two collide on the common case.** It is computed diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/windows-execution-plan/CHECKLIST.md index f587799f..bb5f153e 100644 --- a/crates/windows-execution-plan/CHECKLIST.md +++ b/crates/windows-execution-plan/CHECKLIST.md @@ -19,7 +19,7 @@ the model. | Milestone | State | What it is waiting on | |---|---|---| -| M1 the input contract | 2 of 5 done | nothing -- it is what unblocks the others | +| M1 the input contract | 3 of 5 done | nothing -- it is what unblocks the others | | M2+ the plan as a value | parked | M1, and the topology model landing | | M3+ the policies | parked | M2+ | | M-inf parked | ungated | not scheduled, deliberately | @@ -70,12 +70,30 @@ whether the topology can answer it today -- so the model is designed against a r inclusion rather than by firmware numbering means two granularities can be **incomparable**, so the answer is a set of minimal shared granularities -- almost always one, but not by construction. -- [ ] **EP-1.3** -- **The residency query.** Which memory domain each processor belongs to, and -- +- [x] **EP-1.3** -- **The residency query.** Which memory domain each processor belongs to, and -- for a pair spanning two of them -- what it costs to place a shared buffer on one side rather than the other. **Gap already identified:** `Topology::distances` exists, is never populated, and Win32 cannot populate it; the measurement exists in `windows-placement-probe` and reaches nothing. Tracked as `SH-16.11`. The probe measures this per node pair with a dedicated ring-placement column precisely because it was found to matter. + **Done:** stated as [EP-D-3](DESIGN-NOTES.md#ep-d-3). This is where the direction EP-1.2 refused + lands -- proximity is the link and symmetric, residency is the hop and is not. + The processor-to-node half is answered, with one asymmetry worth preserving: an unknown *cache* + domain costs an optimisation, but an unknown *memory* domain has no honest fallback, since the + pool must be allocated somewhere and guessing means quietly allocating remote memory for the life + of the process. `windows-placement-probe` already refuses on the second while tolerating the + first, and that judgement was correct. + **The cost half needs SH-16.11 restated, and it was.** That item read as though someone had + forgotten to populate a field. Two sharper problems replace it: `distances` can never carry + `Measured` provenance **by construction** -- its only inputs are a literal (`Synthetic`) and a file + (capped at `Restored`) -- so populating it would not help; and even populated it is SLIT-shaped, + one symmetric workload-independent scalar, while the question is directional. `D-9` in the + topology crate already deferred the attributed edge list that would answer it, naming *asymmetry* + among what it would absorb, with the trigger being that a scalar "demonstrably mismodels a machine + somebody is tuning for" -- and this planner is that machine-tuner. + **The trigger is approached, not met**, and the gap is a measurement nobody here can take: both + development hosts are single-node, so every directional run prints "VACUOUS ON THIS MACHINE". + Recorded so D-9 is reopened on evidence rather than on argument. - [ ] **EP-1.4** -- **What the planner does with an unanswered query**, given the model's bar is that it answers without further measurement. A fact that was not observed cannot be acquired at diff --git a/crates/windows-execution-plan/DESIGN-NOTES.md b/crates/windows-execution-plan/DESIGN-NOTES.md index b0beb8f3..419754ca 100644 --- a/crates/windows-execution-plan/DESIGN-NOTES.md +++ b/crates/windows-execution-plan/DESIGN-NOTES.md @@ -13,6 +13,7 @@ a real caller instead of against a guess. |---|---| | EP-D-1 | **The shard-set query**: what the planner must know to choose which processors host a domain, and what today's model cannot tell it. | | EP-D-2 | **The proximity query**: how close two processors are, which selects the channel between their domains. Takes an **unordered** pair; the model has no answer today. | +| EP-D-3 | **The residency query**: where a domain's pool lives, and which side of a cross-domain pair should host a shared ring. **Ordered**, and the half the model cannot answer is structurally unanswerable rather than merely unpopulated. | ## EP-D-1: the shard-set query @@ -210,3 +211,111 @@ this query is the cause of that defect, not a separate problem. - A pairwise query over it, returning minimal shared granularities plus their membership. - Unobserved granularities represented, so an answer can be an upper bound and say so. - A top element, so the query is total. + +## EP-D-3: the residency query + +*Recorded by [CHECKLIST.md](CHECKLIST.md) EP-1.3.* + +### What the planner is choosing + +Two things, and they are different questions that happen to share a subject: + +- **Where each domain's own pool lives.** A domain allocates node-locally to the processor it is + pinned to. Per-processor, unordered, cheap. +- **Which side of a cross-domain pair hosts their shared ring.** Ordered, because the producer + writes and the consumer reads, so the placement decides which of them pays for the crossing. + +This is where the direction that [EP-D-2](#ep-d-2) deliberately refused lands. Proximity is the +link and is symmetric; residency is the hop and is not. + +### The first half is answered, with one asymmetry worth keeping + +`Topology::memory_domains()` yields the memory domains with their processor sets, so +processor-to-domain is a lookup. + +Partial coverage exists here as it does for caches -- a processor may be named by no memory domain +-- but **the right response is different, and `windows-placement-probe` already got this right**. +Its `places_from_topology` refuses on a missing NUMA node while tolerating a missing cache domain, +and the asymmetry is principled: an unknown cache domain costs an optimisation, whereas an unknown +memory domain has no honest fallback at all, since the pool has to be allocated *somewhere* and +guessing means quietly allocating remote memory for the life of the process. + +So the planner inherits that: an unplaced processor may still host a domain, but not with a +node-local pool, and the difference has to be visible in the plan rather than assumed away. + +### The second half is not merely unpopulated -- it cannot be measured, by construction + +`Topology::distances` exists, and it is easy to read its permanent `None` as an oversight. It is +not. The field is documented as being for a fed-in description, because "Windows exposes no +user-mode SLIT reader", and that is accurate. + +The sharper problem is what follows from it. `distances` has exactly two input paths: hand +construction, which defaults to `Provenance::Synthetic`, and deserialization, which +`downgraded_to(Provenance::Restored)` caps. `Topology::discover` hardcodes `None`. So **no path +exists by which `distances` can ever carry `Measured` provenance** -- not because nobody wrote the +code, but because the only sources are a literal and a file, and a file cannot establish that it +describes the machine you are on. + +Under the model's bar -- usable without further measurement -- a planner on a real machine +therefore cannot obtain trustworthy distance for that machine today, and no amount of populating +the existing field would change that. + +### A scalar distance cannot express what this query asks + +Even a populated `Distances` would not answer it. The matrix is SLIT-shaped: one scalar per pair, +with `matrix[i][i]` conventionally `10`. That is a *symmetric, workload-independent* abstraction, +and the residency question is neither. It asks which of two directions is cheaper for a specific +access pattern -- a ring one side writes and the other reads. + +`windows-topology-sys` D-9 already anticipated this precisely, and excluded it deliberately: + +> **HMAT-style attributed relations.** ACPI's Heterogeneous Memory Attribute Table supersedes SLIT, +> giving per-initiator/per-target read and write latency and bandwidth -- four numbers where SLIT +> gives one scalar [...] A general edge list (`{ from, to, read_latency_ns, read_bandwidth_mbps, +> ... }`) would absorb HMAT, **asymmetry**, and multi-hop CXL fabrics; the scalar distance matrix +> this schema keeps will [be revisited when] scalar distance demonstrably mismodels a machine +> somebody is tuning for. + +**This planner is the machine-tuner that deferral names, and asymmetry is exactly the property it +needs.** D-8 makes the revision cheap by keeping the JSON schema outside the semver contract, which +that decision says is "precisely what makes D-9's deferrals safe rather than merely convenient". + +### The trigger is approached, not met, and saying which matters + +D-9's condition is *demonstrable* mismodelling, and honesty requires separating what is shown from +what is expected. + +What is shown: `windows-placement-probe` measures per-hop cost as four numbers per undirected edge +-- two directions times two ring placements -- and its code states that "a hop is not symmetric even +though the link is". The apparatus treats direction as real. + +What is **not** shown: any measurement demonstrating that the four numbers differ. Both development +hosts report a single NUMA node, so every such run is vacuous -- the spike says so itself, printing +"VACUOUS ON THIS MACHINE" and "Apparatus works; question unanswered". So the claim "scalar distance +mismodels this machine" is currently unproven on hardware anyone here has. + +The requirement is real either way, because the planner must choose a side and today has nothing to +choose with. But the *specific* claim that a scalar is insufficient needs a multi-node measurement, +and that measurement should be taken before D-9 is reopened on those grounds rather than after. + +### A measured locality fact must carry what it measured + +The probe's numbers are nanoseconds for one ring-handoff pattern at one message size. Promoting +them into the topology as "the distance" would bake one workload into a model other consumers share +-- and a different consumer, streaming large buffers rather than handing off small messages, would +read them as authoritative and be wrong. + +So a measured relation has to name its measurement, not just its value. This is the concrete reason +per-relation provenance has to be more than a trust label: "measured" is not a sufficient +description of a number whose meaning depends on how it was obtained. + +### What this asks of the model + +- Processor-to-memory-domain, with the unplaced case distinguishable rather than defaulted, because + here it has no honest default. +- A **directed** cost between memory domains, which SLIT's scalar cannot express and which D-9 + already sketched as an attributed edge list. +- Provenance rich enough to say *what* a measured number measured, so one consumer's workload does + not become every consumer's constant. +- And, before reopening D-9 on the asymmetry argument: a multi-node measurement showing the + directions actually differ. From 417f344b6a1754ef0e3ca18d134cbb974b7ea0ad Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 21:29:38 -0400 Subject: [PATCH 237/361] docs: ungate and split SH-16.10, whose gating premise was wrong It said "gated on SH-16.8, since what shape it lands in depends on the model". That conflated acquiring the CPU-set data with reconciling it against what GetLogicalProcessorInformationEx already reports. Acquisition does not depend on the model. CPU Sets is a cheap OS read, in the same class as the relationship walk this crate already does, and nothing about reading it presumes a granularity representation. Only reconciliation depends on the model, and that is now SH-16.13. Field list verified against windows-sys 0.61.2 rather than recalled. All five APIs are present and Win32_System_SystemInformation is already enabled, so there is no manifest change and no blocker. SH-16.13 records why reconciliation is a decision rather than a cleanup: CoreIndex, NumaNodeIndex and EfficiencyClass duplicate facts the existing walk reports, from a different kernel path, so this is a second independent observer of the same relations and the two can disagree. That makes the session's "can one relation hold several observations?" question concrete -- until now it rested on the file-handle spike's reasoning about a different subject. Per the duplicate-then-decide rule, SH-16.10 lands the data as its own observation alongside the existing domains without merging, and SH-16.13 is the merge-or-delete decision made when the model settles rather than pre-empted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 35 ++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index de5c41ab..a3e08389 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -25,7 +25,7 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | -| M16 tenth review round | 6 of 12 open | **gates the merge**; 6 fixed, 6 wait on the design session | +| M16 tenth review round | 7 of 13 open | **gates the merge**; SH-16.10 is ungated and next | | M-inf parked | ungated | not scheduled, deliberately | **The critical path is M16's locality-model work -> SH-3.1.1 -> SH-3.4 -> M4.** M14 and M15 do not @@ -867,11 +867,40 @@ predicted about a 222-commit branch. `SchedulingClass`, `AllocationTag`, `EfficiencyClass`, and per-processor `Parked` / `Allocated` / `RealTime` state. Raised by the engineer's question of whether we expose everything a real system would reveal - through the Win32 API set. Today the answer is **no**. Verify the field list against the SDK - before relying on it. Gated on SH-16.8, since what shape it lands in depends on the model. + through the Win32 API set. Today the answer is **no**. Note `Parked` and `Allocated` bear directly on **thread counts and assignments**, one of the three decisions the model exists to serve, so this is a gap already costing a named use rather than speculative completeness. + **Ungated, and split, because the gating premise was wrong.** This said "gated on SH-16.8, since + what shape it lands in depends on the model". That conflated two things: *acquiring* the data and + *reconciling* it with what `GetLogicalProcessorInformationEx` already reports. Acquisition does + not depend on the model at all -- CPU Sets is a **cheap OS read**, in the same class as the walk + this crate already does, and nothing about reading it presumes a granularity representation. Only + reconciliation depends on the model, and that is now SH-16.13. + Field list **verified against `windows-sys 0.61.2`** rather than recalled: `Id`, `Group`, + `LogicalProcessorIndex`, `CoreIndex`, `LastLevelCacheIndex`, `NumaNodeIndex`, `EfficiencyClass`, + a union carrying `AllFlags` (`Parked` / `Allocated` / `AllocatedToTargetProcess` / `RealTime`), a + union carrying `SchedulingClass`, and `AllocationTag`. All five APIs are present + (`GetSystemCpuSetInformation`, `GetThreadSelectedCpuSets`, `SetThreadSelectedCpuSets`, + `SetThreadSelectedCpuSetMasks`, `SetProcessDefaultCpuSets`) and `Win32_System_SystemInformation` + is already an enabled feature, so there is no manifest change and no blocker. + +- [ ] **SH-16.13** -- **Reconcile the CPU-set observation with the relationship walk.** `CoreIndex`, + `NumaNodeIndex` and `EfficiencyClass` **duplicate** facts `GetLogicalProcessorInformationEx` + already reports, from a different kernel path -- so this is not redundancy to remove, it is a + **second independent observer of the same relations**, and the two can disagree under a hypervisor + or where one path is stale. + This is the concrete instance of the design session's "can one relation hold several + observations?" question, which until now rested on the file-handle spike's agree/disagree + reasoning about a different subject. It is no longer speculative: two Win32 sources describe the + same processor's NUMA node and efficiency class today. + Per [PLATFORM INTEGRITY](.github/copilot-instructions.md)'s duplicate-then-decide rule, SH-16.10 + lands the CPU-set data as its **own** observation alongside the existing domains, without merging. + This item is the merge-or-delete decision, made when the model settles rather than pre-empted. + Gated on SH-16.8. + Note it also bears on SH-16.12: CPU Sets carries `EfficiencyClass` as a plain `u8` with **no + sentinel**, so it is a cleaner source for the field whose `capacity` encoding collides with + "unknown". - [ ] **SH-16.11** -- **`Topology::distances` is a field for a fact Win32 cannot supply, it is never populated, and the measurement that would fill it already exists elsewhere.** `discover()` From 473cc84bc41e4accdff4449ff105fe118a237b19 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 21:37:54 -0400 Subject: [PATCH 238/361] feat(topology)!: read CPU sets, as a second observation rather than a merge Windows describes processors through two APIs and this crate consumed one. GetSystemCpuSetInformation carries facts the relationship walk has no equivalent for -- whether a processor is parked, whether it is allocated to this process at all, the scheduler's own last-level-cache grouping, a scheduling class and an allocation tag. The availability half is a correctness matter rather than an optimisation: AllocatedToTargetProcess plus SetProcessDefaultCpuSets is how container and QoS isolation restrict a process, so a planner that ignores it places work on processors the process may not use. That is a wrong plan, not a slow one. Ungated from the model redesign because the gating premise was wrong, as the previous commit records: this is a cheap OS read in the same class as the walk the crate already does, and nothing about reading it presumes a granularity representation. Carried on Topology as Option>, where None means not observed -- which a hand-built or deserialized topology genuinely is. That is the honest use of Option: one absence, not two collapsed together. serde(default) so descriptions written before the field still load. Nothing is reconciled. CoreIndex, NumaNodeIndex and EfficiencyClass duplicate facts the walk already reports, from a different kernel path, so this is a second independent observer and the two can disagree. Merging here would pick a winner silently and destroy the disagreement, which is the only thing a second observer is for. SH-16.13 owns that decision. The live data justified the caution rather than merely permitting it. On the x64 host CPU Sets reports one distinct LastLevelCacheIndex across all sixteen processors, while outermost_partitioning_cache reports eight partitions at L2. Both are right: Windows names the last level, the derivation names the outermost level that divides. A merge treating LastLevelCacheIndex as "the cache domain" would have collapsed eight shard groups into one here. Kept as a test asserting the relationship -- Windows's grouping is never finer than the derived one -- rather than this host's numbers. It also gives a second source for the matrix-hole argument in the locality-model session: this is the host recorded as unable to express "same cache, same class", and CPU Sets independently says all sixteen share an LLC, so that row is real rather than inferred. The walk follows the relationship walk's buffer discipline for the same reasons: size first, advance by each record's own Size rather than by the struct's, read every field unaligned, and refuse a zero or overrunning Size rather than trusting the buffer. Those two refusals are asserted, not assumed, because trusting them is how a corrupt buffer becomes a hang instead of a stop. One limitation recorded rather than glossed: the four flag bit positions are taken from the SDK's documented bitfield order and are not confirmed against Windows. Every processor on this host reads all four false, consistent with a process that requested no allocation but confirming no bit position. The test checks the decode is self-consistent, not that it matches the OS. Breaking: Topology gains a public field, so struct literals must name it. Eight sites updated across two crates. Completed item: SH-16.10: GetSystemCpuSetInformation is not consumed anywhere, so a whole Win32 topology model is unexposed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 27 +- .../src/fingerprint/tests.rs | 3 + crates/windows-topology-sys/src/cpu_set.rs | 251 ++++++++++++++++++ .../windows-topology-sys/src/cpu_set/tests.rs | 233 ++++++++++++++++ crates/windows-topology-sys/src/lib.rs | 5 +- crates/windows-topology-sys/src/topology.rs | 33 ++- .../src/topology/tests.rs | 4 + 7 files changed, 552 insertions(+), 4 deletions(-) create mode 100644 crates/windows-topology-sys/src/cpu_set.rs create mode 100644 crates/windows-topology-sys/src/cpu_set/tests.rs diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index a3e08389..ac03f643 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -25,7 +25,7 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | -| M16 tenth review round | 7 of 13 open | **gates the merge**; SH-16.10 is ungated and next | +| M16 tenth review round | 6 of 13 open | **gates the merge**; CPU Sets landed, 6 wait on the session | | M-inf parked | ungated | not scheduled, deliberately | **The critical path is M16's locality-model work -> SH-3.1.1 -> SH-3.4 -> M4.** M14 and M15 do not @@ -859,7 +859,7 @@ predicted about a 222-commit branch. this by pointing both consumers at today's method would have to be redone once SH-16.8 lands, so either fix it now and accept the rework, or sequence it after the design session. -- [ ] **SH-16.10** -- **`GetSystemCpuSetInformation` is not consumed anywhere, so a whole Win32 +- [x] **SH-16.10** -- **`GetSystemCpuSetInformation` is not consumed anywhere, so a whole Win32 topology model is unexposed.** The crate consumes all seven `GetLogicalProcessorInformationEx` relations, but `SYSTEM_CPU_SET_INFORMATION` is a *second, parallel* model carrying at least `LastLevelCacheIndex` -- Windows's own LLC grouping, which is a **different answer** from @@ -884,6 +884,29 @@ predicted about a 222-commit branch. (`GetSystemCpuSetInformation`, `GetThreadSelectedCpuSets`, `SetThreadSelectedCpuSets`, `SetThreadSelectedCpuSetMasks`, `SetProcessDefaultCpuSets`) and `Win32_System_SystemInformation` is already an enabled feature, so there is no manifest change and no blocker. + **Done.** `src/cpu_set.rs` walks the records with the same buffer discipline the relationship walk + uses -- size first, advance by each record's own `Size`, read every field unaligned -- and + `Topology::discover` now populates `Topology::cpu_sets`. Carried as + `Option>` where `None` means **not observed**, which a hand-built or deserialized + topology genuinely is; that is the honest use of `Option`, one absence rather than two collapsed + together. `#[serde(default)]` so descriptions written before the field still load. + **Nothing is reconciled**, per duplicate-then-decide. SH-16.13 owns that. + **The live dump justified the caution.** On the x64 host, CPU Sets reports **one** distinct + `LastLevelCacheIndex` across all sixteen processors, while `outermost_partitioning_cache` reports + **eight** partitions at L2. Both are right -- Windows names the *last* level, the derivation names + the outermost level that *divides* -- so a merge treating `LastLevelCacheIndex` as "the cache + domain" would have collapsed eight shard groups into one on this machine. Kept as a test asserting + the *relationship* (Windows's grouping is never finer) rather than the host's numbers. + It also confirms the matrix-hole argument from + [DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md): + this is the host recorded as unable to express `same cache, same class`, and a second source now + says all sixteen share an LLC, so that row is real rather than inferred. + **One thing is verified only against the SDK's documented bitfield order, not against Windows:** + the four flag bit positions. Every processor on this host reads `parked=false, allocated=false, + allocated_to_target_process=false, real_time=false`, which is consistent with a process that has + requested no CPU-set allocation but confirms no bit position. `each_flag_is_read_from_its_own_bit` + checks the decode is self-consistent, not that it matches the OS. Confirm against a parked + processor or an explicit `SetProcessDefaultCpuSets` before relying on the flags. - [ ] **SH-16.13** -- **Reconcile the CPU-set observation with the relationship walk.** `CoreIndex`, `NumaNodeIndex` and `EfficiencyClass` **duplicate** facts `GetLogicalProcessorInformationEx` diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index 51a95d66..76240f07 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -391,6 +391,7 @@ mod from_topology { processors, domains, distances: None, + cpu_sets: None, ..Default::default() } } @@ -658,6 +659,7 @@ mod multi_group_conversion { processors, domains, distances: None, + cpu_sets: None, ..Default::default() } } @@ -735,6 +737,7 @@ mod multi_group_conversion { processors: ProcessorSet::from_group_mask(0, mask), }], distances: None, + cpu_sets: None, ..Default::default() } } diff --git a/crates/windows-topology-sys/src/cpu_set.rs b/crates/windows-topology-sys/src/cpu_set.rs new file mode 100644 index 00000000..e1b8fb25 --- /dev/null +++ b/crates/windows-topology-sys/src/cpu_set.rs @@ -0,0 +1,251 @@ +// Copyright (c) 2026 Mike Grier +//! The safe `GetSystemCpuSetInformation` walk. +//! +//! A second, independent view of the same processors [`crate::walk`] describes, +//! read from a different kernel path. Windows exposes two processor-topology +//! APIs and they are not the same API twice: this one reports **availability** +//! (parked, and whether the processor is allocated to this process at all), +//! Windows's **own** last-level-cache grouping, and a scheduling class and +//! allocation tag that the relationship walk has no equivalent for. +//! +//! The buffer discipline matches `GetLogicalProcessorInformationEx`'s, and for +//! the same reasons: +//! +//! - Size first with a null buffer, which fails with `ERROR_INSUFFICIENT_BUFFER` +//! and reports the byte count. +//! - Records are **variable length**: advance by each record's own `Size` field, +//! never by `size_of::()`. The struct's declared +//! size describes today's `CpuSetInformation` record, and a future type may be +//! longer. +//! - Read every field with `read_unaligned`, since a record's start is only as +//! aligned as the running `Size` sum makes it. +//! +//! # What this deliberately does not do +//! +//! It does not reconcile anything. `CoreIndex`, `NumaNodeIndex` and +//! `EfficiencyClass` duplicate facts the relationship walk already reports, and +//! the two paths can disagree -- under a hypervisor, or where one is stale. +//! Merging them here would silently pick a winner and destroy the disagreement, +//! which is the one thing a second observer is *for*. The records come back as +//! what they are; deciding what to do when they differ is tracked separately. + +use std::io; + +use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; +use windows_sys::Win32::System::SystemInformation::{ + CpuSetInformation, GetSystemCpuSetInformation, SYSTEM_CPU_SET_INFORMATION, +}; + +/// One processor as the CPU-set API describes it. +/// +/// Field-for-field what the record carries, with the two bitfield unions +/// decoded. Nothing is interpreted and nothing is cross-checked against the +/// relationship walk -- see this module's note on why reconciling here would +/// destroy the only thing a second observer is for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct CpuSet { + /// The CPU set's id, which is what `SetThreadSelectedCpuSets` takes. Not a + /// processor number, and not interchangeable with one. + pub id: u32, + /// The processor group. + pub group: u16, + /// The processor's number within its group. + pub logical_processor_index: u8, + /// Windows's index for the owning core. + pub core_index: u8, + /// **Windows's own** last-level-cache grouping: processors sharing a value + /// share an LLC in the scheduler's view. Deliberately kept even though the + /// relationship walk also reports caches, because this is the OS's opinion + /// rather than a partition derived from firmware records, and the two are + /// answers to different questions. + pub last_level_cache_index: u8, + /// Windows's index for the owning NUMA node. + pub numa_node_index: u8, + /// The scheduler's efficiency class. Carried as reported, with no sentinel: + /// a processor absent from this enumeration has no record at all rather + /// than a record holding a stand-in value. Contrast + /// [`Processor::capacity`](crate::Processor::capacity), which uses `0` for + /// both "class zero" and "not known". + pub efficiency_class: u8, + /// The processor is parked, so the scheduler is currently avoiding it. + pub parked: bool, + /// The processor is allocated. + pub allocated: bool, + /// The processor is allocated **to this process**. A planner that ignores + /// this places work on processors the process may not use, which is a wrong + /// plan rather than a slow one. + pub allocated_to_target_process: bool, + /// The processor is marked real-time. + pub real_time: bool, + /// The scheduling class, which shares its union with a reserved `u32`, so + /// confirm its meaning against current SDK documentation before relying on + /// it rather than treating this field as self-describing. + pub scheduling_class: u8, + /// The allocation tag. + pub allocation_tag: u64, +} + +/// Bit positions within `AllFlags`, named rather than written inline. +/// +/// Changing any value is a breaking change: these mirror the SDK's bitfield +/// order, which is part of the ABI rather than this crate's choice. +mod flags { + pub(super) const PARKED: u8 = 1 << 0; + pub(super) const ALLOCATED: u8 = 1 << 1; + pub(super) const ALLOCATED_TO_TARGET_PROCESS: u8 = 1 << 2; + pub(super) const REAL_TIME: u8 = 1 << 3; +} + +/// Enumerate the CPU sets the current process can see. +/// +/// Passing a null process handle asks about the calling process, so +/// `allocated_to_target_process` answers "may *we* use it" rather than "does it +/// exist". +/// +/// # Errors +/// +/// Returns any error from `GetSystemCpuSetInformation` other than the expected +/// sizing failure. +pub(crate) fn enumerate() -> io::Result> { + let mut length: u32 = 0; + // SAFETY: a null buffer with a zero length and a valid out-pointer, which is + // the documented sizing call. A null process handle names this process. + let probe = unsafe { + GetSystemCpuSetInformation( + std::ptr::null_mut(), + 0, + &raw mut length, + std::ptr::null_mut(), + 0, + ) + }; + if probe != 0 { + // Succeeding on the sizing call would mean zero bytes were needed, so + // there is nothing to report. + return Ok(Vec::new()); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { + return Err(error); + } + if length == 0 { + return Ok(Vec::new()); + } + + // `u64`-backed storage for the same reason the relationship walk uses it: + // it guarantees 8-byte alignment for every record header regardless of what + // a `Vec` allocation would have happened to provide. `AllocationTag` is + // 8-byte-sized, so this is not merely tidiness. + let mut storage = vec![0_u64; (length as usize).div_ceil(8)]; + let buffer = storage.as_mut_ptr().cast::(); + let mut actual_length = length; + // SAFETY: `buffer` points to `storage`, whose byte length is at least + // `length` (the size the probe just reported) and is 8-byte aligned; + // `actual_length` is a valid in/out length pointer. + let ok = unsafe { + GetSystemCpuSetInformation( + buffer.cast(), + length, + &raw mut actual_length, + std::ptr::null_mut(), + 0, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + + // SAFETY: `buffer` holds `actual_length` bytes written by the call above: + // consecutive `SYSTEM_CPU_SET_INFORMATION` records whose `Size` fields sum + // to `actual_length`, per the API's contract. + Ok(unsafe { decode(buffer.cast_const(), actual_length) }) +} + +const SIZE_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Size); +const TYPE_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Type); +const UNION_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Anonymous); + +/// Read a `T` from `base + offset` without assuming alignment. +/// +/// # Safety +/// +/// `base + offset` must address at least `size_of::()` initialized bytes. +unsafe fn read_at(base: *const u8, offset: usize) -> T { + // SAFETY: forwarded from the caller. + unsafe { base.add(offset).cast::().read_unaligned() } +} + +/// Walk `length` bytes of consecutive records. +/// +/// # Safety +/// +/// `base` must address `length` initialized bytes laid out as consecutive +/// `SYSTEM_CPU_SET_INFORMATION` records. +unsafe fn decode(base: *const u8, length: u32) -> Vec { + // Offsets within the `CpuSet` arm of the record's union, computed from the + // generated types so a binding change moves them rather than silently + // shifting what is read. + use windows_sys::Win32::System::SystemInformation::{ + SYSTEM_CPU_SET_INFORMATION_0, SYSTEM_CPU_SET_INFORMATION_0_0, + }; + const CPUSET_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION_0, CpuSet); + macro_rules! field { + ($name:ident) => { + UNION_OFFSET + + CPUSET_OFFSET + + core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION_0_0, $name) + }; + } + + let mut records = Vec::new(); + let mut offset = 0_usize; + let length = length as usize; + + while offset + SIZE_OFFSET + size_of::() <= length { + let record = unsafe { base.add(offset) }; + // SAFETY: the bound above proved `Size` itself is in range. + let size = unsafe { read_at::(record, SIZE_OFFSET) } as usize; + // A zero or oversized `Size` would loop forever or read past the end. + // Windows does not produce either, and trusting it anyway is how a + // hostile or corrupt buffer becomes a hang instead of a stop. + if size == 0 || offset + size > length { + break; + } + + // SAFETY: `size` bytes from `record` are in range, and this record is at + // least a full `SYSTEM_CPU_SET_INFORMATION`, so every field below is + // within it. + let kind = unsafe { read_at::(record, TYPE_OFFSET) }; + if kind == CpuSetInformation && size >= size_of::() { + // SAFETY: as above; each offset is computed from the generated type. + let all_flags = unsafe { read_at::(record, field!(Anonymous1)) }; + records.push(CpuSet { + id: unsafe { read_at::(record, field!(Id)) }, + group: unsafe { read_at::(record, field!(Group)) }, + logical_processor_index: unsafe { + read_at::(record, field!(LogicalProcessorIndex)) + }, + core_index: unsafe { read_at::(record, field!(CoreIndex)) }, + last_level_cache_index: unsafe { + read_at::(record, field!(LastLevelCacheIndex)) + }, + numa_node_index: unsafe { read_at::(record, field!(NumaNodeIndex)) }, + efficiency_class: unsafe { read_at::(record, field!(EfficiencyClass)) }, + parked: all_flags & flags::PARKED != 0, + allocated: all_flags & flags::ALLOCATED != 0, + allocated_to_target_process: all_flags & flags::ALLOCATED_TO_TARGET_PROCESS != 0, + real_time: all_flags & flags::REAL_TIME != 0, + scheduling_class: unsafe { read_at::(record, field!(Anonymous2)) }, + allocation_tag: unsafe { read_at::(record, field!(AllocationTag)) }, + }); + } + + offset += size; + } + + records +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-topology-sys/src/cpu_set/tests.rs b/crates/windows-topology-sys/src/cpu_set/tests.rs new file mode 100644 index 00000000..66b6c7a1 --- /dev/null +++ b/crates/windows-topology-sys/src/cpu_set/tests.rs @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Mike Grier +use super::*; + +/// Build a byte buffer of `CpuSetInformation` records the way Windows lays them +/// out, so `decode` is exercised against real record geometry rather than +/// against a `Vec` the test built directly. +fn encode(records: &[SYSTEM_CPU_SET_INFORMATION]) -> Vec { + let size = size_of::(); + let mut storage = vec![0_u64; size_of_val(records).div_ceil(8).max(1)]; + let base = storage.as_mut_ptr().cast::(); + for (index, record) in records.iter().enumerate() { + // SAFETY: `storage` was sized to hold every record end to end. + unsafe { + base.add(index * size) + .cast::() + .write_unaligned(*record); + } + } + storage +} + +fn record( + id: u32, + group: u16, + number: u8, + llc: u8, + class: u8, + flags: u8, +) -> SYSTEM_CPU_SET_INFORMATION { + let mut raw = SYSTEM_CPU_SET_INFORMATION { + Size: size_of::() as u32, + Type: CpuSetInformation, + ..Default::default() + }; + // Writing a union field is safe; only reading one is not. The storage is + // already zeroed, and only the `CpuSet` arm is ever read back. + raw.Anonymous.CpuSet.Id = id; + raw.Anonymous.CpuSet.Group = group; + raw.Anonymous.CpuSet.LogicalProcessorIndex = number; + raw.Anonymous.CpuSet.CoreIndex = number / 2; + raw.Anonymous.CpuSet.LastLevelCacheIndex = llc; + raw.Anonymous.CpuSet.NumaNodeIndex = 0; + raw.Anonymous.CpuSet.EfficiencyClass = class; + raw.Anonymous.CpuSet.Anonymous1.AllFlags = flags; + raw.Anonymous.CpuSet.AllocationTag = u64::from(id) << 32; + raw +} + +fn decode_all(storage: &[u64], length: u32) -> Vec { + // SAFETY: `storage` holds `length` initialized bytes of consecutive records. + unsafe { decode(storage.as_ptr().cast::(), length) } +} + +#[test] +fn every_field_survives_the_walk() { + let size = size_of::() as u32; + let storage = encode(&[record(256, 1, 5, 3, 2, flags::PARKED | flags::REAL_TIME)]); + let decoded = decode_all(&storage, size); + + assert_eq!(decoded.len(), 1); + let r = decoded[0]; + assert_eq!(r.id, 256); + assert_eq!(r.group, 1); + assert_eq!(r.logical_processor_index, 5); + assert_eq!(r.core_index, 2); + assert_eq!(r.last_level_cache_index, 3); + assert_eq!(r.numa_node_index, 0); + assert_eq!(r.efficiency_class, 2); + assert_eq!(r.allocation_tag, 256_u64 << 32); +} + +#[test] +fn each_flag_is_read_from_its_own_bit() { + // Written as one test per bit rather than one combined value, because a + // wrong shift is invisible when several bits are set at once. + let size = size_of::() as u32; + for (bit, name) in [ + (flags::PARKED, "parked"), + (flags::ALLOCATED, "allocated"), + ( + flags::ALLOCATED_TO_TARGET_PROCESS, + "allocated_to_target_process", + ), + (flags::REAL_TIME, "real_time"), + ] { + let storage = encode(&[record(0, 0, 0, 0, 0, bit)]); + let r = decode_all(&storage, size)[0]; + let observed = [ + ("parked", r.parked), + ("allocated", r.allocated), + ("allocated_to_target_process", r.allocated_to_target_process), + ("real_time", r.real_time), + ]; + for (which, value) in observed { + assert_eq!( + value, + which == name, + "with only {name} set, {which} read as {value}" + ); + } + } +} + +#[test] +fn a_zero_size_record_stops_the_walk_rather_than_looping() { + // Windows does not emit one. Trusting that is how a corrupt buffer becomes + // a hang instead of a stop, so the guard is asserted rather than assumed. + let mut raw = record(1, 0, 0, 0, 0, 0); + raw.Size = 0; + let storage = encode(&[raw]); + let decoded = decode_all(&storage, size_of::() as u32); + assert!(decoded.is_empty(), "a zero-size record must end the walk"); +} + +#[test] +fn a_record_claiming_more_than_the_buffer_holds_is_refused() { + let mut raw = record(1, 0, 0, 0, 0, 0); + raw.Size = size_of::() as u32 * 4; + let storage = encode(&[raw]); + let decoded = decode_all(&storage, size_of::() as u32); + assert!( + decoded.is_empty(), + "a record overrunning the reported length must not be read" + ); +} + +#[test] +fn an_unrecognised_record_type_is_skipped_without_stopping() { + // The walk advances by `Size` whatever the type is, so a future record kind + // must not truncate the enumeration at the first one Windows adds. + let size = size_of::() as u32; + let mut unknown = record(1, 0, 0, 0, 0, 0); + unknown.Type = CpuSetInformation + 1; + let storage = encode(&[unknown, record(2, 0, 1, 0, 0, 0)]); + + let decoded = decode_all(&storage, size * 2); + assert_eq!(decoded.len(), 1, "the unknown record is skipped, not fatal"); + assert_eq!(decoded[0].id, 2, "and the record after it is still read"); +} + +#[test] +fn several_records_are_walked_in_order() { + let size = size_of::() as u32; + let storage = encode(&[ + record(10, 0, 0, 0, 0, 0), + record(11, 0, 1, 0, 0, 0), + record(12, 0, 2, 1, 0, 0), + ]); + let decoded = decode_all(&storage, size * 3); + + assert_eq!( + decoded.iter().map(|r| r.id).collect::>(), + vec![10, 11, 12] + ); + assert_eq!(decoded[2].last_level_cache_index, 1); +} + +#[test] +fn a_truncated_trailing_record_is_dropped_rather_than_read() { + // `actual_length` is what the API wrote, and a record straddling its end is + // not a record. Reported as fewer records, never as a partial one. + let size = size_of::() as u32; + let storage = encode(&[record(1, 0, 0, 0, 0, 0), record(2, 0, 1, 0, 0, 0)]); + let decoded = decode_all(&storage, size + size / 2); + + assert_eq!(decoded.len(), 1); + assert_eq!(decoded[0].id, 1); +} + +#[test] +fn enumerating_the_running_system_agrees_with_itself() { + // The only test that touches the real API. It cannot assert a machine's + // shape, so it asserts internal consistency instead: ids are unique, and + // every record names a group and processor number that could exist. + let records = enumerate().expect("enumerating cpu sets on a live system"); + assert!( + !records.is_empty(), + "a running Windows system reports at least one cpu set" + ); + + let mut ids: Vec = records.iter().map(|r| r.id).collect(); + ids.sort_unstable(); + let unique = { + let mut copy = ids.clone(); + copy.dedup(); + copy.len() + }; + assert_eq!(unique, ids.len(), "cpu set ids must be unique"); + + for r in &records { + assert!( + usize::from(r.logical_processor_index) < usize::BITS as usize, + "processor {} exceeds a group's maximum", + r.logical_processor_index + ); + } +} + +#[test] +fn windows_llc_grouping_is_not_the_derived_partitioning_cache() { + // Measured on the x64 development host, and kept because the two numbers + // differ for a reason a merge would destroy. CPU Sets reports **one** + // distinct `LastLevelCacheIndex` across all sixteen processors -- the L3 + // that spans the machine -- while `outermost_partitioning_cache` reports + // eight partitions at L2. Both are right: Windows names the *last* level, + // and the derivation names the outermost level that *divides*. + // + // A reconciliation that treated `LastLevelCacheIndex` as "the cache domain" + // would therefore collapse eight groups into one on this machine, which is + // why SH-16.13 is a decision rather than a cleanup. + // + // Asserted as a *relationship* rather than as the host's numbers, so this + // does not fail on a machine with a different shape: wherever both are + // known, Windows's LLC grouping is never finer than the derived one, since + // the last level is at or outside whatever level first divides the machine. + let records = enumerate().expect("cpu sets"); + let topo = crate::Topology::discover().expect("discover"); + + let mut llc: Vec = records.iter().map(|r| r.last_level_cache_index).collect(); + llc.sort_unstable(); + llc.dedup(); + + let derived = topo + .outermost_partitioning_cache() + .map_or(1, |(_, partitions)| partitions.len()); + + assert!( + llc.len() <= derived, + "Windows reports {} last-level-cache groups against {derived} derived partitions; the \ + last level cannot divide the machine more finely than the outermost dividing level", + llc.len() + ); +} diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index b8a3ce0f..913b0c81 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -60,6 +60,8 @@ #![warn(missing_docs)] +#[cfg(windows)] +mod cpu_set; #[cfg(windows)] mod domain; #[cfg(windows)] @@ -74,8 +76,9 @@ mod topology; mod walk; #[cfg(windows)] -pub use domain::{AttributeValue, Distances, Domain, DomainKind, Processor, ProcessorId}; +pub use cpu_set::CpuSet; #[cfg(windows)] +pub use domain::{AttributeValue, Distances, Domain, DomainKind, Processor, ProcessorId}; pub use processor_set::ProcessorSet; pub use provenance::Provenance; #[cfg(windows)] diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index bcd9f46b..f47cea19 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -3,6 +3,7 @@ use std::io; +use crate::cpu_set::CpuSet; use crate::domain::{Distances, Domain, DomainKind, Processor, ProcessorId}; use crate::provenance::Provenance; use crate::relation::{self, Relations}; @@ -26,6 +27,30 @@ pub struct Topology { pub domains: Vec, /// An optional scalar distance matrix. pub distances: Option, + /// What `GetSystemCpuSetInformation` reported, as **its own observation**. + /// + /// Windows describes processors through two APIs, and this is the second + /// one. It is not a more convenient spelling of [`Self::domains`]: it + /// carries facts the relationship walk has no equivalent for -- whether a + /// processor is parked, whether it is allocated to *this* process, the + /// scheduler's own last-level-cache grouping, a scheduling class and an + /// allocation tag. + /// + /// It also **duplicates** some facts, deliberately and without + /// reconciliation. `CoreIndex`, `NumaNodeIndex` and `EfficiencyClass` also + /// appear, derived differently, in `domains` and [`Self::processors`]. The + /// two paths can disagree -- under a hypervisor, or where one is stale -- + /// and merging them here would silently pick a winner and destroy the + /// disagreement, which is the only thing a second observer is *for*. Which + /// of them a consumer should believe, and what to do when they differ, is a + /// decision that has not been taken. + /// + /// `None` means **not observed**, which is not the same as observed-and- + /// empty: a hand-built or deserialized topology has not asked the running + /// system, and a consumer must be able to tell that from a machine that + /// genuinely reported nothing. + #[cfg_attr(feature = "serde", serde(default))] + pub cpu_sets: Option>, /// Where this content came from. /// /// **Defaults to [`Provenance::Synthetic`]**, so a topology built by hand @@ -49,10 +74,15 @@ impl Topology { /// # Errors /// /// Returns any error from the underlying `GetLogicalProcessorInformationEx` - /// call. + /// or `GetSystemCpuSetInformation` calls. pub fn discover() -> io::Result { let relations = relation::discover()?; let mut topology = Self::from_relations(relations); + // The second observation, kept beside the first rather than folded into + // it. Both are cheap reads of the running system, so both belong to + // discovery -- neither is a measurement in the sense that would make it + // expensive or optional. + topology.cpu_sets = Some(crate::cpu_set::enumerate()?); // The one place in the crate that may claim this is the machine you are // on, because it is the one place that asked the operating system. topology.provenance = Provenance::Measured; @@ -126,6 +156,7 @@ impl Topology { processors, domains, distances: None, + cpu_sets: None, // Synthetic, not measured: this is a pure transform of whatever // relations it was handed, and cannot know where they came from. // `discover` stamps the claim because `discover` is what read the diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index aebfb026..e29c327c 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -73,6 +73,7 @@ fn synthetic() -> Topology { }, ], distances: None, + cpu_sets: None, // Named rather than defaulted, so this fixture states what it is. The // helper is called `synthetic` and now says so in the value too. provenance: Provenance::Synthetic, @@ -304,6 +305,7 @@ fn struct_update_syntax_from_default_stays_untrusted() { // nobody thinks to name. let topology = Topology { distances: None, + cpu_sets: None, ..Default::default() }; @@ -592,6 +594,7 @@ fn split_l1_machine(cores: u32, last_level: u8) -> Topology { processors: Vec::new(), domains, distances: None, + cpu_sets: None, provenance: Provenance::Synthetic, } } @@ -608,6 +611,7 @@ fn cache_levels_are_empty_when_no_cache_is_reported() { processors: Vec::new(), domains: Vec::new(), distances: None, + cpu_sets: None, provenance: Provenance::Synthetic, }; assert!(topo.cache_levels().is_empty()); From 467a378b83f5cc7b7a47dc3e081781b9381e5d05 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 21:48:25 -0400 Subject: [PATCH 239/361] docs(topology): record D-13 and D-14, and audit every Option against D-13 Two decisions, one of which came out of the engineer noticing that the cpu_sets field had to say *which* absence its None meant. D-13 generalises that. An Option is three different facts wearing one shape: not observed, observed and absent, and a computed answer that happens to be negative. A consumer that cannot tell the first from the second will eventually read one as the other, and the failure is silent both ways -- "we did not look" read as "there is none" invents a fact, and the reverse sends a caller off to re-derive something already settled. So every Option documents which it means, at its site, and none may mean more than one. This is D-11's reasoning carried up a level. D-11 rejected Some(0) for memory_bytes because a sentinel would be indistinguishable from a real value; D-13 is that argument moved from "do not use a sentinel" to "say which absence you mean". Audited all five Options in the crate. Four are unambiguous once stated: distances is not-observed and in fact unobservable here, cpu_sets is not-observed with Some(empty) a distinct legitimate answer, processor() is an ordinary lookup miss, and outermost_partitioning_cache returns a negative result rather than an absence at all. The audit found one real gap. memory_bytes is unambiguous from discover, which always sets None for D-11's reason, but a *description's* None conflates "the description omitted the field" with "this node's capacity is genuinely unknown". Documentation cannot fix that, because the two are the same value today, so it is queued against SH-16.8 where absence becomes first-class -- recorded here so it is not rediscovered, queued there so it is not merely recorded. Also recorded where the crate already got this wrong, since the same file reasoning correctly in one place and not another is worth naming: Processor::capacity is a sentinel that collides with a legitimate value, which is strictly worse than an ambiguous Option because a careful caller cannot distinguish the cases at all. SH-16.12. D-14 records the LastLevelCacheIndex finding in the crate that owns the model rather than only in the consumer's notes and the checklist. CPU Sets reports one LLC group over all sixteen processors on the x64 host; the derivation reports eight partitions at L2. Both correct, different questions -- Windows names the last level whether or not it divides anything, the derivation names the outermost level that does. A consumer substituting one for the other would have produced one shard group where the derivation produces eight, and nothing about the value would have looked wrong. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 6 ++ crates/windows-topology-sys/DESIGN-NOTES.md | 84 +++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index ac03f643..c7c809bd 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -840,6 +840,12 @@ predicted about a 222-commit branch. [DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) is attributed to hardware, but those sixteen processors do share one L3, so a per-level model would express it. Gated on the session above, which carries the design space and the open questions. + **Scope addition from [D-13](crates/windows-topology-sys/DESIGN-NOTES.md):** the audit that decision + performed over every `Option` in the crate found exactly one site that documentation cannot fix. + `DomainKind::Memory::memory_bytes` is unambiguous from `discover`, which always sets `None`, but a + **description's** `None` conflates "the description omitted the field" with "this node's capacity is + genuinely unknown" -- the two are the same value today. Whatever representation this item lands must + cover it, since absence becoming first-class is precisely the fix. **Direction now settled** by the engineer: presence and observation must be modeled, not collapsed into an `Option`. "Win32 did not report it" and "it was found not to be present" are different facts, and the representation must be built for **observed connectivity** rather than diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index ffbd2756..9b819355 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -32,6 +32,8 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-10 | **The description is platform-neutral; platform constraints live in the planner.** A description sourced from Linux will have one group possibly containing more than 64 processors, which is unrepresentable as a Windows affinity mask. The schema does not enforce the Windows limit. A Windows planner consuming such a description must reject or split it rather than silently emitting an affinity mask that cannot exist. Keeping the constraint in the planner is what allows a description of a machine to be written on, and for, a different platform. | | D-11 | **A `Memory` domain's `memory_bytes` is `Option`, not a bare `u64`, because Windows's own enumeration cannot report it.** `GetLogicalProcessorInformationEx`'s NUMA-node relationship carries a processor set and a node number, never a capacity; measuring node memory would mean a different API entirely. A `Topology` this crate discovers therefore always sets `memory_bytes: None` for every memory domain it produces from `RelationNumaNode`/`RelationNumaNodeEx`. Using `Some(0)` as a stand-in would be indistinguishable from "this node genuinely has no memory," which is exactly the CXL-expander case D-5 exists to represent honestly; `None` is the only choice that does not silently invent data. A hand-written or fed-in description may still supply a real value. | | D-12 | **A topology carries its own provenance, and the untrusted value is the default.** This crate deliberately lets a topology be discovered, built by hand, or deserialized from a description written for a machine you do not have -- and until now the three were indistinguishable once built. [`Provenance`] is `Synthetic` by `Default`, so forgetting is safe and claiming is deliberate; only `discover` yields `Measured`; and deserialization can only ever *downgrade*, so a file cannot assert it is the machine you are on. | +| D-13 | **Every `Option` in this crate must say *which* absence it means.** "Not observed", "observed and absent", and "a computed answer that is negative" are three different facts, and an `Option` spells all three identically. Each one is documented at its site, and no field may mean more than one. See the detail section below, which audits every `Option` the crate has. | +| D-14 | **Windows's `LastLevelCacheIndex` is not `Topology::outermost_partitioning_cache`, and neither is wrong.** Measured on the x64 development host: CPU Sets reports **one** LLC group over all sixteen processors, while the derivation reports **eight** partitions at L2. Windows names the *last* level; the derivation names the outermost level that *divides*. They answer different questions, so neither may be substituted for the other, and a consumer treating the CPU-set value as "the cache domain" would collapse eight groups into one on that machine. | ## D-12: provenance, and why the default points at distrust @@ -81,6 +83,88 @@ cannot know where they came from; putting the claim in `discover` keeps it attac asking the operating system, so a future second caller of the transform does not silently inherit an assertion it has not earned. +## D-13: which absence an `Option` means + +An `Option` is three different facts wearing one shape, and this crate carries all three: + +1. **Not observed.** Nothing asked. The value may exist on this machine; we did not look, or there + is no way to look. +2. **Observed and absent.** Something asked, and the answer was that there is none. +3. **A negative result.** Not an absence at all: a computed answer whose value happens to be "no". + +A consumer that cannot tell (1) from (2) will eventually read one as the other, and the failure is +silent both ways -- treating "we did not look" as "there is none" invents a fact, and treating +"there is none" as "we did not look" sends a caller off to re-derive something already settled. + +**The rule: every `Option` documents which of the three it means, at its site, and no single +`Option` may mean more than one.** Where a field would otherwise have to mean two, that is the +signal to change the representation rather than to write a longer comment. + +This is the same reasoning [D-11](#d-11) already applied to `memory_bytes`, one level down: `Some(0)` +was rejected there because a sentinel would be indistinguishable from a real value. D-13 is that +argument carried up from "do not use a sentinel" to "say which absence you mean". + +### The audit + +| Site | Which absence | Notes | +|---|---|---| +| `Topology::distances` | **not observed**, and unobservable here | Windows exposes no user-mode SLIT reader, so `discover` can never fill it. Populated only by a fed-in description. | +| `Topology::cpu_sets` | **not observed** | `Some(v)` means the CPU-set API answered, and `v` may legitimately be empty; `None` means nothing asked, which is what a hand-built or deserialized topology is. | +| `DomainKind::Memory::memory_bytes` | **not observed** from `discover` | See below: a *description's* `None` is currently ambiguous, and that is the one gap this audit found. | +| `Topology::processor` | lookup miss | Ordinary "no such element", not a fact about the machine. | +| `Topology::outermost_partitioning_cache` | **negative result** (category 3) | `None` is the real answer "no level divides this machine", already documented as such. Not an absence, and must not be read as one. | + +### The one gap this audit found + +`memory_bytes` is unambiguous from `discover`, which always sets `None` for the reason D-11 gives. +It is **ambiguous from a description**: a description that omits the field and a description +written for a node whose capacity is genuinely unknown produce the same `None`, and nothing +distinguishes them. + +That is not fixable by documentation, because the two really are the same value today. It is fixed +by the representation, which is the subject of the open locality-model work -- see `SH-16.8` in +[CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md), where absence +becomes first-class rather than a shape. Recorded here so the gap is not rediscovered, and queued +there so it is not merely recorded. + +### Where this crate already got it wrong + +`Processor::capacity` is the counter-example, and it is worse than an ambiguous `Option`: it is a +**sentinel that collides with a legitimate value**. `0` means offline, *or* in no core domain, *or* +efficiency class zero -- and the third is every processor on every non-hybrid machine. A careful +caller cannot distinguish them at all, where an ambiguous `Option` at least admits it is absent. +Tracked as `SH-16.12`. `DomainKind::Core { efficiency_class }` carries the same fact with no +sentinel and is the interim answer. + +## D-14: Windows's last-level cache is a different question + +`SYSTEM_CPU_SET_INFORMATION::LastLevelCacheIndex` and +[`Topology::outermost_partitioning_cache`](crate::Topology::outermost_partitioning_cache) both look +like "which cache groups these processors", and they are not the same question. + +Measured on the x64 development host, sixteen processors: + +- CPU Sets reports **one** distinct `LastLevelCacheIndex`. That is the L3, which spans the machine. +- `outermost_partitioning_cache` reports **eight** partitions, at L2. + +Both are correct. Windows names the **last** level in the hierarchy, whether or not it divides +anything; the derivation names the outermost level that **does** divide, which is what a caller +sharding work needs and is why it exists. On a machine whose last level is shared by everything, +those answers differ by the whole width of the machine. + +The consequence worth stating plainly: **a consumer must not substitute one for the other.** Reading +`LastLevelCacheIndex` as "the cache domain" would have produced one shard group where the derivation +produces eight, and nothing about the value would have looked wrong. + +This is also the first concrete instance of two Win32 sources describing overlapping facts, which is +why `Topology::cpu_sets` is carried beside the domains rather than merged into them. Deciding what a +consumer should do when the two disagree -- as opposed to answering different questions, which is +this case -- is `SH-16.13`. + +Kept honest by a test that asserts the *relationship* rather than this host's numbers: Windows's +grouping is never finer than the derived one, because the last level is at or outside whatever level +first divides the machine. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was From 313a79093a14871c9f2ea835f168b04a68bb58f4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 21:53:41 -0400 Subject: [PATCH 240/361] docs: hand the planner's requirements to the session, and block EP-1.4 as a duplicate The engineer asked whether the model should be fixed before analysing the planner further. It should, but the reason applies to one item rather than all four, and the distinction is worth recording. EP-1.1 through EP-1.3 push requirements into the model's design. They are valid whatever shape the model currently has -- which is why doing them against today's model found real defects: a sentinel colliding with a legitimate value, a field that can never carry Measured provenance by construction, and a stale claim about directional measurement in a parked item. EP-1.4 reads behaviour out of the model. It asks what the planner does when the answer is "not observed", a state the model cannot currently express reliably, so analysing it now would be reasoning about a shape that does not exist. It is also a duplicate, which nobody had noticed. The session's fourth open question -- what a consumer does when a needed fact is not measured -- is the same decision seen from the model's side. Filed independently in two places, and taken separately they can disagree: a planner degrading in a way the model does not support, or a model offering a fallback no consumer wants. Both now say so and point at each other. Completed the handover half of EP-1.5, since it did not depend on the model existing and is what a model designer needs in front of them. The session now carries the three queries in a table -- shard set per processor, proximity over an unordered pair, residency over an ordered one -- plus the four model properties that follow: a pairwise query must exist, the order must be total via an explicit machine-wide top, an answer must be able to be an upper bound and say so, and a measured number must carry what it measured. The coverage half of EP-1.5 stays open, since recording which requirements the settled model answers needs a settled model. M1 is now three done and two blocked, which is as far as it can go. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-execution-plan/CHECKLIST.md | 19 ++++++++++- ...SESSION-2026-09-02-cache-locality-model.md | 34 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/windows-execution-plan/CHECKLIST.md index bb5f153e..9d14e654 100644 --- a/crates/windows-execution-plan/CHECKLIST.md +++ b/crates/windows-execution-plan/CHECKLIST.md @@ -19,7 +19,7 @@ the model. | Milestone | State | What it is waiting on | |---|---|---| -| M1 the input contract | 3 of 5 done | nothing -- it is what unblocks the others | +| M1 the input contract | 3 done, 2 blocked | as far as it can go before the model exists | | M2+ the plan as a value | parked | M1, and the topology model landing | | M3+ the policies | parked | M2+ | | M-inf parked | ungated | not scheduled, deliberately | @@ -101,10 +101,27 @@ whether the topology can answer it today -- so the model is designed against a r refuses to plan, or emits a plan carrying an explicit "this was chosen without knowing X" marker. The third is the only one that survives review of a plan by a human, which is one of the reasons a plan is a value. + **BLOCKED, and not merely because it is downstream.** EP-1.1 through EP-1.3 push requirements + *into* the model's design, which is why they were worth doing against today's model and found real + defects in it. This item reads behaviour *out* of the model -- it asks what the planner does when + the answer is "not observed", a state the model cannot currently express reliably -- so doing it + now would be analysing a shape that does not exist yet. + **It is also a duplicate.** The design session's fourth open question, "what a consumer does when a + needed fact is `not measured`", is this same decision seen from the model's side; the two were + filed independently before anyone noticed. Taken separately they can disagree: a planner that + degrades in a way the model does not support, or a model offering a fallback no consumer wants. + Answer them together, in the session. - [ ] **EP-1.5** -- **Hand the resulting requirements to the design session** as the consumer-side input it asked for, and record in the session which of them the settled model answers and which it deliberately does not. + **Half done, and split because the halves have different prerequisites.** The *handover* is + complete: the session now carries the three queries in a table, plus the four model properties that + follow from them -- a pairwise query must exist, the order must be total, an answer must be able to + be an upper bound, and a measured number must carry what it measured. That was the part the model + designer needs in front of them, and it did not depend on the model existing. + The *coverage* half -- recording which requirements the settled model answers and which it + deliberately does not -- can only be written once there is a settled model. It stays open here. > **-> CROSS-COMPONENT HANDOFF:** next work is in the repository root -> > [DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) > -> `SH-16.8` in diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md index 79ed8aca..a123ac93 100644 --- a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -283,6 +283,35 @@ The walk also found the mapping itself was **unowned**: `CHECKLIST-io-domains.md contracts "the runtime cannot be written without" and all four concern the queue, while M33+.1 presupposes a plan naming which thread, which node and which shard. That is now a component. +### The consumer's requirements, stated + +Three queries, recorded in full as EP-D-1, EP-D-2 and EP-D-3 in +[the planner's design notes](../crates/windows-execution-plan/DESIGN-NOTES.md). Summarised here +because a model designed without them in view is what produced the current one. + +| Query | Shape | What the model must answer | +|---|---|---| +| **Shard set** (EP-D-1) | per processor | identity as `(group, number)`; online; core membership and SMT; efficiency class **without a sentinel**; and availability -- parked, and allocated to *this* process | +| **Proximity** (EP-D-2) | **unordered** pair | the minimal granularities the two share, **their membership** (to size an MPSC fan-in without re-deriving the grouping), and whether a **finer granularity went unobserved**, so an answer can be an upper bound and say so | +| **Residency** (EP-D-3) | **ordered** pair | processor-to-memory-domain with the unplaced case distinguishable, and a **directed** cost between memory domains, which SLIT's symmetric scalar cannot express | + +Four properties of the model follow from those and are worth stating as requirements rather than +leaving implicit in three separate documents: + +1. **A pairwise query must exist.** No query in `windows-topology-sys` takes two processors today, + and that absence is the direct cause of `SH-16.9`'s three inconsistent reconstructions. +2. **The order must be total**, which needs an explicit "the machine" top granularity -- otherwise + every caller writes the same empty-case branch for a cross-node pair. +3. **An answer must be able to be an upper bound.** "Tightest shared is L3" and "at most L3, finer + not observed" are different answers, and under the no-probing bar the planner cannot go and check. +4. **A measured number must carry what it measured.** The probe's figures are nanoseconds for one + ring-handoff pattern at one message size; promoting them as "the distance" would bake one + workload into a model other consumers share. + +**Where the requirements stop.** They say what the planner must be able to *ask*. They do not say +what it should *do* when the answer is "not observed" -- that is this session's fourth open question +and the planner's EP-1.4, which are the same decision seen from two ends and must be taken together. + ### Still open - **Who owns the measurement phase, and what does it cost?** A `discover()` that measures is @@ -294,6 +323,11 @@ presupposes a plan naming which thread, which node and which shard. That is now conflict itself, forcing the caller to adjudicate. - What a consumer does when a needed fact is `not measured` -- is degrading its choice, or does the model offer a documented fallback? + **This is the same decision as the planner's EP-1.4**, seen from the model's side rather than the + consumer's, and the two were filed independently before anyone noticed. They must be taken + together: answering either alone risks a planner that degrades in a way the model does not + support, or a model offering a fallback no consumer wants. EP-1.4 is blocked on this question + rather than on the model as a whole. ## What the code actually does, verified rather than assumed From 9f6a1d6de5c1bead042af983e0310925b041c5e4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 22:11:29 -0400 Subject: [PATCH 241/361] docs: record the two-component architecture, which answers who measures The engineer described the second component's function, and it is richer than "takes a topology, applies policy" in ways that resolve an open question and reverse an earlier conclusion. There are two graphs, and calling both "topology" is why this has been confusing. What the machine is -- observed from the Windows data model, never chosen, and mockable so a machine nobody has is a first-class input. And what we intend to build on it: which processors host domains, where threads pin, which rings connect them, where buffers live. The synthesizer takes two inputs, not one. The observed machine, and a description of the desired function. That second input is the thing the design has been missing, and its absence is why "what is most useful for consumers" kept being hard to answer in the abstract -- there was no scenario to be useful for. Planning is a negotiation rather than a pure function: the component may call back to its caller through traits for clarification the scenario did not settle. And it is the component that measures, with permission. That answers the session's open question about who owns the measurement phase, and it is better than either candidate the session had. EP-D-3 had already established that a measured number means nothing without knowing what it measured -- the probe's figures are nanoseconds for one ring-handoff pattern at one message size -- and a component that knows the scenario can measure the right thing, where a discover() that measured could not, having no idea what the caller intends. The no-probing bar survives and is sharper for it. Three stages, each honest about its cost: observe, cheap and choiceless; synthesize, which may measure with permission; execute, no I/O. One consequence flagged rather than acted on, because it reverses a conclusion this session reached earlier. If the synthesizer measures for its own scenario, measured facts may not belong in the observed topology at all -- the earlier reasoning that they must assumed a single component. With two, a measurement is the synthesizer's working state and its justification for a choice, not a property of the machine. That would mean Topology::distances is deleted rather than filled, which is the opposite of what SH-16.11 currently proposes. Confirm before removing anything. Three new items, none of them model questions: describe the scenario input, decide what the callback traits ask, and settle the naming before any type is written. The last is cheap now and expensive once either name is public. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-execution-plan/CHECKLIST.md | 26 +++++++ crates/windows-execution-plan/COMPONENT.md | 40 +++++++++-- ...SESSION-2026-09-02-cache-locality-model.md | 67 ++++++++++++++++++- 3 files changed, 126 insertions(+), 7 deletions(-) diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/windows-execution-plan/CHECKLIST.md index 9d14e654..ca048584 100644 --- a/crates/windows-execution-plan/CHECKLIST.md +++ b/crates/windows-execution-plan/CHECKLIST.md @@ -20,6 +20,7 @@ the model. | Milestone | State | What it is waiting on | |---|---|---| | M1 the input contract | 3 done, 2 blocked | as far as it can go before the model exists | +| M1+ scenario and naming | open | the session; neither is a model question | | M2+ the plan as a value | parked | M1, and the topology model landing | | M3+ the policies | parked | M2+ | | M-inf parked | ungated | not scheduled, deliberately | @@ -127,6 +128,31 @@ whether the topology can answer it today -- so the model is designed against a r > -> `SH-16.8` in > [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). +## M1+: the scenario input, and the naming + +Raised when the engineer described this component's function, which turned out to be richer than +"takes a topology, applies policy". Both are gated on the locality-model session, but neither is a +model question -- they are this component's own. + +- [ ] **EP-1+.1** -- **Describe the scenario input.** The synthesizer takes *two* inputs and only one + is described anywhere. The scenario says what the caller intends to run, and it is what makes a + measurement meaningful: [EP-D-3](DESIGN-NOTES.md#ep-d-3) established that a measured number means + nothing without knowing what it measured, so at minimum the scenario must distinguish small-message + handoff from large-buffer streaming. Its absence is why "what is most useful for consumers" was + hard to answer in the abstract for so long. + +- [ ] **EP-1+.2** -- **Decide what the caller-callback traits ask.** Planning is a negotiation: the + component may call back for clarification the scenario did not settle. Enumerating those questions + is what decides whether this is one trait or several, and it cannot be done before EP-1+.1 says + what the scenario already answers. + +- [ ] **EP-1+.3** -- **Settle the naming, before any type is written.** Both inputs and the output + are graphs of processors and their relations, so "topology" fits all of them and distinguishes + none -- and a reader seeing the word twice will eventually take one for the other. Decide whether + the observed machine keeps the bare name (qualified only by its crate), gains a qualifier, or is + renamed outright, and what the synthesized arrangement is called. Cheap now; expensive once either + name is public. This one blocks nothing but should not be settled by whoever writes the first type. + ## M2+: the plan as a value Parked, not pending. Gated on the topology model landing. Shape recorded so it is not lost, per the diff --git a/crates/windows-execution-plan/COMPONENT.md b/crates/windows-execution-plan/COMPONENT.md index 66afdafb..0db97a2a 100644 --- a/crates/windows-execution-plan/COMPONENT.md +++ b/crates/windows-execution-plan/COMPONENT.md @@ -6,15 +6,47 @@ place, rather than living as an assumption inside somebody else's milestone. ## What it is -Takes a `Topology` and produces a **plan for execution domains**: which processors get a domain, -where each domain's thread is pinned, which memory node each domain allocates from, what channel -connects each pair of domains, and where each channel's buffer lives. +A **synthesizer**. It takes two inputs and produces a third thing: -The shape it plans for is the one +- **the observed machine** -- what Windows reports, plus whatever else is trivially available. This + is `windows_topology_sys::Topology`, and it is **mockable**: a description of a machine nobody has + is a first-class input, which is what makes this component testable without the hardware it plans + for. +- **a description of the desired function** -- the scenario. What the caller intends to run, in + enough detail that a measurement taken on its behalf means something. + +From those it synthesizes **a concrete description of the arrangement to construct**: which +processors host domains, where each thread pins, which memory node each allocates from, what channel +connects each pair, and where each channel's buffer lives. + +Two things follow that a "takes a topology, returns a plan" description would miss. + +**It may ask.** Planning is a negotiation, not a pure function: the component may call back to its +caller through traits, for clarifying information the scenario did not settle. Which questions those +are is not yet known, and knowing them is what decides whether that is one trait or several. + +**It may measure, with permission.** This is the component that probes, and it is the right one -- +because a measured number is only meaningful alongside *what it measured*. The probe's existing +figures are nanoseconds for one ring-handoff pattern at one message size; a component that knows the +scenario can measure the right thing, where a `Topology::discover` that measured could not, having +no idea what the caller intends. + +So there are three stages, each honest about its cost: **observe** (cheap, no choices), **synthesize** +(may measure, with permission), **execute** (no I/O, no probing). + +The arrangement it plans for is the one [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M33+ describes -- "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- which is a Seastar-style shard-per-core runtime. +## Two graphs, one word + +Both inputs and the output are graphs of processors and their relations, so "topology" fits all of +them and distinguishes none. That ambiguity is live and unresolved: what the machine **is** and what +we intend to **build on it** are different enough that a reader seeing `Topology` twice will +eventually take one for the other. Naming is tracked as an open decision rather than settled by +whoever writes the first type. + ## Why it is separate Because two different kinds of statement were being made by one crate. diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md index a123ac93..702d9f4d 100644 --- a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -312,11 +312,72 @@ leaving implicit in three separate documents: what it should *do* when the answer is "not observed" -- that is this session's fourth open question and the planner's EP-1.4, which are the same decision seen from two ends and must be taken together. +### The two-component architecture, and who measures + +Settled by the engineer, and it answers the measurement question below rather than adding to it. + +There are **two** things, and both are graphs of processors and their relations, which is why +calling both "topology" has been confusing: + +1. **What the machine *is*.** Read from the Windows data model, plus whatever else is trivially + available. Observed, never chosen. This is today's `Topology`, and it is **mockable** -- a + description of a machine nobody has is a first-class input, which is what makes the second + component testable. + +2. **What we are going to *build* on it.** A concrete description of the arrangement to construct: + which processors host domains, which threads pin where, which rings connect them, where each + buffer lives. + +The second component synthesizes the second from the first, and it takes **two** inputs, not one: + +- the observed machine, and +- **a description of the desired function** -- the scenario. This is the input the design has been + missing, and its absence is why "what is most useful for consumers" kept being hard to answer in + the abstract. + +It may also **call back to its caller** through traits, to ask for clarifying information the +scenario did not settle. So planning is a negotiation rather than a pure function. + +**And it is the component that measures, with the caller's permission**, to determine the optimal +arrangement *for that scenario*. + +### What this resolves + +**Who owns the measurement phase: the synthesizer, permissioned.** Not `discover()`, and not an +enrich step on the topology. This is better than either, for a reason the session had already found +without drawing the conclusion: EP-D-3 established that a measured number is only meaningful +alongside *what it measured* -- the probe's figures are nanoseconds for one ring-handoff pattern at +one message size. A component that knows the scenario can measure the right thing; a `discover()` +that measures cannot, because it does not know what the caller intends to do. + +**The no-probing bar survives, sharpened.** The *observed* topology never measures, so it remains +usable without further measurement. The synthesizer may measure, but that is a distinct, +permissioned, scenario-specific activity producing a **plan**. The plan, once produced, is consumed +without further measurement. Three stages, each honest about its cost: observe (cheap), synthesize +(may measure, with permission), execute (no I/O). + +### A consequence that needs confirming + +If the synthesizer measures for its own scenario, then **measured facts may not belong in the +observed topology at all.** The session earlier concluded they must, on the grounds that a consumer +forbidden from probing needs them present -- but that reasoning assumed one component. With two, the +measurement is the synthesizer's working state and its justification for a choice, not a property of +the machine. + +That would make the observed topology purely what Windows reports, and it would mean +`Topology::distances` is **deleted rather than filled** -- which is a cleaner answer than SH-16.11's, +and the opposite of what that item currently proposes. Flagged rather than acted on, because it +reverses a conclusion this session reached earlier and should be confirmed before anything is +removed. + ### Still open -- **Who owns the measurement phase, and what does it cost?** A `discover()` that measures is - expensive and surprising; a separate enrich step is honest but can be skipped; lazy is ruled - out by the bar above. +- ~~Who owns the measurement phase?~~ **Answered above: the synthesizer, with permission.** +- **What shape is the scenario input?** It is the newly-named second input and nothing describes it + yet. EP-D-3's finding constrains it: it must carry enough for a measurement to be meaningful, + which at minimum distinguishes small-message handoff from large-buffer streaming. +- **What do the caller-callback traits ask?** Knowing which questions cannot be answered from the + scenario alone is what decides whether this is one trait or several. - Whether multiple observations per relation are held as a set, or reduced on insert with the reduction recorded. - What a query returns when observations disagree: a value plus a conflict marker, or the From 36e397d58ceb5108d48816d566d0712861056656 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 22:25:17 -0400 Subject: [PATCH 242/361] refactor(topology)!: rename Topology to MachineMemoryTopology Two different things were both going to be called a topology: what the machine is, read from the Windows data model, and what we intend to build on it -- which domains exist, where threads pin, which rings connect them. Both are graphs of processors and their relations, so the word fits both and distinguishes neither, and a reader seeing it twice would eventually take one for the other. MachineMemoryTopology is wordy on purpose. It says which of the two it is, and a caller who finds it long can `use` it under whatever name suits their file. Mechanical rename, no behaviour change. Done with a case-sensitive word-boundary match rather than a text replace, which matters here: the crate path windows_topology_sys and the module topology are both lowercase, so 94 type references were renamed out of 358 total occurrences of the word, and the paths were left alone. Verified by compiling the workspace with all targets and all features, plus clippy and 125 tests. Renamed in live documents too, so the docs do not immediately go stale. Left the append-only archives -- COMPLETED-CHECKLIST.md, COMPLETED-PLANS.md, and their per-crate siblings -- and the older 2026-08-30 design session untouched: those record what the type was called when they were written, and rewriting them would be inventing a history where it always had this name. Breaking, for a crate published at 0.1.0 and already breaking to 0.2.0 in this branch, so the cost is already paid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 8 ++-- PLANS.md | 2 +- crates/windows-execution-plan/CHECKLIST.md | 4 +- crates/windows-execution-plan/COMPONENT.md | 6 +-- crates/windows-execution-plan/DESIGN-NOTES.md | 10 ++-- .../examples/ring_copy/main.rs | 6 +-- .../examples/ring_copy/plan.rs | 11 +++-- .../examples/ring_copy/policy.rs | 4 +- crates/windows-ioring-sys/src/lib.rs | 2 +- .../src/bin/placement_probe/main.rs | 6 +-- .../src/bin/placement_probe/tests.rs | 2 +- .../src/core_affinity.rs | 6 +-- .../src/core_affinity/tests.rs | 4 +- .../src/fingerprint.rs | 22 +++++---- .../src/fingerprint/tests.rs | 29 ++++++------ .../src/record/tests.rs | 2 +- .../src/bin/topology.rs | 2 +- .../windows-platform-probes/src/topology.rs | 8 ++-- crates/windows-topology-sys/DESIGN-NOTES.md | 20 ++++---- crates/windows-topology-sys/README.md | 12 ++--- .../examples/print_topology.rs | 4 +- .../windows-topology-sys/src/cpu_set/tests.rs | 2 +- crates/windows-topology-sys/src/domain.rs | 4 +- crates/windows-topology-sys/src/lib.rs | 10 ++-- crates/windows-topology-sys/src/provenance.rs | 8 ++-- crates/windows-topology-sys/src/topology.rs | 10 ++-- .../src/topology/tests.rs | 46 +++++++++---------- ...SESSION-2026-09-02-cache-locality-model.md | 16 +++---- 28 files changed, 136 insertions(+), 130 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index c7c809bd..2ccc7e8d 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -300,7 +300,7 @@ conclude first. an unexported type, or a feature that only resolves inside the workspace will show up. - [ ] **SH-5.2** -- Confirm the published `windows-topology-sys` still reports `Provenance::Measured` - from `discover()` when consumed as a dependency, and that a `Topology::default()` is `Synthetic`. + from `discover()` when consumed as a dependency, and that a `MachineMemoryTopology::default()` is `Synthetic`. The provenance rules are the newest thing in the crate and the least exercised outside it. ## M6: long-running validation @@ -853,7 +853,7 @@ predicted about a 222-commit branch. which merges both. Shape still open. - [ ] **SH-16.9** -- **The "outermost partitioning cache" rule is stated three times, and two of the - three disagree.** `Topology::outermost_partitioning_cache` requires more than one partition **and** + three disagree.** `MachineMemoryTopology::outermost_partitioning_cache` requires more than one partition **and** pairwise disjointness. `Observation::outermost_partitioning_cache` in `windows-platform-probes` is `caches.iter().filter(|c| c.domains > 1).max_by_key(|c| c.level)` -- **no disjointness check** -- computed over a `CacheLevel` summary that crate builds itself, even though it already depends on @@ -892,7 +892,7 @@ predicted about a 222-commit branch. is already an enabled feature, so there is no manifest change and no blocker. **Done.** `src/cpu_set.rs` walks the records with the same buffer discipline the relationship walk uses -- size first, advance by each record's own `Size`, read every field unaligned -- and - `Topology::discover` now populates `Topology::cpu_sets`. Carried as + `MachineMemoryTopology::discover` now populates `MachineMemoryTopology::cpu_sets`. Carried as `Option>` where `None` means **not observed**, which a hand-built or deserialized topology genuinely is; that is the honest use of `Option`, one absence rather than two collapsed together. `#[serde(default)]` so descriptions written before the field still load. @@ -931,7 +931,7 @@ predicted about a 222-commit branch. sentinel**, so it is a cleaner source for the field whose `capacity` encoding collides with "unknown". -- [ ] **SH-16.11** -- **`Topology::distances` is a field for a fact Win32 cannot supply, it is never +- [ ] **SH-16.11** -- **`MachineMemoryTopology::distances` is a field for a fact Win32 cannot supply, it is never populated, and the measurement that would fill it already exists elsewhere.** `discover()` hardcodes `distances: None`, every other construction sets `None`, and no consumer reads the field. Windows exposes no API for NUMA node distance -- ACPI carries SLIT, Win32 does not surface diff --git a/PLANS.md b/PLANS.md index 9ff16d3c..d09ee37e 100644 --- a/PLANS.md +++ b/PLANS.md @@ -18,7 +18,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| | [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | -| [crates/windows-execution-plan/CHECKLIST.md](crates/windows-execution-plan/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a `Topology` to a set of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 is a *requirements* milestone and is the only active one -- it states what the topology must answer, feeding the open locality-model session, which asked what shape is most useful to consumers and is being answered by naming one. M2+ and M3+ are parked on that session concluding. EP-1.1 is done and already earned its keep: checking the shard-set query against the model found `Processor::capacity` using `0` as both a valid efficiency class and a "not known" sentinel, which collide on every non-hybrid machine (filed as SH-16.12). | [crates/windows-execution-plan/DESIGN-NOTES.md](crates/windows-execution-plan/DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | +| [crates/windows-execution-plan/CHECKLIST.md](crates/windows-execution-plan/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a `MachineMemoryTopology` to a set of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 is a *requirements* milestone and is the only active one -- it states what the topology must answer, feeding the open locality-model session, which asked what shape is most useful to consumers and is being answered by naming one. M2+ and M3+ are parked on that session concluding. EP-1.1 is done and already earned its keep: checking the shard-set query against the model found `Processor::capacity` using `0` as both a valid efficiency class and a "not known" sentinel, which collide on every non-hybrid machine (filed as SH-16.12). | [crates/windows-execution-plan/DESIGN-NOTES.md](crates/windows-execution-plan/DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14, M15 and M16 are the three later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. M16 is the SH-3.1.1 diff review, the first to read the branch as a diff rather than react to a comment: seven findings, six fixed, including a publish-workflow regression this branch had introduced two commits earlier and a soundness hole in the crate about to freeze its API. Its remaining four are blocked on [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), which began by asking whether collapsing a seven-kind, any-depth topology onto a single cache boundary is the right projection and has since settled that presence and observation must be modeled rather than collapsed into an `Option`. **That work now gates the merge**: unlike M14 and M15, which concern a defect in an implementation that can ship disclosed, M16 concerns the shape of the public model `windows-topology-sys` 0.2.0 would publish, and a published model cannot be reshaped without another break. So M3 waits on M16, and M16 waits on the session. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/windows-execution-plan/CHECKLIST.md index ca048584..b1e454c8 100644 --- a/crates/windows-execution-plan/CHECKLIST.md +++ b/crates/windows-execution-plan/CHECKLIST.md @@ -1,6 +1,6 @@ # Checklist: the execution-domain planner -Plans the mapping from a `Topology` to a set of execution domains. See +Plans the mapping from a `MachineMemoryTopology` to a set of execution domains. See [COMPONENT.md](COMPONENT.md) for what this crate is and why it is separate from both the topology crate and the runtime. @@ -73,7 +73,7 @@ whether the topology can answer it today -- so the model is designed against a r - [x] **EP-1.3** -- **The residency query.** Which memory domain each processor belongs to, and -- for a pair spanning two of them -- what it costs to place a shared buffer on one side rather than - the other. **Gap already identified:** `Topology::distances` exists, is never populated, and Win32 + the other. **Gap already identified:** `MachineMemoryTopology::distances` exists, is never populated, and Win32 cannot populate it; the measurement exists in `windows-placement-probe` and reaches nothing. Tracked as `SH-16.11`. The probe measures this per node pair with a dedicated ring-placement column precisely because it was found to matter. diff --git a/crates/windows-execution-plan/COMPONENT.md b/crates/windows-execution-plan/COMPONENT.md index 0db97a2a..ca3923b8 100644 --- a/crates/windows-execution-plan/COMPONENT.md +++ b/crates/windows-execution-plan/COMPONENT.md @@ -9,7 +9,7 @@ place, rather than living as an assumption inside somebody else's milestone. A **synthesizer**. It takes two inputs and produces a third thing: - **the observed machine** -- what Windows reports, plus whatever else is trivially available. This - is `windows_topology_sys::Topology`, and it is **mockable**: a description of a machine nobody has + is `windows_topology_sys::MachineMemoryTopology`, and it is **mockable**: a description of a machine nobody has is a first-class input, which is what makes this component testable without the hardware it plans for. - **a description of the desired function** -- the scenario. What the caller intends to run, in @@ -28,7 +28,7 @@ are is not yet known, and knowing them is what decides whether that is one trait **It may measure, with permission.** This is the component that probes, and it is the right one -- because a measured number is only meaningful alongside *what it measured*. The probe's existing figures are nanoseconds for one ring-handoff pattern at one message size; a component that knows the -scenario can measure the right thing, where a `Topology::discover` that measured could not, having +scenario can measure the right thing, where a `MachineMemoryTopology::discover` that measured could not, having no idea what the caller intends. So there are three stages, each honest about its cost: **observe** (cheap, no choices), **synthesize** @@ -43,7 +43,7 @@ runtime. Both inputs and the output are graphs of processors and their relations, so "topology" fits all of them and distinguishes none. That ambiguity is live and unresolved: what the machine **is** and what -we intend to **build on it** are different enough that a reader seeing `Topology` twice will +we intend to **build on it** are different enough that a reader seeing `MachineMemoryTopology` twice will eventually take one for the other. Naming is tracked as an open decision rather than settled by whoever writes the first type. diff --git a/crates/windows-execution-plan/DESIGN-NOTES.md b/crates/windows-execution-plan/DESIGN-NOTES.md index 419754ca..cdc8ac3a 100644 --- a/crates/windows-execution-plan/DESIGN-NOTES.md +++ b/crates/windows-execution-plan/DESIGN-NOTES.md @@ -54,7 +54,7 @@ sized against it. Points 1 through 3 cleanly. `ProcessorId` is `(group, number)` by construction and documents why (D-7). `Processor::online` is exactly the distinction in point 2. `DomainKind::Core` carries -`simultaneous_multithreading` and the sibling set, so point 3 is a walk of `Topology::cores()`. +`simultaneous_multithreading` and the sibling set, so point 3 is a walk of `MachineMemoryTopology::cores()`. Point 4 is answered, but **twice, in two shapes, and one of them is unsafe to use** -- see below. @@ -196,7 +196,7 @@ repository has been bitten specifically by structure that was assumed rather tha ### What today's model answers: nothing -`Topology::outermost_partitioning_cache` reports **one level for the whole machine**, and +`MachineMemoryTopology::outermost_partitioning_cache` reports **one level for the whole machine**, and `Slice::same_cache_domain` reduces that to a boolean at that one level. Neither is pairwise. There is no query anywhere in `windows-topology-sys` that takes two processors. @@ -230,7 +230,7 @@ link and is symmetric; residency is the hop and is not. ### The first half is answered, with one asymmetry worth keeping -`Topology::memory_domains()` yields the memory domains with their processor sets, so +`MachineMemoryTopology::memory_domains()` yields the memory domains with their processor sets, so processor-to-domain is a lookup. Partial coverage exists here as it does for caches -- a processor may be named by no memory domain @@ -245,13 +245,13 @@ node-local pool, and the difference has to be visible in the plan rather than as ### The second half is not merely unpopulated -- it cannot be measured, by construction -`Topology::distances` exists, and it is easy to read its permanent `None` as an oversight. It is +`MachineMemoryTopology::distances` exists, and it is easy to read its permanent `None` as an oversight. It is not. The field is documented as being for a fed-in description, because "Windows exposes no user-mode SLIT reader", and that is accurate. The sharper problem is what follows from it. `distances` has exactly two input paths: hand construction, which defaults to `Provenance::Synthetic`, and deserialization, which -`downgraded_to(Provenance::Restored)` caps. `Topology::discover` hardcodes `None`. So **no path +`downgraded_to(Provenance::Restored)` caps. `MachineMemoryTopology::discover` hardcodes `None`. So **no path exists by which `distances` can ever carry `Measured` provenance** -- not because nobody wrote the code, but because the only sources are a literal and a file, and a file cannot establish that it describes the machine you are on. diff --git a/crates/windows-ioring-sys/examples/ring_copy/main.rs b/crates/windows-ioring-sys/examples/ring_copy/main.rs index 48a4c909..f5aef8f9 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/main.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/main.rs @@ -21,7 +21,7 @@ use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Storage::FileSystem::{ BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, }; -use windows_topology_sys::Topology; +use windows_topology_sys::MachineMemoryTopology; const DEFAULT_CHUNK_LEN: usize = 1024 * 1024; @@ -141,13 +141,13 @@ fn parse_args() -> Result { }) } -fn load_topology(path: Option<&PathBuf>) -> io::Result { +fn load_topology(path: Option<&PathBuf>) -> io::Result { match path { Some(path) => { let file = std::fs::File::open(path)?; serde_json::from_reader(file).map_err(io::Error::other) } - None => Topology::discover(), + None => MachineMemoryTopology::discover(), } } diff --git a/crates/windows-ioring-sys/examples/ring_copy/plan.rs b/crates/windows-ioring-sys/examples/ring_copy/plan.rs index 87883f0f..0ee1f0f6 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/plan.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/plan.rs @@ -4,7 +4,7 @@ use std::io; -use windows_topology_sys::{Domain, DomainKind, ProcessorSet, Topology}; +use windows_topology_sys::{Domain, DomainKind, MachineMemoryTopology, ProcessorSet}; /// What one execution domain needs to run: a single-group affinity mask and, /// if known, the NUMA node its registered buffer should prefer. @@ -27,7 +27,10 @@ pub struct DomainPlan { /// representable plan. This does not silently narrow it to a subset; the /// fed-in (or discovered) topology described something the platform cannot /// do, and that is reported rather than papered over. -pub fn build_plan(topology: &Topology, domains: &[Domain]) -> io::Result> { +pub fn build_plan( + topology: &MachineMemoryTopology, + domains: &[Domain], +) -> io::Result> { domains .iter() .enumerate() @@ -85,7 +88,7 @@ fn label_for(domain: &Domain) -> String { /// The NUMA node whose processors overlap `processors`, if any domain /// reports one -- `None` on a machine that reports no NUMA nodes at all. -fn numa_node_for(topology: &Topology, processors: &ProcessorSet) -> Option { +fn numa_node_for(topology: &MachineMemoryTopology, processors: &ProcessorSet) -> Option { topology .domains .iter() @@ -100,7 +103,7 @@ fn numa_node_for(topology: &Topology, processors: &ProcessorSet) -> Option /// A NUMA node other than `local`, for the sample's `--placement remote` /// switch -- deliberately the wrong node, so the buffer-placement effect /// (M7.4) is measurable rather than assumed. -pub fn remote_numa_node(topology: &Topology, local: Option) -> Option { +pub fn remote_numa_node(topology: &MachineMemoryTopology, local: Option) -> Option { topology .domains .iter() diff --git a/crates/windows-ioring-sys/examples/ring_copy/policy.rs b/crates/windows-ioring-sys/examples/ring_copy/policy.rs index 4254e208..4e3ff5e1 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/policy.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/policy.rs @@ -1,7 +1,7 @@ // Copyright (c) 2026 Mike Grier //! Policy -> domain selection (M7.1): named code, not data. -use windows_topology_sys::{Domain, DomainKind, ProcessorSet, Topology}; +use windows_topology_sys::{Domain, DomainKind, MachineMemoryTopology, ProcessorSet}; /// How to partition the machine into `IoRing` execution domains (M7.1). /// @@ -48,7 +48,7 @@ impl Policy { /// domains, generalized to every policy here (M7.5 depends on knowing /// when this happened, to report it honestly rather than silently). #[must_use] - pub fn select(self, topology: &Topology) -> (Vec, bool) { + pub fn select(self, topology: &MachineMemoryTopology) -> (Vec, bool) { let matched: Vec = match self { Self::Single => Vec::new(), Self::ByL3 => topology diff --git a/crates/windows-ioring-sys/src/lib.rs b/crates/windows-ioring-sys/src/lib.rs index 06955bc3..333815ae 100644 --- a/crates/windows-ioring-sys/src/lib.rs +++ b/crates/windows-ioring-sys/src/lib.rs @@ -111,7 +111,7 @@ //! construction is also the expensive one. "Durability on the ring" in //! `DESIGN-NOTES.md` has the full shape and the three ways to pay for it. //! -//! # Topology guidance +//! # MachineMemoryTopology guidance //! //! This crate does not partition anything for you (D-8 in `DESIGN-NOTES.md`): //! it makes a ring cheap and correct, makes its affinity explicit, and leaves diff --git a/crates/windows-placement-probe/src/bin/placement_probe/main.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs index 5d391250..5ea7b379 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/main.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -20,7 +20,7 @@ use windows_placement_probe::fingerprint::{Fingerprint, places_from_topology}; use windows_placement_probe::machine::MachineDescription; use windows_placement_probe::record::SubmissionRecord; use windows_placement_probe::submission::{self, DISCUSSION_URL}; -use windows_topology_sys::Topology; +use windows_topology_sys::MachineMemoryTopology; /// What the run was asked to do. struct Options { @@ -85,11 +85,11 @@ fn run(out: &mut impl Sink) -> ExitCode { let machine = MachineDescription::read(options.suppress_model); // **One discovery, two derivations.** The announced plan and the recorded - // fingerprint used to come from separate `Topology::discover()` calls, so a + // fingerprint used to come from separate `MachineMemoryTopology::discover()` calls, so a // processor going offline between them would have the notice describing one // machine and the record another, with nothing in the output saying which // was which. Both now come from this reading. - let topology = match Topology::discover() { + let topology = match MachineMemoryTopology::discover() { Ok(topology) => topology, Err(error) => { out.problem(&format!("could not read this machine's topology: {error}")); diff --git a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs index 45d32d7c..350a11c2 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs @@ -324,7 +324,7 @@ fn described() -> MachineDescription { } fn host() -> Fingerprint { - Fingerprint::from_topology(&windows_topology_sys::Topology::default()) + Fingerprint::from_topology(&windows_topology_sys::MachineMemoryTopology::default()) } #[test] diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index 91340b94..3ef7e423 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -79,7 +79,7 @@ use std::collections::BTreeMap; use std::io::ErrorKind; -use windows_topology_sys::Topology; +use windows_topology_sys::MachineMemoryTopology; use crate::fingerprint::{Fingerprint, ProcessorPlace, Slice, places_from_topology}; use crate::peer_index_cache::{ITEMS, Strategy, time_model_on, time_model_placed}; @@ -621,7 +621,7 @@ pub fn memory_placements(producer: ProcessorPlace, consumer: ProcessorPlace) -> /// /// # Errors /// -/// Returns whatever [`Topology::discover`] failed with, or +/// Returns whatever [`MachineMemoryTopology::discover`] failed with, or /// [`std::io::ErrorKind::InvalidData`] if the discovered topology leaves an /// online processor unplaced -- the same refusal /// [`crate::fingerprint::discover_places`] reports, reached the same way. @@ -630,7 +630,7 @@ pub fn measure() -> std::io::Result { // is the shape the rows were measured on. Calling `discover_places()` and // then reading the topology again would reintroduce, inside this function, // exactly the skew `Observation::host` exists to let the caller detect. - let topology = Topology::discover()?; + let topology = MachineMemoryTopology::discover()?; let processors = places_from_topology(&topology).map_err(|unplaceable| { std::io::Error::new(ErrorKind::InvalidData, unplaceable.to_string()) })?; diff --git a/crates/windows-placement-probe/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs index 0a981881..7a9e964b 100644 --- a/crates/windows-placement-probe/src/core_affinity/tests.rs +++ b/crates/windows-placement-probe/src/core_affinity/tests.rs @@ -9,7 +9,7 @@ use super::{Placement, RunPlan, classify, memory_placements, node_pairs, representative_pairs}; use crate::peer_index_cache::ITEMS; -use windows_topology_sys::Topology; +use windows_topology_sys::MachineMemoryTopology; use crate::fingerprint::ProcessorPlace; @@ -1098,7 +1098,7 @@ fn observation_of(rows: Vec) -> super::Observation { // These tests exercise row lookup, which never consults the host. A // bare topology is the smallest shape that is a real conversion rather // than a hand-built `Fingerprint` literal. - host: crate::fingerprint::Fingerprint::from_topology(&Topology::default()), + host: crate::fingerprint::Fingerprint::from_topology(&MachineMemoryTopology::default()), processors: Vec::new(), by_class: Vec::new(), measurements: Vec::new(), diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index c4a65ee1..b16b5b89 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -76,7 +76,7 @@ use std::fmt; -use windows_topology_sys::{DomainKind, Provenance, Topology}; +use windows_topology_sys::{DomainKind, MachineMemoryTopology, Provenance}; /// One logical processor's position in the machine. /// @@ -331,9 +331,9 @@ impl Fingerprint { /// /// # Errors /// - /// Returns whatever [`Topology::discover`] failed with. + /// Returns whatever [`MachineMemoryTopology::discover`] failed with. pub fn discover() -> std::io::Result { - Ok(Self::from_topology(&Topology::discover()?)) + Ok(Self::from_topology(&MachineMemoryTopology::discover()?)) } /// Read a shape from any topology, discovered or not. @@ -343,7 +343,7 @@ impl Fingerprint { /// from is what the fingerprint reports, and there is no path here that /// invents the answer. #[must_use] - pub fn from_topology(topology: &Topology) -> Self { + pub fn from_topology(topology: &MachineMemoryTopology) -> Self { let cores: Vec<_> = topology.cores().collect(); // Read off the processor list, not off core-domain membership, and with // the same `online` filter `places_from_topology` applies -- so the @@ -386,7 +386,7 @@ impl Fingerprint { efficiency_classes.sort_unstable(); // The outermost level that actually divides the machine, asked of the - // topology rather than recomputed here: `Topology` owns that rule, and + // topology rather than recomputed here: `MachineMemoryTopology` owns that rule, and // a second statement of it drifts. It also deduplicates a level // reported once per cache -- an L1 arriving as separate `data` and // `instruction` domains over the same processors is two relationships @@ -492,14 +492,14 @@ impl fmt::Display for Fingerprint { /// /// # Errors /// -/// Returns whatever [`Topology::discover`] failed with, or +/// Returns whatever [`MachineMemoryTopology::discover`] failed with, or /// [`ErrorKind::InvalidData`](std::io::ErrorKind::InvalidData) if the discovered /// topology names memory domains but leaves an online processor out of all of /// them. Discovery has never produced that, and it would mean the topology /// crate's parse had regressed rather than that the machine is unusual -- which /// is worth saying out loud rather than papering over with a fabricated node. pub fn discover_places() -> std::io::Result> { - places_from_topology(&Topology::discover()?).map_err(|unplaceable| { + places_from_topology(&MachineMemoryTopology::discover()?).map_err(|unplaceable| { std::io::Error::new(std::io::ErrorKind::InvalidData, unplaceable.to_string()) }) } @@ -526,7 +526,7 @@ pub enum MissingPlacement { /// No core domain covers it, though the topology names core domains. /// /// Covers the efficiency class too, and deliberately has no separate - /// variant for it: [`Topology::cores`] yields only `DomainKind::Core` + /// variant for it: [`MachineMemoryTopology::cores`] yields only `DomainKind::Core` /// domains, and every one of those carries a class, so a processor's core /// and its class are known or unknown together. A variant no input could /// produce would be dead public API. @@ -613,7 +613,9 @@ impl std::error::Error for UnplacedProcessor {} /// [`classify`](crate::core_affinity::classify) would then report a shared core, /// class or cache that is not there. A partial topology is a legitimate input to /// this seam (D-12), so it is refused rather than guessed at. -pub fn places_from_topology(topology: &Topology) -> Result, UnplacedProcessor> { +pub fn places_from_topology( + topology: &MachineMemoryTopology, +) -> Result, UnplacedProcessor> { // Every map here is keyed by the full `(group, number)` pair. Keying on the // number alone is the defect this function is written against: on a machine // with more than 64 logical processors each group numbers from zero, so @@ -639,7 +641,7 @@ pub fn places_from_topology(topology: &Topology) -> Result, } // The outermost cache level that actually divides the machine. This calls - // the same `Topology` method the fingerprint does, rather than repeating + // the same `MachineMemoryTopology` method the fingerprint does, rather than repeating // the rule, so the two cannot disagree about which level partitions the // host or about how many partitions it has. let mut cache_of = std::collections::BTreeMap::new(); diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index 76240f07..874def74 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -249,7 +249,7 @@ fn the_marker_is_the_only_difference_an_untrusted_host_renders() { #[test] fn a_fingerprint_read_from_this_machine_reports_itself_as_measured() { // Ties the rendering to the real path: `discover` goes through - // `Topology::discover`, which is the only thing entitled to claim the + // `MachineMemoryTopology::discover`, which is the only thing entitled to claim the // machine. If provenance ever stopped flowing, every probe banner would // quietly start printing a taint marker -- or worse, stop printing one. let fingerprint = Fingerprint::discover().expect("this machine must be discoverable"); @@ -260,10 +260,11 @@ fn a_fingerprint_read_from_this_machine_reports_itself_as_measured() { #[test] fn a_fingerprint_built_from_a_hand_made_topology_is_not_measured() { - // The path a synthetic host takes. `Topology::default` is untrusted by + // The path a synthetic host takes. `MachineMemoryTopology::default` is untrusted by // construction, and `from_topology` must carry that through rather than // inventing an answer. - let fingerprint = Fingerprint::from_topology(&windows_topology_sys::Topology::default()); + let fingerprint = + Fingerprint::from_topology(&windows_topology_sys::MachineMemoryTopology::default()); assert!(!fingerprint.provenance.is_measured()); assert!( @@ -304,7 +305,7 @@ fn the_banner_carries_whatever_the_fingerprint_says() { mod from_topology { use windows_topology_sys::{ - Domain, DomainKind, Processor, ProcessorId, ProcessorSet, Topology, + Domain, DomainKind, MachineMemoryTopology, Processor, ProcessorId, ProcessorSet, }; use crate::fingerprint::places_from_topology; @@ -320,7 +321,7 @@ mod from_topology { /// Assemble a topology from a list of cores, the way Windows would report /// one: a group domain, a core domain per core, a cache domain per distinct /// cache id, and a memory domain per distinct node. - fn topology_of(cores: &[CoreSpec]) -> Topology { + fn topology_of(cores: &[CoreSpec]) -> MachineMemoryTopology { let mut processors = Vec::new(); let mut domains = Vec::new(); let mut next_number = 0_u8; @@ -387,7 +388,7 @@ mod from_topology { }); } - Topology { + MachineMemoryTopology { processors, domains, distances: None, @@ -409,7 +410,7 @@ mod from_topology { } /// Two nodes, two cores each, two threads per core. - fn two_node_host() -> Topology { + fn two_node_host() -> MachineMemoryTopology { topology_of(&[ CoreSpec { efficiency_class: 0, @@ -562,7 +563,7 @@ mod from_topology { #[test] fn a_synthetic_topology_drives_the_classifier_end_to_end() { - // The whole point of routing through `Topology`: selection now runs on + // The whole point of routing through `MachineMemoryTopology`: selection now runs on // positions the real conversion produced, not on positions a test // author assumed it would produce. use crate::core_affinity::{Placement, node_pairs, representative_pairs}; @@ -595,14 +596,14 @@ mod from_topology { mod multi_group_conversion { use windows_topology_sys::{ - Domain, DomainKind, Processor, ProcessorId, ProcessorSet, Topology, + Domain, DomainKind, MachineMemoryTopology, Processor, ProcessorId, ProcessorSet, }; use crate::fingerprint::{Fingerprint, MissingPlacement, places_from_topology}; /// One processor per core, four cores per group, two groups -- with the /// numbers overlapping, which is how Windows really presents it. - fn two_group_topology() -> Topology { + fn two_group_topology() -> MachineMemoryTopology { let mut processors = Vec::new(); let mut domains = Vec::new(); let mut core_id = 0_u32; @@ -655,7 +656,7 @@ mod multi_group_conversion { }); } - Topology { + MachineMemoryTopology { processors, domains, distances: None, @@ -719,10 +720,10 @@ mod multi_group_conversion { /// A topology whose only domain is the group: online processors, no core, /// no cache, and no memory domain at all. - fn bare_processors(count: u8) -> Topology { + fn bare_processors(count: u8) -> MachineMemoryTopology { let all: Vec = (0..count).collect(); let mask = all.iter().fold(0_usize, |mask, n| mask | (1 << n)); - Topology { + MachineMemoryTopology { processors: all .iter() .map(|&number| Processor { @@ -868,7 +869,7 @@ mod multi_group_conversion { // test below pins the false sharing that would otherwise be reported. /// `bare_processors`, plus a real core domain covering the given numbers. - fn with_core_domain(count: u8, id: u32, members: &[u8]) -> Topology { + fn with_core_domain(count: u8, id: u32, members: &[u8]) -> MachineMemoryTopology { let mut topology = bare_processors(count); let mask = members.iter().fold(0_usize, |m, n| m | (1 << n)); topology.domains.push(Domain { diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index c981d6fc..6fe85631 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -303,7 +303,7 @@ fn observation_on(host: Fingerprint) -> crate::core_affinity::Observation { /// The shape a four-processor bare topology produces, as a real conversion. fn measured_host() -> Fingerprint { - Fingerprint::from_topology(&windows_topology_sys::Topology::default()) + Fingerprint::from_topology(&windows_topology_sys::MachineMemoryTopology::default()) } #[test] diff --git a/crates/windows-platform-probes/src/bin/topology.rs b/crates/windows-platform-probes/src/bin/topology.rs index 3c4f5461..03c85787 100644 --- a/crates/windows-platform-probes/src/bin/topology.rs +++ b/crates/windows-platform-probes/src/bin/topology.rs @@ -34,7 +34,7 @@ fn render() -> String { let observation = match measure() { Ok(observation) => observation, Err(error) => { - let _ = writeln!(out, "Topology::discover failed: {error}"); + let _ = writeln!(out, "MachineMemoryTopology::discover failed: {error}"); let _ = writeln!( out, "(Reported rather than measured: a probe that cannot read its" diff --git a/crates/windows-platform-probes/src/topology.rs b/crates/windows-platform-probes/src/topology.rs index 574632da..ce880908 100644 --- a/crates/windows-platform-probes/src/topology.rs +++ b/crates/windows-platform-probes/src/topology.rs @@ -24,7 +24,7 @@ //! //! # It measures the shipping crate, deliberately //! -//! The parse comes from [`windows_topology_sys::Topology::discover`] rather +//! The parse comes from [`windows_topology_sys::MachineMemoryTopology::discover`] rather //! than from a reimplementation here, for the same reason the pool-growth probe //! uses the real thread-pool crate: a reimplementation would measure the //! reimplementation. The raw counters below are then read *independently* @@ -38,7 +38,7 @@ use windows_sys::Win32::System::Threading::{ GetNumaHighestNodeNumber, }; -use windows_topology_sys::{DomainKind, Topology}; +use windows_topology_sys::{DomainKind, MachineMemoryTopology}; /// One cache level, summarised across the machine. #[derive(Debug, Clone, PartialEq, Eq)] @@ -204,9 +204,9 @@ impl Observation { /// /// # Errors /// -/// Propagates a failure from [`Topology::discover`]. +/// Propagates a failure from [`MachineMemoryTopology::discover`]. pub fn measure() -> io::Result { - let topology = Topology::discover()?; + let topology = MachineMemoryTopology::discover()?; let online_processors = topology.processors.iter().filter(|p| p.online).count(); diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 9b819355..e40b04a6 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -30,14 +30,14 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-8 | **The JSON schema is explicitly not covered by this crate's semver contract**, following the precedent set by `windows-file-watcher`'s D-71 for its scenario schema. This is load-bearing rather than incidental: it is precisely what makes D-9's deferrals safe rather than merely convenient. A schema v2 when HMAT-class hardware matters is permitted, so the cost of *not* pre-building for it is bounded. The Rust API is covered by semver as always. | | D-9 | **Deliberately excluded, with reasons.** See the detail section below. **No work is scheduled against any of it** -- the absence of checklist items is intentional, not an oversight. | | D-10 | **The description is platform-neutral; platform constraints live in the planner.** A description sourced from Linux will have one group possibly containing more than 64 processors, which is unrepresentable as a Windows affinity mask. The schema does not enforce the Windows limit. A Windows planner consuming such a description must reject or split it rather than silently emitting an affinity mask that cannot exist. Keeping the constraint in the planner is what allows a description of a machine to be written on, and for, a different platform. | -| D-11 | **A `Memory` domain's `memory_bytes` is `Option`, not a bare `u64`, because Windows's own enumeration cannot report it.** `GetLogicalProcessorInformationEx`'s NUMA-node relationship carries a processor set and a node number, never a capacity; measuring node memory would mean a different API entirely. A `Topology` this crate discovers therefore always sets `memory_bytes: None` for every memory domain it produces from `RelationNumaNode`/`RelationNumaNodeEx`. Using `Some(0)` as a stand-in would be indistinguishable from "this node genuinely has no memory," which is exactly the CXL-expander case D-5 exists to represent honestly; `None` is the only choice that does not silently invent data. A hand-written or fed-in description may still supply a real value. | +| D-11 | **A `Memory` domain's `memory_bytes` is `Option`, not a bare `u64`, because Windows's own enumeration cannot report it.** `GetLogicalProcessorInformationEx`'s NUMA-node relationship carries a processor set and a node number, never a capacity; measuring node memory would mean a different API entirely. A `MachineMemoryTopology` this crate discovers therefore always sets `memory_bytes: None` for every memory domain it produces from `RelationNumaNode`/`RelationNumaNodeEx`. Using `Some(0)` as a stand-in would be indistinguishable from "this node genuinely has no memory," which is exactly the CXL-expander case D-5 exists to represent honestly; `None` is the only choice that does not silently invent data. A hand-written or fed-in description may still supply a real value. | | D-12 | **A topology carries its own provenance, and the untrusted value is the default.** This crate deliberately lets a topology be discovered, built by hand, or deserialized from a description written for a machine you do not have -- and until now the three were indistinguishable once built. [`Provenance`] is `Synthetic` by `Default`, so forgetting is safe and claiming is deliberate; only `discover` yields `Measured`; and deserialization can only ever *downgrade*, so a file cannot assert it is the machine you are on. | | D-13 | **Every `Option` in this crate must say *which* absence it means.** "Not observed", "observed and absent", and "a computed answer that is negative" are three different facts, and an `Option` spells all three identically. Each one is documented at its site, and no field may mean more than one. See the detail section below, which audits every `Option` the crate has. | -| D-14 | **Windows's `LastLevelCacheIndex` is not `Topology::outermost_partitioning_cache`, and neither is wrong.** Measured on the x64 development host: CPU Sets reports **one** LLC group over all sixteen processors, while the derivation reports **eight** partitions at L2. Windows names the *last* level; the derivation names the outermost level that *divides*. They answer different questions, so neither may be substituted for the other, and a consumer treating the CPU-set value as "the cache domain" would collapse eight groups into one on that machine. | +| D-14 | **Windows's `LastLevelCacheIndex` is not `MachineMemoryTopology::outermost_partitioning_cache`, and neither is wrong.** Measured on the x64 development host: CPU Sets reports **one** LLC group over all sixteen processors, while the derivation reports **eight** partitions at L2. Windows names the *last* level; the derivation names the outermost level that *divides*. They answer different questions, so neither may be substituted for the other, and a consumer treating the CPU-set value as "the cache domain" would collapse eight groups into one on that machine. | ## D-12: provenance, and why the default points at distrust -Three ways to obtain a `Topology` are supported on purpose, and the crate's own front page advertises +Three ways to obtain a `MachineMemoryTopology` are supported on purpose, and the crate's own front page advertises the third: "deserialize one from JSON written for a machine you do not have". That is a feature -- it is how a consumer tests against hardware it lacks, and this workspace needs it right now, because `probe-core-affinity` must exercise NUMA selection logic on hosts that have exactly one NUMA node. @@ -49,7 +49,7 @@ running on. Three decisions make the marker hard to lose. -**`Synthetic` is `Default`.** This is the load-bearing one. `Topology::default()`, +**`Synthetic` is `Default`.** This is the load-bearing one. `MachineMemoryTopology::default()`, `..Default::default()`, and every construction that simply does not think about provenance come out tainted. A caller must do work to claim data is real, rather than work to admit it is not. The reverse default would mean every forgetful construction silently asserts it read the machine -- which is @@ -108,11 +108,11 @@ argument carried up from "do not use a sentinel" to "say which absence you mean" | Site | Which absence | Notes | |---|---|---| -| `Topology::distances` | **not observed**, and unobservable here | Windows exposes no user-mode SLIT reader, so `discover` can never fill it. Populated only by a fed-in description. | -| `Topology::cpu_sets` | **not observed** | `Some(v)` means the CPU-set API answered, and `v` may legitimately be empty; `None` means nothing asked, which is what a hand-built or deserialized topology is. | +| `MachineMemoryTopology::distances` | **not observed**, and unobservable here | Windows exposes no user-mode SLIT reader, so `discover` can never fill it. Populated only by a fed-in description. | +| `MachineMemoryTopology::cpu_sets` | **not observed** | `Some(v)` means the CPU-set API answered, and `v` may legitimately be empty; `None` means nothing asked, which is what a hand-built or deserialized topology is. | | `DomainKind::Memory::memory_bytes` | **not observed** from `discover` | See below: a *description's* `None` is currently ambiguous, and that is the one gap this audit found. | -| `Topology::processor` | lookup miss | Ordinary "no such element", not a fact about the machine. | -| `Topology::outermost_partitioning_cache` | **negative result** (category 3) | `None` is the real answer "no level divides this machine", already documented as such. Not an absence, and must not be read as one. | +| `MachineMemoryTopology::processor` | lookup miss | Ordinary "no such element", not a fact about the machine. | +| `MachineMemoryTopology::outermost_partitioning_cache` | **negative result** (category 3) | `None` is the real answer "no level divides this machine", already documented as such. Not an absence, and must not be read as one. | ### The one gap this audit found @@ -139,7 +139,7 @@ sentinel and is the interim answer. ## D-14: Windows's last-level cache is a different question `SYSTEM_CPU_SET_INFORMATION::LastLevelCacheIndex` and -[`Topology::outermost_partitioning_cache`](crate::Topology::outermost_partitioning_cache) both look +[`MachineMemoryTopology::outermost_partitioning_cache`](crate::MachineMemoryTopology::outermost_partitioning_cache) both look like "which cache groups these processors", and they are not the same question. Measured on the x64 development host, sixteen processors: @@ -157,7 +157,7 @@ The consequence worth stating plainly: **a consumer must not substitute one for produces eight, and nothing about the value would have looked wrong. This is also the first concrete instance of two Win32 sources describing overlapping facts, which is -why `Topology::cpu_sets` is carried beside the domains rather than merged into them. Deciding what a +why `MachineMemoryTopology::cpu_sets` is carried beside the domains rather than merged into them. Deciding what a consumer should do when the two disagree -- as opposed to answering different questions, which is this case -- is `SH-16.13`. diff --git a/crates/windows-topology-sys/README.md b/crates/windows-topology-sys/README.md index 0a3430b3..4a4c7481 100644 --- a/crates/windows-topology-sys/README.md +++ b/crates/windows-topology-sys/README.md @@ -8,9 +8,9 @@ empty shell on other platforms. ## Example ```rust,no_run -use windows_topology_sys::Topology; +use windows_topology_sys::MachineMemoryTopology; -let topology = Topology::discover()?; +let topology = MachineMemoryTopology::discover()?; println!( "{} logical processor(s), {} domain(s)", topology.processors.len(), @@ -40,8 +40,8 @@ This crate does that walk once, safely, and hands back owned records. ## Scope -**What this is:** safe enumeration ([`Topology::discover`]), plus a plain-data -description ([`Topology`], [`Domain`]) that needs no Windows API to construct +**What this is:** safe enumeration ([`MachineMemoryTopology::discover`]), plus a plain-data +description ([`MachineMemoryTopology`], [`Domain`]) that needs no Windows API to construct -- build one by hand, or (with the `serde` feature) deserialize one from JSON written for a machine you do not have. @@ -62,8 +62,8 @@ Run `cargo run --example print_topology --features serde` to see the host's own topology as JSON -- the shape a hand-written or synthetic description takes. -[`Topology::discover`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.Topology.html#method.discover -[`Topology`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.Topology.html +[`MachineMemoryTopology::discover`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.MachineMemoryTopology.html#method.discover +[`MachineMemoryTopology`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.MachineMemoryTopology.html [`Domain`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.Domain.html ## License diff --git a/crates/windows-topology-sys/examples/print_topology.rs b/crates/windows-topology-sys/examples/print_topology.rs index 135763b6..010fb4cc 100644 --- a/crates/windows-topology-sys/examples/print_topology.rs +++ b/crates/windows-topology-sys/examples/print_topology.rs @@ -10,10 +10,10 @@ //! cargo run --example print_topology --features serde //! ``` -use windows_topology_sys::Topology; +use windows_topology_sys::MachineMemoryTopology; fn main() { - let topology = Topology::discover().expect("discover the host topology"); + let topology = MachineMemoryTopology::discover().expect("discover the host topology"); let json = serde_json::to_string_pretty(&topology).expect("serialize"); println!("{json}"); } diff --git a/crates/windows-topology-sys/src/cpu_set/tests.rs b/crates/windows-topology-sys/src/cpu_set/tests.rs index 66b6c7a1..ec6a84da 100644 --- a/crates/windows-topology-sys/src/cpu_set/tests.rs +++ b/crates/windows-topology-sys/src/cpu_set/tests.rs @@ -214,7 +214,7 @@ fn windows_llc_grouping_is_not_the_derived_partitioning_cache() { // known, Windows's LLC grouping is never finer than the derived one, since // the last level is at or outside whatever level first divides the machine. let records = enumerate().expect("cpu sets"); - let topo = crate::Topology::discover().expect("discover"); + let topo = crate::MachineMemoryTopology::discover().expect("discover"); let mut llc: Vec = records.iter().map(|r| r.last_level_cache_index).collect(); llc.sort_unstable(); diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index a15c2876..8481f4ac 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -181,7 +181,7 @@ pub struct Domain { /// Deliberately not the HMAT attributed-relation model (per-initiator, /// per-target read/write latency and bandwidth): that was considered and /// declined for now, see D-9 in `DESIGN-NOTES.md`. Windows exposes no -/// user-mode SLIT reader, so a [`crate::Topology`] this crate discovers never +/// user-mode SLIT reader, so a [`crate::MachineMemoryTopology`] this crate discovers never /// populates this; it exists for a fed-in description sourced from a system /// that does report it. #[derive(Clone, Debug, PartialEq, Eq)] @@ -193,7 +193,7 @@ pub struct Distances { /// open (D-4). pub over: String, /// The distance matrix, in the order those domains appear in - /// [`crate::Topology::domains`] filtered to `over`. Square; + /// [`crate::MachineMemoryTopology::domains`] filtered to `over`. Square; /// `matrix[i][i]` is conventionally `10`, Windows's and ACPI SLIT's own /// "local" value. pub matrix: Vec>, diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 913b0c81..7531c017 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -6,10 +6,10 @@ //! //! Two things, deliberately separated: //! -//! - **[`Topology::discover`]** reads the running system's processor groups, +//! - **[`MachineMemoryTopology::discover`]** reads the running system's processor groups, //! cores, caches, and NUMA nodes safely, via //! [`GetLogicalProcessorInformationEx`][gpi]. -//! - **[`Topology`]**, [`Domain`], and friends are plain data. They do not +//! - **[`MachineMemoryTopology`]**, [`Domain`], and friends are plain data. They do not //! need Windows to construct: build one by hand, or (with the `serde` //! feature) deserialize one from JSON written for a machine you do not //! have. See [`examples/print_topology.rs`] for the shape a description @@ -44,13 +44,13 @@ //! # Availability //! //! `GetLogicalProcessorInformationEx` is documented back to Windows Vista / -//! Server 2008, so [`Topology::discover`] works on every version this +//! Server 2008, so [`MachineMemoryTopology::discover`] works on every version this //! repository's shared baseline supports; nothing here is gated on a runtime //! capability probe the way `windows-ioring-sys` needs one. //! //! # The JSON schema is not semver-covered //! -//! With the `serde` feature, [`Topology`] and [`Domain`] serialize to and +//! With the `serde` feature, [`MachineMemoryTopology`] and [`Domain`] serialize to and //! deserialize from a JSON shape documented on [`Domain`] itself. That shape //! is **not** covered by this crate's semver contract (D-8 in //! `DESIGN-NOTES.md`), even though the Rust types that produce it are, as @@ -87,4 +87,4 @@ pub use relation::{ Relations, discover, }; #[cfg(windows)] -pub use topology::Topology; +pub use topology::MachineMemoryTopology; diff --git a/crates/windows-topology-sys/src/provenance.rs b/crates/windows-topology-sys/src/provenance.rs index d538649f..1a454aa2 100644 --- a/crates/windows-topology-sys/src/provenance.rs +++ b/crates/windows-topology-sys/src/provenance.rs @@ -3,7 +3,7 @@ use std::fmt; -/// Where a [`Topology`](crate::Topology)'s content came from. +/// Where a [`MachineMemoryTopology`](crate::MachineMemoryTopology)'s content came from. /// /// # Why this exists /// @@ -28,7 +28,7 @@ use std::fmt; /// /// # The default is the untrusted value, on purpose /// -/// [`Self::Synthetic`] is [`Default`], so a `Topology` built by +/// [`Self::Synthetic`] is [`Default`], so a `MachineMemoryTopology` built by /// [`Default::default`], completed with `..Default::default()`, or otherwise /// assembled without a thought about provenance comes out **tainted**. A caller /// must do work to claim data is real, rather than work to admit it is not. @@ -55,7 +55,7 @@ pub enum Provenance { /// machine -- but not necessarily *this* one, and nothing in the file can /// establish which. Restored, - /// Read from the running system by [`Topology::discover`](crate::Topology::discover). + /// Read from the running system by [`MachineMemoryTopology::discover`](crate::MachineMemoryTopology::discover). /// The only variant that asserts "this is the machine you are on". Measured, } @@ -104,7 +104,7 @@ impl fmt::Display for Provenance { /// Deserialize a provenance, refusing any claim above [`Provenance::Restored`]. /// -/// Wired onto [`Topology::provenance`](crate::Topology::provenance) so the rule +/// Wired onto [`MachineMemoryTopology::provenance`](crate::MachineMemoryTopology::provenance) so the rule /// holds for every description, including one hand-edited to claim it was /// measured. /// diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index f47cea19..07739d8b 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -1,5 +1,5 @@ // Copyright (c) 2026 Mike Grier -//! Assembling a [`Topology`] from discovered relations. +//! Assembling a [`MachineMemoryTopology`] from discovered relations. use std::io; @@ -11,7 +11,7 @@ use crate::relation::{self, Relations}; /// A processor, cache, and memory topology: a set of processors and the /// domains that relate them. /// -/// Built either by [`Topology::discover`] from the running system, by hand, +/// Built either by [`MachineMemoryTopology::discover`] from the running system, by hand, /// or (with the `serde` feature) by deserializing a fed-in description. /// /// The JSON shape this produces and accepts is explicitly not covered by this @@ -19,7 +19,7 @@ use crate::relation::{self, Relations}; /// `DESIGN-NOTES.md`. #[derive(Clone, Debug, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Topology { +pub struct MachineMemoryTopology { /// Every logical processor, including one for each inactive slot up to a /// group's maximum processor count. pub processors: Vec, @@ -68,7 +68,7 @@ pub struct Topology { pub provenance: Provenance, } -impl Topology { +impl MachineMemoryTopology { /// Discover the running system's topology. /// /// # Errors @@ -335,7 +335,7 @@ impl Topology { /// which is what the measured case needs (L1i and L1d cover identical /// sets). That alone does **not** make the result a partition: two distinct /// sets can still overlap. Real hardware does not do this, but a - /// `Topology` is deliberately constructible by hand and by deserialization + /// `MachineMemoryTopology` is deliberately constructible by hand and by deserialization /// (see [`Provenance`](crate::Provenance)), so this method cannot assume /// hardware produced it -- and a caller splitting work across overlapping /// "partitions" double-counts the processors in the intersection and diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index e29c327c..0cb76a61 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -4,9 +4,9 @@ use crate::domain::{Domain, DomainKind}; use crate::processor_set::ProcessorSet; use crate::relation::CacheKind; -fn synthetic() -> Topology { +fn synthetic() -> MachineMemoryTopology { let group0 = ProcessorSet::from_group_mask(0, 0b11); - Topology { + MachineMemoryTopology { processors: vec![ Processor { id: ProcessorId { @@ -137,7 +137,7 @@ fn processor_looks_up_by_id() { #[test] fn discover_succeeds_and_every_online_processor_is_in_some_group() { - let topo = Topology::discover().expect("discover"); + let topo = MachineMemoryTopology::discover().expect("discover"); assert!(!topo.processors.is_empty()); assert!(topo.groups().count() >= 1); @@ -155,7 +155,7 @@ fn discover_succeeds_and_every_online_processor_is_in_some_group() { #[test] fn discover_reports_a_processor_entry_for_every_slot_up_to_each_groups_maximum() { - let topo = Topology::discover().expect("discover"); + let topo = MachineMemoryTopology::discover().expect("discover"); let relations = crate::relation::discover().expect("discover relations"); let expected: usize = relations .groups @@ -183,19 +183,19 @@ mod serde_tests { // expect to match". Everything except the provenance must survive // verbatim, so this compares against the original with only that field // adjusted -- a second corruption would still fail here. - let topology = Topology::discover().expect("discover"); + let topology = MachineMemoryTopology::discover().expect("discover"); assert!( topology.provenance.is_measured(), "discover must claim the machine it read" ); let json = serde_json::to_string(&topology).expect("serialize"); - let back: Topology = serde_json::from_str(&json).expect("deserialize"); + let back: MachineMemoryTopology = serde_json::from_str(&json).expect("deserialize"); assert_eq!(back.provenance, Provenance::Restored); assert_eq!( back, - Topology { + MachineMemoryTopology { provenance: Provenance::Restored, ..topology } @@ -215,7 +215,7 @@ mod serde_tests { ], "distances": null }"#; - let topology: Topology = serde_json::from_str(json).expect("parse"); + let topology: MachineMemoryTopology = serde_json::from_str(json).expect("parse"); assert_eq!(topology.processors.len(), 2); assert_eq!(topology.groups().count(), 1); let memory: Vec<_> = topology.memory_domains().collect(); @@ -243,7 +243,7 @@ mod serde_tests { ], "distances": {"over": "memory", "matrix": [[10, 40], [40, 10]]} }"#; - let topology: Topology = serde_json::from_str(json).expect("parse"); + let topology: MachineMemoryTopology = serde_json::from_str(json).expect("parse"); assert_eq!(topology.memory_domains().count(), 2); assert!( topology.memory_domains().any(|d| d.processors.is_empty()), @@ -269,7 +269,7 @@ mod serde_tests { ], "distances": null }"#; - let error = serde_json::from_str::(json) + let error = serde_json::from_str::(json) .expect_err("processor number 100 is out of range"); assert!( error.to_string().contains("100"), @@ -289,10 +289,10 @@ fn a_hand_built_topology_is_not_measured() { #[test] fn a_defaulted_topology_is_not_measured() { - // `Topology::default()` is the easiest way to obtain one and must be the + // `MachineMemoryTopology::default()` is the easiest way to obtain one and must be the // safe one. If this ever reports measured, every forgetful construction in // every dependent silently starts asserting it read the machine. - let topology = Topology::default(); + let topology = MachineMemoryTopology::default(); assert_eq!(topology.provenance, Provenance::Synthetic); assert!(!topology.provenance.is_measured()); @@ -303,7 +303,7 @@ fn struct_update_syntax_from_default_stays_untrusted() { // `..Default::default()` is how a caller builds a topology while naming // only the fields they care about, and provenance is exactly the field // nobody thinks to name. - let topology = Topology { + let topology = MachineMemoryTopology { distances: None, cpu_sets: None, ..Default::default() @@ -316,7 +316,7 @@ fn struct_update_syntax_from_default_stays_untrusted() { mod serde_provenance { use super::*; - fn load(provenance_field: &str) -> Topology { + fn load(provenance_field: &str) -> MachineMemoryTopology { let json = format!(r#"{{"processors": [], "domains": [], "distances": null{provenance_field}}}"#); serde_json::from_str(&json).expect("the description must parse") @@ -372,7 +372,7 @@ mod serde_provenance { "the marker is not visible in the persisted form: {json}" ); - let reloaded: Topology = serde_json::from_str(&json).expect("must parse"); + let reloaded: MachineMemoryTopology = serde_json::from_str(&json).expect("must parse"); assert_eq!(reloaded.provenance, Provenance::Restored); assert!(!reloaded.provenance.is_measured()); } @@ -385,7 +385,7 @@ mod serde_provenance { measured.provenance = Provenance::Measured; let json = serde_json::to_string(&measured).expect("must serialize"); - let reloaded: Topology = serde_json::from_str(&json).expect("must parse"); + let reloaded: MachineMemoryTopology = serde_json::from_str(&json).expect("must parse"); assert_eq!(reloaded.processors, measured.processors); assert_eq!(reloaded.domains, measured.domains); @@ -470,7 +470,7 @@ fn heterogeneous_relations() -> (crate::relation::Relations, Vec) { #[test] fn each_processor_takes_the_efficiency_class_of_its_own_core() { let (relations, domains) = heterogeneous_relations(); - let processors = Topology::processors_from(&relations, &domains); + let processors = MachineMemoryTopology::processors_from(&relations, &domains); assert_eq!(processors.len(), 2); assert_eq!( @@ -491,7 +491,7 @@ fn a_processor_with_no_matching_core_domain_reports_no_capacity() { // class. Windows reports relations only for active processors, so this is // the inactive-slot path. let (relations, _) = heterogeneous_relations(); - let processors = Topology::processors_from(&relations, &[]); + let processors = MachineMemoryTopology::processors_from(&relations, &[]); assert_eq!(processors.len(), 2); for processor in &processors { @@ -538,7 +538,7 @@ fn an_offline_processor_reports_no_capacity_even_when_a_core_claims_it() { processors: both, }]; - let processors = Topology::processors_from(&relations, &domains); + let processors = MachineMemoryTopology::processors_from(&relations, &domains); assert!(processors[0].online); assert_eq!(processors[0].capacity, 7); @@ -555,7 +555,7 @@ fn an_offline_processor_reports_no_capacity_even_when_a_core_claims_it() { /// The split L1 is the point: Windows reports one relationship per *cache*, so /// a core contributes an L1 `data` domain **and** an L1 `instruction` domain /// covering exactly the same two processors. -fn split_l1_machine(cores: u32, last_level: u8) -> Topology { +fn split_l1_machine(cores: u32, last_level: u8) -> MachineMemoryTopology { let mut domains = Vec::new(); let mut all = 0usize; let mut id = 0u32; @@ -590,7 +590,7 @@ fn split_l1_machine(cores: u32, last_level: u8) -> Topology { processors: ProcessorSet::from_group_mask(0, all), }); - Topology { + MachineMemoryTopology { processors: Vec::new(), domains, distances: None, @@ -607,7 +607,7 @@ fn cache_levels_are_ascending_and_without_repeats() { #[test] fn cache_levels_are_empty_when_no_cache_is_reported() { - let topo = Topology { + let topo = MachineMemoryTopology { processors: Vec::new(), domains: Vec::new(), distances: None, @@ -693,7 +693,7 @@ fn a_machine_with_no_cache_at_all_has_no_partitioning_cache() { #[test] fn a_level_whose_domains_overlap_is_not_a_partition() { - // `Topology` is deliberately constructible by hand and by deserialization, + // `MachineMemoryTopology` is deliberately constructible by hand and by deserialization, // so `outermost_partitioning_cache` cannot assume hardware produced its // input. Two L2 domains that share processor 1 are distinct sets, so // deduplication keeps both -- and a caller told they are partitions places diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md index 702d9f4d..695b4b35 100644 --- a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -106,12 +106,12 @@ the answer today is **no**, there is a whole Win32 model unexposed. probes measure *cost per firmware-reported placement* (`core_affinity` times handoffs between pairs already classified from the topology), and the NUMA spikes infer *policy* -- first-touch versus creator affinity, per-volume versus per-file -- not structure. `Provenance` already has -a `Measured` variant, but it qualifies a whole `Topology`, not an individual relation. So the +a `Measured` variant, but it qualifies a whole `MachineMemoryTopology`, not an individual relation. So the capability question 2 describes is **new work, not a retrofit**, and the per-relation provenance it needs does not exist yet. **The "outermost partitioning cache" rule is stated three times, and two of the three -disagree.** `Topology::outermost_partitioning_cache` requires more than one partition **and** +disagree.** `MachineMemoryTopology::outermost_partitioning_cache` requires more than one partition **and** pairwise disjointness. `Observation::outermost_partitioning_cache` in `windows-platform-probes` is `caches.iter().filter(|c| c.domains > 1).max_by_key(|c| c.level)` -- no disjointness check -- over a `CacheLevel` summary it builds itself, even though that @@ -249,8 +249,8 @@ measure (expensive, on-machine), decide (no I/O) -- and something has to own the ### The canonical case, already half-built: NUMA distances -`Topology::distances: Option` exists, and every path sets it to `None`. -`Topology::discover` hardcodes `distances: None`; no consumer reads the field. Meanwhile +`MachineMemoryTopology::distances: Option` exists, and every path sets it to `None`. +`MachineMemoryTopology::discover` hardcodes `distances: None`; no consumer reads the field. Meanwhile **Win32 cannot supply it** -- ACPI carries SLIT, but no Win32 API surfaces node distances -- and `windows-placement-probe` **already measures the equivalent**, via `node_pairs_measured()`, producing per-node-pair handoff cost with ring placement and rendering it as a table. @@ -320,7 +320,7 @@ There are **two** things, and both are graphs of processors and their relations, calling both "topology" has been confusing: 1. **What the machine *is*.** Read from the Windows data model, plus whatever else is trivially - available. Observed, never chosen. This is today's `Topology`, and it is **mockable** -- a + available. Observed, never chosen. This is today's `MachineMemoryTopology`, and it is **mockable** -- a description of a machine nobody has is a first-class input, which is what makes the second component testable. @@ -365,7 +365,7 @@ measurement is the synthesizer's working state and its justification for a choic the machine. That would make the observed topology purely what Windows reports, and it would mean -`Topology::distances` is **deleted rather than filled** -- which is a cleaner answer than SH-16.11's, +`MachineMemoryTopology::distances` is **deleted rather than filled** -- which is a cleaner answer than SH-16.11's, and the opposite of what that item currently proposes. Flagged rather than acted on, because it reverses a conclusion this session reached earlier and should be confirmed before anything is removed. @@ -395,7 +395,7 @@ removed. The objection is half right, and the halves point at different files. **The base model hardcodes no level count, and that was deliberate.** `DomainKind::Cache` -carries `level: u8`; `Topology::cache_levels()` returns whatever the firmware reported, +carries `level: u8`; `MachineMemoryTopology::cache_levels()` returns whatever the firmware reported, sorted and deduplicated; `caches_at_level` takes any `u8`. There is already a regression test, `a_partitioning_cache_above_level_four_is_found`, whose comment reads: "`level` is a `u8`. A consumer sweeping a hard-coded `1..=4` reports this machine as having no @@ -414,7 +414,7 @@ So the crate named "topology" does model a network. The collapse is downstream o Three sites, in increasing severity: -1. **`Topology::outermost_partitioning_cache()`** -- selects exactly one level (outermost +1. **`MachineMemoryTopology::outermost_partitioning_cache()`** -- selects exactly one level (outermost first, requiring more than one pairwise-disjoint domain) and discards every other level. This one sits *inside* the topology crate, which is where the engineer's irony lands squarely: the crate offers a rich model and then a lossy convenience view that consumers From 6584ff9c98c47c975136462618f188edc886d06c Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 22:29:32 -0400 Subject: [PATCH 243/361] docs(topology): open the MMT plan to reshape the machine memory topology A fresh plan in the crate that owns the model, numbered MMT-* rather than continuing the release checklist's SH-*, per the engineer's direction to ignore the release plans and start clean. It supersedes six M16 items -- SH-16.5, 16.8, 16.9, 16.11, 16.12 and 16.13 -- which were all the same piece of work seen from different angles. Those are left in place, unchecked and marked, rather than deleted: each records how its defect was found, which the new plan does not repeat. Read the new plan for what to do and the old items for why. M1 is decision work and gates the rest. Five questions, each cheap to answer now and expensive to answer wrongly after code exists: whether several observations of one relation are a set or reduced on insert, what a query returns when two sources differ, what a consumer does with a fact never observed, whether distances survives at all, and whether the synthesizer lives in this crate. Two of those are sharper than they were this morning. D-14 found a third case for the disagreement question: CPU Sets reports one last-level-cache group where the derivation reports eight L2 partitions, and neither is wrong because they answer different questions -- so the model needs agreement, contradiction, and different-subject, and a design with only the first two will file the third as a conflict and teach a caller to distrust a correct answer. And the distances question now runs the other way from the release checklist's version: if the synthesizer measures for its own scenario, a measured number is its working state rather than a property of the machine, so the field may be deleted rather than filled. MMT-1.3 carries a cross-component prerequisite: it is the same decision as EP-1.4 in windows-execution-plan, seen from the model's side, and they must be taken together. M2 through M4 build the granularity order, per-relation provenance, and the pairwise queries the execution planner stated requirements against. M5 lists the defects the reshape subsumes, so they are fixed once rather than fixed and then re-fixed. Restored two pointers the previous checklist carried and this rewrite had dropped: the crate's original 2026-08-22 design session, and its archive. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 24 +++- crates/windows-topology-sys/CHECKLIST.md | 167 ++++++++++++++++++++++- crates/windows-topology-sys/PLANS.md | 7 +- 3 files changed, 183 insertions(+), 15 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 2ccc7e8d..d13ec633 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -25,7 +25,7 @@ merge that closes it, which is backwards. Only M1 through M6 are a sequence. | M7-M13 review rounds | **done, archived** | -- | | M14 ninth review round | 1 open | SH-14.1, the ABA defect; disclosed at SH-15.8, fix is M15 | | M15 the claim protocol | 5 open | SH-15.6 is the decision; gated on SH-15.5.1 | -| M16 tenth review round | 6 of 13 open | **gates the merge**; CPU Sets landed, 6 wait on the session | +| M16 tenth review round | 7 done, 6 superseded | its own findings are fixed; the model work moved to `MMT-*` | | M-inf parked | ungated | not scheduled, deliberately | **The critical path is M16's locality-model work -> SH-3.1.1 -> SH-3.4 -> M4.** M14 and M15 do not @@ -735,6 +735,16 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. ## M16: PR #56 tenth review round -- the SH-3.1.1 diff review +> **Six of these items are superseded.** SH-16.5, SH-16.8, SH-16.9, SH-16.11, SH-16.12 and SH-16.13 +> are all the same piece of work seen from different angles -- reshaping the machine memory topology +> -- and they now live in +> [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) as a plan of +> their own, numbered `MMT-*`. They are left here, unchecked and marked, rather than deleted: each +> records how the defect was *found*, which the new plan does not repeat. +> +> **Read the new plan for what to do; read these for why.** The six that remain live here are the +> review round's own findings, already fixed. + **This round is the one [SH-3.1.1](#m3-land-the-branch) asked for**, and it is the first that read the branch as a *diff* rather than reacting to a reviewer's comment. Five reviewers took non-overlapping crate scopes across all 200 changed files; seven findings came back, listed here worst-first rather @@ -809,7 +819,7 @@ predicted about a 222-commit branch. Fixed by dropping empty domains, with the contrast against `memory_domains` (which deliberately keeps a processor-less domain, D-5) recorded at the filter. -- [ ] **SH-16.5** -- **`windows-placement-probe` refuses a partially-covering cache level that +- [ ] **SH-16.5** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **`windows-placement-probe` refuses a partially-covering cache level that `windows-topology-sys` deliberately hands back.** `outermost_partitioning_cache` documents that "full coverage of the online processors is deliberately *not* required"; `places_from_topology` treats any online processor the chosen level does not name as `MissingPlacement::CacheDomain` and @@ -827,7 +837,7 @@ predicted about a 222-commit branch. sabotage-verified; it was reverted deliberately and preserved outside the repository as `sh-16.5-prototype.patch`. The contradiction is real and stays unfixed until the session concludes. -- [ ] **SH-16.8** -- **The locality model collapses a seven-kind, any-depth topology onto one cache +- [ ] **SH-16.8** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **The locality model collapses a seven-kind, any-depth topology onto one cache boundary, and nothing records that as a choice.** Raised by the engineer during the SH-16.5 fix, and confirmed: `windows-topology-sys` hardcodes no level count (`level` is a `u8`, and a regression test already guards against a consumer sweeping `1..=4`) and models `Group`, `Package`, `Die`, `Module`, @@ -852,7 +862,7 @@ predicted about a 222-commit branch. for a ladder of levels with optional rungs. That rules out the SH-16.5 prototype's `Unknown` arm, which merges both. Shape still open. -- [ ] **SH-16.9** -- **The "outermost partitioning cache" rule is stated three times, and two of the +- [ ] **SH-16.9** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **The "outermost partitioning cache" rule is stated three times, and two of the three disagree.** `MachineMemoryTopology::outermost_partitioning_cache` requires more than one partition **and** pairwise disjointness. `Observation::outermost_partitioning_cache` in `windows-platform-probes` is `caches.iter().filter(|c| c.domains > 1).max_by_key(|c| c.level)` -- **no disjointness check** -- @@ -914,7 +924,7 @@ predicted about a 222-commit branch. checks the decode is self-consistent, not that it matches the OS. Confirm against a parked processor or an explicit `SetProcessDefaultCpuSets` before relying on the flags. -- [ ] **SH-16.13** -- **Reconcile the CPU-set observation with the relationship walk.** `CoreIndex`, +- [ ] **SH-16.13** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **Reconcile the CPU-set observation with the relationship walk.** `CoreIndex`, `NumaNodeIndex` and `EfficiencyClass` **duplicate** facts `GetLogicalProcessorInformationEx` already reports, from a different kernel path -- so this is not redundancy to remove, it is a **second independent observer of the same relations**, and the two can disagree under a hypervisor @@ -931,7 +941,7 @@ predicted about a 222-commit branch. sentinel**, so it is a cleaner source for the field whose `capacity` encoding collides with "unknown". -- [ ] **SH-16.11** -- **`MachineMemoryTopology::distances` is a field for a fact Win32 cannot supply, it is never +- [ ] **SH-16.11** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **`MachineMemoryTopology::distances` is a field for a fact Win32 cannot supply, it is never populated, and the measurement that would fill it already exists elsewhere.** `discover()` hardcodes `distances: None`, every other construction sets `None`, and no consumer reads the field. Windows exposes no API for NUMA node distance -- ACPI carries SLIT, Win32 does not surface @@ -965,7 +975,7 @@ predicted about a 222-commit branch. THIS MACHINE". Take that measurement on multi-node hardware before reopening D-9 on asymmetry grounds, not after. -- [ ] **SH-16.12** -- **`Processor::capacity` uses `0` as both a legitimate efficiency class and a +- [ ] **SH-16.12** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **`Processor::capacity` uses `0` as both a legitimate efficiency class and a sentinel for "not known", and the two collide on the common case.** It is computed `online.then(|| find the owning Core domain).flatten().unwrap_or(0)`, so `0` means the processor is offline, *or* is online but named by no `Core` domain, *or* genuinely has efficiency class zero. diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 4cb8284c..393e9c25 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -1,7 +1,168 @@ -# Checklist: windows-topology-sys +# Checklist: reshaping the machine memory topology -Design decisions are in [DESIGN-NOTES.md](DESIGN-NOTES.md); the session that produced them is +A fresh plan, deliberately numbered `MMT-*` rather than continuing the release checklist's `SH-*`. +It supersedes the model items filed there during PR #56's tenth review round; those are marked and +point here. + +Design decisions live in [DESIGN-NOTES.md](DESIGN-NOTES.md). The session that produced this plan is +[DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md); +the consumer whose requirements shaped it is +[windows-execution-plan](../windows-execution-plan/DESIGN-NOTES.md). + +The crate's *original* design session, which produced the model this plan reshapes, is [DESIGN-SESSION-2026-08-22-topology-schema.md](design-sessions/DESIGN-SESSION-2026-08-22-topology-schema.md). Completed milestones are archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md). -No milestones are currently planned. +## What this is for + +The current model describes a machine as a list of domains, and answers questions about it with one +global projection (`outermost_partitioning_cache`) that three consumers have independently +re-derived, two of them differently. It cannot say whether a fact was observed or merely absent, it +cannot answer anything about a *pair* of processors, and it collapses a seven-kind, any-depth +locality graph onto a single cache boundary. + +The reshape has one governing idea, settled with the engineer: **model the observed connectivity.** +Presence and observation are facts to represent, not shapes to infer from. + +## Where this stands + +| Milestone | State | What it is waiting on | +|---|---|---| +| M1 settle what is still open | open | nothing -- these are decisions, and they gate the rest | +| M2 the granularity model | parked | M1 | +| M3 observation and provenance | parked | M1 | +| M4 the queries | parked | M2, M3 | +| M5 the defects this subsumes | parked | M4 | + +**M1 is decision work, not implementation.** Each item is a question the session left open, and each +would change the shape of everything below it. They are cheap to answer and expensive to answer +wrongly after code exists. + +## M1: settle what is still open + +- [ ] **MMT-1.1** -- **Are several observations of one relation held as a set, or reduced on insert + with the reduction recorded?** No longer speculative: `GetLogicalProcessorInformationEx` and + `GetSystemCpuSetInformation` both report a processor's core, NUMA node and efficiency class, from + different kernel paths, and both are read today. A set is honest and pushes adjudication onto every + caller; reducing on insert is convenient and throws away the disagreement, which is the one thing a + second observer is for. + +- [ ] **MMT-1.2** -- **What a query returns when observations differ -- and there are three cases, + not two.** [D-14](DESIGN-NOTES.md#d-14) found the third: CPU Sets reports one last-level-cache + group where the derivation reports eight L2 partitions, and neither is wrong because they answer + **different questions**. So the model must distinguish *agreement*, *contradiction*, and + *different subject* -- and a design that only has the first two will file the third as a conflict + and teach a caller to distrust a correct answer. + +- [ ] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that + the model answers without further measurement. Degrade to a documented weaker policy, refuse, or + answer with an explicit "chosen without knowing X" marker. + > **-> CROSS-COMPONENT PREREQUISITE:** this is the same decision as `EP-1.4` in + > [windows-execution-plan](../windows-execution-plan/CHECKLIST.md), seen from the model's side + > rather than the consumer's. They were filed independently before anyone noticed. **Take them + > together** -- answering either alone risks a planner that degrades in a way the model does not + > support, or a model offering a fallback no consumer wants. + +- [ ] **MMT-1.4** -- **Does `distances` survive at all?** The two-component architecture says the + *synthesizer* measures, with the caller's permission, for its own scenario -- so a measured number + is its working state and its justification for a choice, not a property of the machine. That + reverses this session's earlier conclusion that measured facts must live in the model, which + assumed a single component. If it holds, `distances` is **deleted rather than filled**, which is + the opposite of what the release checklist proposed. Decide before removing anything. + +- [ ] **MMT-1.5** -- **Does the synthesizer live in this crate, and therefore what is this crate + called?** Recorded as open rather than settled: see + [windows-execution-plan/COMPONENT.md](../windows-execution-plan/COMPONENT.md). The naming follows + the merge rather than leading it -- while this crate is only a Win32 wrapper, `-sys` is correct + for it; if it gains a synthesizer that measures, it stops being one and the name should change + then. + +## M2: the granularity model + +Parked on M1. Shape recorded so it is not lost. + +- [ ] **M2+.1** -- Model **observed sharing relations**, not a ladder of levels with optional rungs. + A machine with no L3 has no L3 relation, which is an observation rather than a missing value. + +- [ ] **M2+.2** -- Derive the order from **observed set inclusion**, never from firmware level + numbers. Inclusion is checkable; numbering is asserted, and this crate has been bitten by asserted + structure before -- the ARM64 host with no L3, and the guard test against a consumer sweeping + `1..=4`. + +- [ ] **M2+.3** -- Give the order an explicit **top** ("the machine"), so a pairwise query is total. + Two processors always share one address space, one scheduler and one memory system; without a top, + every caller writes the same empty-case branch for a cross-node pair. + +- [ ] **M2+.4** -- Represent **incomparable** granularities. An inclusion order is partial, so two + granularities may not nest, and the honest answer to "tightest shared" is then a set of minimal + elements -- almost always one, but not by construction. + +- [ ] **M2+.5** -- Make absence first-class per [D-13](DESIGN-NOTES.md#d-13): **not observed**, + **observed and absent**, and **a negative result** are three different facts that an `Option` + spells identically. + +## M3: observation and provenance + +Parked on M1. + +- [ ] **M3+.1** -- Provenance is **per relation**, not per source. Per-relation subsumes per-source + by repetition, and the reverse fails on the case that matters: two sources describing the *same* + relation. + +- [ ] **M3+.2** -- Keep two properties of the old `Provenance` **because they re-derive**, not + because they were there: the default is the untrusted value (a *stronger* argument per-relation, + since there are more places to forget), and trust never upgrades (a file still cannot establish it + describes the machine you are on). + +- [ ] **M3+.3** -- Supersede the whole-object `Provenance` **without replacing it with another + whole-object scalar**. With trust per relation, an object-level scalar can only be the minimum -- + ninety-nine measured relations and one synthetic reading `SYNTHETIC` -- or the maximum, which is + dishonest. Trust belongs to an *answer*. + +- [ ] **M3+.4** -- Carry both observers without merging, per MMT-1.1's decision. `Topology::cpu_sets` + already lands this way; this item is whether that stays a parallel list or becomes observations + attached to relations. + +## M4: the queries + +Parked on M2 and M3. Each is a requirement from +[windows-execution-plan](../windows-execution-plan/DESIGN-NOTES.md), stated there against a real +caller rather than invented here. + +- [ ] **M4+.1** -- **Pairwise proximity**, over an **unordered** pair, returning the minimal shared + granularities, **their membership** (so a caller can size an MPSC fan-in without re-deriving the + grouping), and whether a finer granularity went **unobserved** so the answer can be an upper bound + and say so. This is the query with no equivalent today, and its absence is why the partitioning + rule got re-derived three times. + +- [ ] **M4+.2** -- The **shard-set** surface (EP-D-1): identity as `(group, number)`, online, core + membership and SMT, efficiency class **without a sentinel**, and availability. + +- [ ] **M4+.3** -- **Residency** (EP-D-3): processor to memory domain, with the unplaced case + distinguishable rather than defaulted -- an unknown cache domain costs an optimisation, an unknown + memory domain has no honest fallback. + +- [ ] **M4+.4** -- Reduce `outermost_partitioning_cache` to a **named projection** over the order -- + "the coarsest granularity with more than one group" -- so it is a query rather than a rule, and + cannot be restated wrongly because there is nothing to restate. + +## M5: the defects this subsumes + +Parked on M4. Each already exists as a defect; the reshape is what fixes them, so they are listed +here rather than fixed separately and then re-fixed. + +- [ ] **M5+.1** -- `Processor::capacity` uses `0` as both a legitimate efficiency class and a "not + known" sentinel, and the two collide on **every non-hybrid machine**. Worse than an ambiguous + `Option`: a colliding sentinel cannot be distinguished even by a careful caller. + +- [ ] **M5+.2** -- `DomainKind::Memory::memory_bytes` is unambiguous from `discover` but ambiguous + from a **description**, where "the field was omitted" and "this node's capacity is unknown" are the + same value. The [D-13](DESIGN-NOTES.md#d-13) audit found this and documentation cannot fix it. + +- [ ] **M5+.3** -- The partitioning rule is stated **three times in two crates**, and two of the + three differ: `windows-platform-probes` omits the pairwise-disjointness check this crate requires. + M4+.4 removes the reason to restate it. + +- [ ] **M5+.4** -- `windows-placement-probe` **refuses a partially-covering cache level** that this + crate deliberately hands back, failing an entire measurement run over a topology this crate + considers describable. M2+.5 gives it the vocabulary to accept one. diff --git a/crates/windows-topology-sys/PLANS.md b/crates/windows-topology-sys/PLANS.md index b979b89d..662cd37d 100644 --- a/crates/windows-topology-sys/PLANS.md +++ b/crates/windows-topology-sys/PLANS.md @@ -1,10 +1,7 @@ # Plans: windows-topology-sys -Completed checklists are recorded in [COMPLETED-PLANS.md](COMPLETED-PLANS.md), and the milestones they -contained are archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md). Design decisions are in -[DESIGN-NOTES.md](DESIGN-NOTES.md). - | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| +| [CHECKLIST.md](CHECKLIST.md) | in progress | **Reshaping the machine memory topology**, numbered `MMT-*` and deliberately distinct from the release checklist's `SH-*`, six of whose items it supersedes. The governing idea, settled with the engineer: model the **observed connectivity** rather than a ladder of levels with optional rungs, and treat presence and observation as facts to represent rather than shapes to infer from. M1 is decision work and gates everything -- whether several observations of one relation are held as a set or reduced on insert, what a query returns when two sources differ (three cases, not two: D-14 found that CPU Sets and the derivation can differ by answering *different questions*), what a consumer does with a fact that was never observed, whether `distances` survives at all now that the synthesizer measures for its own scenario, and whether that synthesizer lives in this crate -- which decides the crate's name, since `-sys` is right only while this is purely a Win32 wrapper. M2 through M4 build the granularity order, per-relation provenance, and the pairwise queries the execution planner stated requirements for. M5 lists the defects the reshape subsumes rather than fixing them twice. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | -No checklists are currently in progress. +Completed plans are in [COMPLETED-PLANS.md](COMPLETED-PLANS.md). From a2edb9a6b3cb1297d588c7305a844a0cef2aeb39 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 22:42:41 -0400 Subject: [PATCH 244/361] docs(topology): decide MMT-1.1 on measurement -- a set, by way of membership identity The question was whether several observations of one relation are held as a set or reduced on insert. Stated abstractly the two look balanced: a set is honest and pushes adjudication onto every caller, a reduction is convenient and throws away the disagreement. That framing is what the question sat on. Measured it instead. Compared as partitions rather than as labels, the two Win32 sources agree exactly on the core partition -- eight groups each -- and label it completely differently: [0, 2, 4, ..., 14] against [0, 1, ..., 7]. CPU Sets numbers a core by its first logical processor; the relationship walk numbers domains in discovery order. Neither is wrong, and neither is a claim about the machine. So the sources agree on the facts and disagree on the names, and that decides it by a different argument than the item expected. A relation is identified by (kind, membership); a source's label is an attribute of the observation, not of the relation. Two sources producing the same membership have observed one relation twice. Reduce-on-insert is then not merely lossy but arbitrary: its job would be to resolve a conflict, and the only conflict present is between labelling schemes where both labels are correct and picking one is a coin toss -- while the fact that mattered needed no reduction at all. And a set costs nothing in the common case, because agreement is one relation with two observations rather than two competing relations. It also disposes of D-14's third case, which MMT-1.2 was going to have to invent vocabulary for. Under membership identity, different memberships at different kinds are different relations, so they never meet to disagree. The case dissolves rather than being handled. What remains for MMT-1.2 is narrower and genuinely a contradiction: two sources claiming the same kind over overlapping-but-unequal memberships -- and the detection machinery already exists, since that is exactly what are_pairwise_disjoint checks for cache domains today. Recorded honestly about the evidence: only the core comparison is strong. NUMA is one group on this host so it matches under almost any bug, and efficiency class is zero everywhere -- which is both trivially matchable and the exact value Processor::capacity's sentinel is indistinguishable from, so that row confirms nothing about either source. A hybrid, multi-node machine would test all three; none is available here. The probe that produced this was temporary and is removed; 125 tests pass. Completed item: MMT-1.1: Are several observations of one relation held as a set, or reduced on insert with the reduction recorded? Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 37 +++++++--- crates/windows-topology-sys/DESIGN-NOTES.md | 77 +++++++++++++++++++++ 2 files changed, 105 insertions(+), 9 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 393e9c25..ee4e1082 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -28,7 +28,7 @@ Presence and observation are facts to represent, not shapes to infer from. | Milestone | State | What it is waiting on | |---|---|---| -| M1 settle what is still open | open | nothing -- these are decisions, and they gate the rest | +| M1 settle what is still open | 1 of 5 done | nothing -- these are decisions, and they gate the rest | | M2 the granularity model | parked | M1 | | M3 observation and provenance | parked | M1 | | M4 the queries | parked | M2, M3 | @@ -40,19 +40,38 @@ wrongly after code exists. ## M1: settle what is still open -- [ ] **MMT-1.1** -- **Are several observations of one relation held as a set, or reduced on insert +- [x] **MMT-1.1** -- **Are several observations of one relation held as a set, or reduced on insert with the reduction recorded?** No longer speculative: `GetLogicalProcessorInformationEx` and `GetSystemCpuSetInformation` both report a processor's core, NUMA node and efficiency class, from different kernel paths, and both are read today. A set is honest and pushes adjudication onto every caller; reducing on insert is convenient and throws away the disagreement, which is the one thing a second observer is for. - -- [ ] **MMT-1.2** -- **What a query returns when observations differ -- and there are three cases, - not two.** [D-14](DESIGN-NOTES.md#d-14) found the third: CPU Sets reports one last-level-cache - group where the derivation reports eight L2 partitions, and neither is wrong because they answer - **different questions**. So the model must distinguish *agreement*, *contradiction*, and - *different subject* -- and a design that only has the first two will file the third as a conflict - and teach a caller to distrust a correct answer. + **Done, as [D-15](DESIGN-NOTES.md#d-15): a set -- but the reason is not the one above.** Measured + rather than argued. The two sources **agree exactly** on the core partition (eight groups each) and + **label it completely differently** (`[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]`). So the + disagreement a reduction would resolve is between *dictionaries*, not about the machine. + That makes **a relation identified by `(kind, membership)`**, with a source's label an attribute of + the *observation*. Reduce-on-insert is then not merely lossy but **arbitrary** -- it would pick + between two correct labels by coin toss, while the fact that mattered needed no reduction because + the sources agreed. And a set costs nothing in the common case: agreement is one relation with two + observations, not two competing relations. + **Honest about the evidence:** only the core comparison is strong. NUMA is one group here so it + matches under almost any bug, and efficiency class is zero everywhere -- which is both trivially + matchable and the exact value `Processor::capacity`'s sentinel is indistinguishable from, so that + row confirms nothing. A hybrid, multi-node machine would test all three; none is available. + +- [ ] **MMT-1.2** -- **What a query returns when observations differ.** ~~And there are three cases, + not two~~ -- **the third case dissolved.** [D-14](DESIGN-NOTES.md#d-14) found that CPU Sets reports + one last-level-cache group where the derivation reports eight L2 partitions, neither wrong because + they answer **different questions**, and this item was going to have to invent vocabulary for it. + [D-15](DESIGN-NOTES.md#d-15) removes the need: under `(kind, membership)` identity, different + memberships at different kinds are simply **different relations**, so they never meet to disagree. + **What remains is narrower**: two sources claiming the same *kind* over overlapping-but-unequal + memberships -- a real contradiction about the machine. Decide what a query returns then: a value + plus a conflict marker, or the conflict itself, forcing the caller to adjudicate. + Note the detection machinery already exists. Overlapping-but-unequal sets at one kind is exactly + what `are_pairwise_disjoint` checks for cache domains today; generalising it from "cache levels" to + "any kind" is the whole of the check. - [ ] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that the model answers without further measurement. Degrade to a documented weaker policy, refuse, or diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index e40b04a6..5a67c3e2 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -34,6 +34,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-12 | **A topology carries its own provenance, and the untrusted value is the default.** This crate deliberately lets a topology be discovered, built by hand, or deserialized from a description written for a machine you do not have -- and until now the three were indistinguishable once built. [`Provenance`] is `Synthetic` by `Default`, so forgetting is safe and claiming is deliberate; only `discover` yields `Measured`; and deserialization can only ever *downgrade*, so a file cannot assert it is the machine you are on. | | D-13 | **Every `Option` in this crate must say *which* absence it means.** "Not observed", "observed and absent", and "a computed answer that is negative" are three different facts, and an `Option` spells all three identically. Each one is documented at its site, and no field may mean more than one. See the detail section below, which audits every `Option` the crate has. | | D-14 | **Windows's `LastLevelCacheIndex` is not `MachineMemoryTopology::outermost_partitioning_cache`, and neither is wrong.** Measured on the x64 development host: CPU Sets reports **one** LLC group over all sixteen processors, while the derivation reports **eight** partitions at L2. Windows names the *last* level; the derivation names the outermost level that *divides*. They answer different questions, so neither may be substituted for the other, and a consumer treating the CPU-set value as "the cache domain" would collapse eight groups into one on that machine. | +| D-15 | **A relation is identified by its `(kind, membership)`, not by any source's label -- so several observations of one relation are held as a *set*, never reduced on insert.** Measured, not assumed: the two Win32 sources agree exactly on the core partition (eight groups each) while labelling it completely differently (`[0,2,4,...,14]` against `[0,1,...,7]`). The "disagreement" a reduction would resolve is between *dictionaries*, not about the machine, and reducing would have to pick a label arbitrarily while discarding the other source's. Under membership identity the common case costs nothing -- one relation, two observations -- and a genuine contradiction stays representable. See the detail section below. | ## D-12: provenance, and why the default points at distrust @@ -165,6 +166,82 @@ Kept honest by a test that asserts the *relationship* rather than this host's nu grouping is never finer than the derived one, because the last level is at or outside whatever level first divides the machine. +## D-15: a relation is its membership, and observations are a set + +*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.1.* + +### The question, and why it looked balanced + +Two Win32 sources now report overlapping facts about the same processors: the relationship walk and +the CPU-set enumeration both describe a processor's core, its NUMA node and its efficiency class, +from different kernel paths. So the model must decide whether several observations of one relation +are **held as a set** or **reduced on insert** with the reduction recorded. + +Stated abstractly the two look balanced. A set is honest and pushes adjudication onto every caller; +a reduction is convenient and throws away the disagreement, which is the one thing a second observer +is for. That framing is what the question sat on for most of a day. + +### What measurement showed + +Compared on the x64 development host, as partitions rather than as labels: + +| | agreement | strength of the evidence | +|---|---|---| +| core partition | **identical**, eight groups each | **strong** -- eight non-trivial groups matched exactly | +| core *labels* | **completely different**: `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]` | -- | +| NUMA partition | identical, one group | weak: a single group matches trivially | +| efficiency class | identical, all zero | weak: all zero, which is the very value `Processor::capacity`'s sentinel collides with | + +**The two sources agree on the facts and disagree on the names.** CPU Sets numbers a core by its +first logical processor; the relationship walk numbers domains in discovery order. Neither is wrong, +and neither is a claim about the machine. + +### What follows + +**A relation is identified by `(kind, membership)`.** Which processors are grouped is the +observation; what a source calls that group is an attribute *of the observation*, not of the +relation. Two sources producing the same membership have observed **one** relation twice. + +That settles the question, and not by the argument the checklist item expected: + +- **Reduce-on-insert is not merely lossy, it is arbitrary.** Its job would be to resolve a conflict, + and the only conflict present is between labelling schemes -- where both labels are correct and + picking one is a coin toss. Meanwhile the fact that mattered needed no reduction at all, because + the sources agreed on it. +- **A set costs nothing in the common case.** Agreement means one relation carrying two observations, + not two competing relations. The feared duplication does not materialise where the sources agree, + which is the usual case. +- **And a genuine contradiction stays representable**: two sources claiming the same *kind* for + overlapping-but-unequal memberships. That is a real disagreement about the machine, and it is + exactly what a reduction would have hidden. + +### It also disposes of D-14's third case + +[D-14](#d-14) found that CPU Sets' `LastLevelCacheIndex` and the derived partitioning cache differ -- +one group against eight -- without either being wrong, because they answer different questions. That +looked like it would need a third state beside "agree" and "contradict". + +Under membership identity it needs nothing. Different memberships at different kinds are **different +relations**, so they never meet to disagree. The third case dissolves rather than being handled, +which is a better outcome than the vocabulary MMT-1.2 was going to have to invent. + +### The machinery for detecting contradiction already exists + +Overlapping-but-unequal sets *at the same kind* is precisely what +`MachineMemoryTopology::are_pairwise_disjoint` already checks for cache domains, and what +[D-5](#d-5)-era work established as the shape of a corrupt or hand-built topology. Generalising it +from "cache levels" to "any kind" is the whole of the contradiction check, rather than new +machinery. + +### What this does not establish + +The core comparison is strong; the other two are not, and saying so matters more than the headline. +The NUMA partition is one group on this host, so it would match under almost any bug. Efficiency +class is zero everywhere, which is both trivially matchable *and* the exact value +`Processor::capacity`'s sentinel is indistinguishable from -- so that row confirms nothing about +either source. A hybrid, multi-node machine would test all three properly, and none is available +here. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was From 6b1f762b031b1940f30c598c17d16182204c761a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 22:43:03 -0400 Subject: [PATCH 245/361] chore(topology): restore LF endings in DESIGN-NOTES.md --- .../windows-topology-sys/DESIGN-NOTES.md.bak | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 crates/windows-topology-sys/DESIGN-NOTES.md.bak diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md.bak b/crates/windows-topology-sys/DESIGN-NOTES.md.bak new file mode 100644 index 00000000..5a67c3e2 --- /dev/null +++ b/crates/windows-topology-sys/DESIGN-NOTES.md.bak @@ -0,0 +1,291 @@ +# Design notes: windows-topology-sys (Tier 1) + +This crate does not exist yet as compiled code. This file, the checklist beside it, and the design session +it references are the design record that precedes it. Creating the Cargo skeleton is M1.1 in +[CHECKLIST.md](CHECKLIST.md). + +## Intent + +Safe enumeration of the running system's processor, cache, and memory topology, plus a +JSON-serializable description of it that can be persisted, hand-written, or fed in from another machine. + +It exists to serve [windows-ioring-sys](../windows-ioring-sys/DESIGN-NOTES.md)'s locality story without +that crate having to own a partitioning policy (its D-8), but it is not specific to it: the description is +the input to a policy, and the policy is somebody else's code. + +Same philosophy as the rest of this repository. Raise a Win32 primitive into memory-safe Rust at minimum +additional CPU and memory cost; do not solve the consumer's architecture for them. + +## Decision index + +| ID | Decision | +|---|---| +| D-1 | **This crate exists because the `windows` crate does not provide memory safety here, only typed FFI.** Verified rather than assumed: `windows` 0.61.1 exposes `pub unsafe fn GetLogicalProcessorInformationEx(relationshiptype, buffer: Option<*mut SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX>, returnedlength: *mut u32) -> windows_core::Result<()>`. Every real hazard stays with the caller, and they are worse than usual: two-call sizing against `ERROR_INSUFFICIENT_BUFFER`; a **walk-by-`Size`** over variable-length records, where treating the buffer as a `&[T]` silently misparses; **trailing arrays that lie in the type system** (`PROCESSOR_RELATIONSHIP.GroupMask` is declared `[GROUP_AFFINITY; 1]` but is actually `GroupCount` long, and `NUMA_NODE_RELATIONSHIP` / `CACHE_RELATIONSHIP` do the same through a union); and an undiscriminated union keyed by `Relationship`. Reading past element 0 of that array is exactly what the API requires and exactly what Rust calls undefined behavior. | +| D-2 | **Shape: safe enumeration, not a topology renderer.** Enumeration is durable -- `GetLogicalProcessorInformationEx` gains relations rather than changing shape -- while *interpretation* (what constitutes a "domain" worth partitioning by) is exactly what CXL and further chiplet federation will churn. The durable thing belongs in the crate and the volatile thing above it. A consumer wanting an opinionated model builds it from these records; this crate does not ship one. | +| D-3 | **`ProcessorSet` is the one abstraction added above faithful records, because it is where the bugs actually are.** Handing back raw `usize` masks would be safe FFI rather than a safe API: callers assume a single processor group, assume 64 or fewer processors, and lose the group when they flatten to an index. `ProcessorSet` carries `(group, mask)` correctly and spans groups. Everything else stays a faithful mirror of what Win32 reported. | +| D-4 | **Domains are open-kinded rather than a fixed set of relation types.** Forced by the Linux cross-check (see the design session): Linux models `die`, `cluster`, and on s390x `book` and `drawer`, none of which Windows reports and none of which cache domains reliably approximate -- Zen 2 had two L3 domains per die, so cache and die genuinely differ. Enumerating every level any architecture will ever have is a losing game, so a domain is `{ kind, id, processors, ...kind-specific attributes }` with well-known kinds documented rather than closed. The cost is honest: a policy filters `kind == "cache" && level == 3` instead of reading a typed `topology.caches`, which is weaker typing at the schema boundary in exchange for surviving hardware nobody has shipped yet. Rust-side typed accessors over the open representation keep the ergonomic loss confined to the JSON. | +| D-5 | **A NUMA node is modelled as a memory domain that may contain no processors, not as a processor grouping that happens to have memory.** Linux exposes `has_cpu` / `has_memory` per node precisely because memory-only nodes are now ordinary: CXL memory expanders, persistent memory in system-RAM mode, HBM tiers, and GPU-attached memory on coherent parts all appear that way. An earlier draft of this schema defined a node by its processor list, which makes a CXL expander a degenerate case rather than a first-class one -- backwards for the hardware direction this repository expects. This is the correction the Linux comparison was most valuable for, because Windows' own model is currently impoverished here and looking only at Windows would have missed it. | +| D-6 | **Domains reference processors; they do not nest.** No hierarchy is imposed, so the schema never asserts that packages contain nodes or that nodes contain cache domains. Chiplets and CXL already violate those assumptions, and Linux's own levels do not form a strict hierarchy either -- clusters cut across cache domains on some ARM parts. Refusing to impose one is what lets a synthetic description describe a machine whose shape we have not seen. | +| D-7 | **A processor is identified by `(group, number)`, never by a flat index.** The Windows processor group is the hard affinity boundary -- a thread's affinity is a `GROUP_AFFINITY` and cannot span groups -- so flattening destroys the one constraint a planner must respect. A description sourced from a system without groups simply has one group, which is lossless in that direction. | +| D-8 | **The JSON schema is explicitly not covered by this crate's semver contract**, following the precedent set by `windows-file-watcher`'s D-71 for its scenario schema. This is load-bearing rather than incidental: it is precisely what makes D-9's deferrals safe rather than merely convenient. A schema v2 when HMAT-class hardware matters is permitted, so the cost of *not* pre-building for it is bounded. The Rust API is covered by semver as always. | +| D-9 | **Deliberately excluded, with reasons.** See the detail section below. **No work is scheduled against any of it** -- the absence of checklist items is intentional, not an oversight. | +| D-10 | **The description is platform-neutral; platform constraints live in the planner.** A description sourced from Linux will have one group possibly containing more than 64 processors, which is unrepresentable as a Windows affinity mask. The schema does not enforce the Windows limit. A Windows planner consuming such a description must reject or split it rather than silently emitting an affinity mask that cannot exist. Keeping the constraint in the planner is what allows a description of a machine to be written on, and for, a different platform. | +| D-11 | **A `Memory` domain's `memory_bytes` is `Option`, not a bare `u64`, because Windows's own enumeration cannot report it.** `GetLogicalProcessorInformationEx`'s NUMA-node relationship carries a processor set and a node number, never a capacity; measuring node memory would mean a different API entirely. A `MachineMemoryTopology` this crate discovers therefore always sets `memory_bytes: None` for every memory domain it produces from `RelationNumaNode`/`RelationNumaNodeEx`. Using `Some(0)` as a stand-in would be indistinguishable from "this node genuinely has no memory," which is exactly the CXL-expander case D-5 exists to represent honestly; `None` is the only choice that does not silently invent data. A hand-written or fed-in description may still supply a real value. | +| D-12 | **A topology carries its own provenance, and the untrusted value is the default.** This crate deliberately lets a topology be discovered, built by hand, or deserialized from a description written for a machine you do not have -- and until now the three were indistinguishable once built. [`Provenance`] is `Synthetic` by `Default`, so forgetting is safe and claiming is deliberate; only `discover` yields `Measured`; and deserialization can only ever *downgrade*, so a file cannot assert it is the machine you are on. | +| D-13 | **Every `Option` in this crate must say *which* absence it means.** "Not observed", "observed and absent", and "a computed answer that is negative" are three different facts, and an `Option` spells all three identically. Each one is documented at its site, and no field may mean more than one. See the detail section below, which audits every `Option` the crate has. | +| D-14 | **Windows's `LastLevelCacheIndex` is not `MachineMemoryTopology::outermost_partitioning_cache`, and neither is wrong.** Measured on the x64 development host: CPU Sets reports **one** LLC group over all sixteen processors, while the derivation reports **eight** partitions at L2. Windows names the *last* level; the derivation names the outermost level that *divides*. They answer different questions, so neither may be substituted for the other, and a consumer treating the CPU-set value as "the cache domain" would collapse eight groups into one on that machine. | +| D-15 | **A relation is identified by its `(kind, membership)`, not by any source's label -- so several observations of one relation are held as a *set*, never reduced on insert.** Measured, not assumed: the two Win32 sources agree exactly on the core partition (eight groups each) while labelling it completely differently (`[0,2,4,...,14]` against `[0,1,...,7]`). The "disagreement" a reduction would resolve is between *dictionaries*, not about the machine, and reducing would have to pick a label arbitrarily while discarding the other source's. Under membership identity the common case costs nothing -- one relation, two observations -- and a genuine contradiction stays representable. See the detail section below. | + +## D-12: provenance, and why the default points at distrust + +Three ways to obtain a `MachineMemoryTopology` are supported on purpose, and the crate's own front page advertises +the third: "deserialize one from JSON written for a machine you do not have". That is a feature -- it +is how a consumer tests against hardware it lacks, and this workspace needs it right now, because +`probe-core-affinity` must exercise NUMA selection logic on hosts that have exactly one NUMA node. + +The hazard is that **the resulting value looked exactly like a discovered one**. There is a passing +test in this crate that parses a *Linux-shaped* description, complete with an ACPI SLIT-style distance +matrix, on a Windows-only crate. Nothing downstream could tell that apart from the machine it was +running on. + +Three decisions make the marker hard to lose. + +**`Synthetic` is `Default`.** This is the load-bearing one. `MachineMemoryTopology::default()`, +`..Default::default()`, and every construction that simply does not think about provenance come out +tainted. A caller must do work to claim data is real, rather than work to admit it is not. The reverse +default would mean every forgetful construction silently asserts it read the machine -- which is +precisely the accident this exists to catch, and it would be catastrophically quiet. + +**The variants are ordered by trust**, `Synthetic < Restored < Measured`, so the derived `Ord` *is* the +trust order and `min` implements "never upgrade". `downgraded_to` is that one line, which is why there +is no second, subtly different rule anywhere: a ceiling is a maximum, not an assignment, so passing a +synthetic description through a loader does not launder it into a restored one. + +**Deserialization refuses any claim above `Restored`.** A hand-edited `"provenance": "measured"` is +ignored. This is the one place forgery rather than accident is refused, and the asymmetry is +deliberate: a line of code claiming `Measured` had to be written by someone who meant it, whereas a +JSON file is data that travels, gets copied between machines, and is edited by people who never read +this note. The marker is still *serialized*, so it is visible in the persisted form -- the goal is that +a tainted topology is loud, not that it is unwritable. + +The consequence to be aware of: **a measured topology cannot be archived and reloaded as measured.** +That is intended. What you reload is a description of a machine, and the fact that it was once read +from a real one does not make it a statement about the host doing the reading. + +**The threat model is accident, not forgery.** A caller who writes `provenance: Provenance::Measured` +over data they fabricated has lied deliberately, and no type in a crate with public fields prevents +that. Adding a private field with a constructor was considered and rejected: it would break the +hand-construction the crate deliberately supports (D-8's "plain data" property), for a guarantee that +only holds against an adversary this crate does not have. + +`from_relations` stamps `Synthetic` and `discover` overwrites it with `Measured`, rather than the +transform claiming it. `from_relations` is a pure function of whatever relations it is handed and +cannot know where they came from; putting the claim in `discover` keeps it attached to the act of +asking the operating system, so a future second caller of the transform does not silently inherit an +assertion it has not earned. + +## D-13: which absence an `Option` means + +An `Option` is three different facts wearing one shape, and this crate carries all three: + +1. **Not observed.** Nothing asked. The value may exist on this machine; we did not look, or there + is no way to look. +2. **Observed and absent.** Something asked, and the answer was that there is none. +3. **A negative result.** Not an absence at all: a computed answer whose value happens to be "no". + +A consumer that cannot tell (1) from (2) will eventually read one as the other, and the failure is +silent both ways -- treating "we did not look" as "there is none" invents a fact, and treating +"there is none" as "we did not look" sends a caller off to re-derive something already settled. + +**The rule: every `Option` documents which of the three it means, at its site, and no single +`Option` may mean more than one.** Where a field would otherwise have to mean two, that is the +signal to change the representation rather than to write a longer comment. + +This is the same reasoning [D-11](#d-11) already applied to `memory_bytes`, one level down: `Some(0)` +was rejected there because a sentinel would be indistinguishable from a real value. D-13 is that +argument carried up from "do not use a sentinel" to "say which absence you mean". + +### The audit + +| Site | Which absence | Notes | +|---|---|---| +| `MachineMemoryTopology::distances` | **not observed**, and unobservable here | Windows exposes no user-mode SLIT reader, so `discover` can never fill it. Populated only by a fed-in description. | +| `MachineMemoryTopology::cpu_sets` | **not observed** | `Some(v)` means the CPU-set API answered, and `v` may legitimately be empty; `None` means nothing asked, which is what a hand-built or deserialized topology is. | +| `DomainKind::Memory::memory_bytes` | **not observed** from `discover` | See below: a *description's* `None` is currently ambiguous, and that is the one gap this audit found. | +| `MachineMemoryTopology::processor` | lookup miss | Ordinary "no such element", not a fact about the machine. | +| `MachineMemoryTopology::outermost_partitioning_cache` | **negative result** (category 3) | `None` is the real answer "no level divides this machine", already documented as such. Not an absence, and must not be read as one. | + +### The one gap this audit found + +`memory_bytes` is unambiguous from `discover`, which always sets `None` for the reason D-11 gives. +It is **ambiguous from a description**: a description that omits the field and a description +written for a node whose capacity is genuinely unknown produce the same `None`, and nothing +distinguishes them. + +That is not fixable by documentation, because the two really are the same value today. It is fixed +by the representation, which is the subject of the open locality-model work -- see `SH-16.8` in +[CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md), where absence +becomes first-class rather than a shape. Recorded here so the gap is not rediscovered, and queued +there so it is not merely recorded. + +### Where this crate already got it wrong + +`Processor::capacity` is the counter-example, and it is worse than an ambiguous `Option`: it is a +**sentinel that collides with a legitimate value**. `0` means offline, *or* in no core domain, *or* +efficiency class zero -- and the third is every processor on every non-hybrid machine. A careful +caller cannot distinguish them at all, where an ambiguous `Option` at least admits it is absent. +Tracked as `SH-16.12`. `DomainKind::Core { efficiency_class }` carries the same fact with no +sentinel and is the interim answer. + +## D-14: Windows's last-level cache is a different question + +`SYSTEM_CPU_SET_INFORMATION::LastLevelCacheIndex` and +[`MachineMemoryTopology::outermost_partitioning_cache`](crate::MachineMemoryTopology::outermost_partitioning_cache) both look +like "which cache groups these processors", and they are not the same question. + +Measured on the x64 development host, sixteen processors: + +- CPU Sets reports **one** distinct `LastLevelCacheIndex`. That is the L3, which spans the machine. +- `outermost_partitioning_cache` reports **eight** partitions, at L2. + +Both are correct. Windows names the **last** level in the hierarchy, whether or not it divides +anything; the derivation names the outermost level that **does** divide, which is what a caller +sharding work needs and is why it exists. On a machine whose last level is shared by everything, +those answers differ by the whole width of the machine. + +The consequence worth stating plainly: **a consumer must not substitute one for the other.** Reading +`LastLevelCacheIndex` as "the cache domain" would have produced one shard group where the derivation +produces eight, and nothing about the value would have looked wrong. + +This is also the first concrete instance of two Win32 sources describing overlapping facts, which is +why `MachineMemoryTopology::cpu_sets` is carried beside the domains rather than merged into them. Deciding what a +consumer should do when the two disagree -- as opposed to answering different questions, which is +this case -- is `SH-16.13`. + +Kept honest by a test that asserts the *relationship* rather than this host's numbers: Windows's +grouping is never finer than the derived one, because the last level is at or outside whatever level +first divides the machine. + +## D-15: a relation is its membership, and observations are a set + +*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.1.* + +### The question, and why it looked balanced + +Two Win32 sources now report overlapping facts about the same processors: the relationship walk and +the CPU-set enumeration both describe a processor's core, its NUMA node and its efficiency class, +from different kernel paths. So the model must decide whether several observations of one relation +are **held as a set** or **reduced on insert** with the reduction recorded. + +Stated abstractly the two look balanced. A set is honest and pushes adjudication onto every caller; +a reduction is convenient and throws away the disagreement, which is the one thing a second observer +is for. That framing is what the question sat on for most of a day. + +### What measurement showed + +Compared on the x64 development host, as partitions rather than as labels: + +| | agreement | strength of the evidence | +|---|---|---| +| core partition | **identical**, eight groups each | **strong** -- eight non-trivial groups matched exactly | +| core *labels* | **completely different**: `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]` | -- | +| NUMA partition | identical, one group | weak: a single group matches trivially | +| efficiency class | identical, all zero | weak: all zero, which is the very value `Processor::capacity`'s sentinel collides with | + +**The two sources agree on the facts and disagree on the names.** CPU Sets numbers a core by its +first logical processor; the relationship walk numbers domains in discovery order. Neither is wrong, +and neither is a claim about the machine. + +### What follows + +**A relation is identified by `(kind, membership)`.** Which processors are grouped is the +observation; what a source calls that group is an attribute *of the observation*, not of the +relation. Two sources producing the same membership have observed **one** relation twice. + +That settles the question, and not by the argument the checklist item expected: + +- **Reduce-on-insert is not merely lossy, it is arbitrary.** Its job would be to resolve a conflict, + and the only conflict present is between labelling schemes -- where both labels are correct and + picking one is a coin toss. Meanwhile the fact that mattered needed no reduction at all, because + the sources agreed on it. +- **A set costs nothing in the common case.** Agreement means one relation carrying two observations, + not two competing relations. The feared duplication does not materialise where the sources agree, + which is the usual case. +- **And a genuine contradiction stays representable**: two sources claiming the same *kind* for + overlapping-but-unequal memberships. That is a real disagreement about the machine, and it is + exactly what a reduction would have hidden. + +### It also disposes of D-14's third case + +[D-14](#d-14) found that CPU Sets' `LastLevelCacheIndex` and the derived partitioning cache differ -- +one group against eight -- without either being wrong, because they answer different questions. That +looked like it would need a third state beside "agree" and "contradict". + +Under membership identity it needs nothing. Different memberships at different kinds are **different +relations**, so they never meet to disagree. The third case dissolves rather than being handled, +which is a better outcome than the vocabulary MMT-1.2 was going to have to invent. + +### The machinery for detecting contradiction already exists + +Overlapping-but-unequal sets *at the same kind* is precisely what +`MachineMemoryTopology::are_pairwise_disjoint` already checks for cache domains, and what +[D-5](#d-5)-era work established as the shape of a corrupt or hand-built topology. Generalising it +from "cache levels" to "any kind" is the whole of the contradiction check, rather than new +machinery. + +### What this does not establish + +The core comparison is strong; the other two are not, and saying so matters more than the headline. +The NUMA partition is one group on this host, so it would match under almost any bug. Efficiency +class is zero everywhere, which is both trivially matchable *and* the exact value +`Processor::capacity`'s sentinel is indistinguishable from -- so that row confirms nothing about +either source. A hybrid, multi-node machine would test all three properly, and none is available +here. + +## What was deliberately excluded (D-9) + +Recorded because what a design declines is as important as what it adopts, and because each of these was +considered and rejected rather than overlooked. Each entry states what would justify revisiting it. + +**HMAT-style attributed relations.** ACPI's Heterogeneous Memory Attribute Table supersedes SLIT, giving +per-initiator/per-target read and write latency and bandwidth -- four numbers where SLIT gives one scalar -- +and Linux already exposes it. A general edge list (`{ from, to, read_latency_ns, read_bandwidth_mbps, ... }`) +would absorb HMAT, asymmetry, and multi-hop CXL fabrics; the scalar distance matrix this schema keeps will +not. That was raised as an argument for building the edge list now, on the grounds that retrofitting it is +a breaking schema change. **Deferred anyway, and the deferral is safe because of D-8:** the schema carries +no stability promise, so a v2 is permitted. The trade taken is a simpler, hand-writable description now +against a schema break later. *Revisit when:* tiered-memory hardware is in scope for a consumer, or a +scalar distance demonstrably mismodels a machine somebody is tuning for. + +**Devices as topology participants and as initiators.** HMAT models initiators separately from targets +because GPUs, DMA-capable NICs, and NVMe controllers all initiate memory access, and for an I/O-focused +consumer the device is the locality question. Naming devices in the description would let a synthetic +topology drive ring planning against a device layout Windows cannot easily enumerate anyway (the +handle-to-device-node walk goes through SetupAPI/CfgMgr with real failure modes on spanned volumes, +Storage Spaces, network paths, and VHDs). **Excluded as scope, by the engineer's direction:** it changes +the crate's identity from processor topology to system topology, which is a materially larger surface than +the name implies and a larger promise than is wanted now. *Revisit when:* a consumer needs device-aligned +planning badly enough to accept that surface, at which point the crate probably wants a different name. + +**Queue and interrupt affinity.** NVMe queue-pair to CPU mapping is the mechanism by which +submission-core and completion-core locality actually happens, and it is what would let a plan align rings +with hardware queues. **Excluded:** it is downstream of devices being representable at all, so it cannot +precede the previous entry. + +**Power and thermal domains, cache partitioning (Intel RDT/CAT, ARM MPAM), and +confidential-computing memory-encryption domains.** All are real partitioning concepts that could map onto +ring assignment. **Excluded:** none of them is locality, each needs its own vocabulary, and D-4's open +domain kinds mean adding any of them later is additive rather than breaking -- so there is no cost to +waiting and no benefit to guessing at their shape now. + +**Memory tiering abstract distance.** Linux's tiering model assigns nodes an abstract distance for +promotion and demotion decisions. **Excluded** for the same reason as HMAT, and it would arrive with it. + +## What the Linux comparison established + +The cross-check was run to find future expansion directions rather than to validate the schema, and it did +both. Details are in the design session; the summary is that three things in the then-current draft were +genuinely violated -- memory-only nodes (D-5), fixed domain kinds (D-4), and missing online/offline state +-- while three decisions held up unchanged: processor identity as `(group, number)` (D-7), +reference-don't-nest (D-6), and treating distances as optional, which Linux vindicated by actually having +SLIT where Windows does not. From d8d065432d6ba1fd41a34b6f69a68c619398214b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 22:43:33 -0400 Subject: [PATCH 246/361] chore: ignore tpu-mcp .bak files, and untrack one that slipped in --- .gitignore | 6 + .../windows-topology-sys/DESIGN-NOTES.md.bak | 291 ------------------ 2 files changed, 6 insertions(+), 291 deletions(-) delete mode 100644 crates/windows-topology-sys/DESIGN-NOTES.md.bak diff --git a/.gitignore b/.gitignore index 4a56b3e7..f1a2d501 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ target placement-probe-v*.json .vs .vscode/settings.json + +# tpu-mcp writes .bak beside any file it repairs. Those are transient +# backups of a repair that has already been verified and committed, so they are +# never wanted in history -- and being adjacent to the file they back up, they +# are easy to sweep up with `git add -A` without noticing. +*.bak diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md.bak b/crates/windows-topology-sys/DESIGN-NOTES.md.bak deleted file mode 100644 index 5a67c3e2..00000000 --- a/crates/windows-topology-sys/DESIGN-NOTES.md.bak +++ /dev/null @@ -1,291 +0,0 @@ -# Design notes: windows-topology-sys (Tier 1) - -This crate does not exist yet as compiled code. This file, the checklist beside it, and the design session -it references are the design record that precedes it. Creating the Cargo skeleton is M1.1 in -[CHECKLIST.md](CHECKLIST.md). - -## Intent - -Safe enumeration of the running system's processor, cache, and memory topology, plus a -JSON-serializable description of it that can be persisted, hand-written, or fed in from another machine. - -It exists to serve [windows-ioring-sys](../windows-ioring-sys/DESIGN-NOTES.md)'s locality story without -that crate having to own a partitioning policy (its D-8), but it is not specific to it: the description is -the input to a policy, and the policy is somebody else's code. - -Same philosophy as the rest of this repository. Raise a Win32 primitive into memory-safe Rust at minimum -additional CPU and memory cost; do not solve the consumer's architecture for them. - -## Decision index - -| ID | Decision | -|---|---| -| D-1 | **This crate exists because the `windows` crate does not provide memory safety here, only typed FFI.** Verified rather than assumed: `windows` 0.61.1 exposes `pub unsafe fn GetLogicalProcessorInformationEx(relationshiptype, buffer: Option<*mut SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX>, returnedlength: *mut u32) -> windows_core::Result<()>`. Every real hazard stays with the caller, and they are worse than usual: two-call sizing against `ERROR_INSUFFICIENT_BUFFER`; a **walk-by-`Size`** over variable-length records, where treating the buffer as a `&[T]` silently misparses; **trailing arrays that lie in the type system** (`PROCESSOR_RELATIONSHIP.GroupMask` is declared `[GROUP_AFFINITY; 1]` but is actually `GroupCount` long, and `NUMA_NODE_RELATIONSHIP` / `CACHE_RELATIONSHIP` do the same through a union); and an undiscriminated union keyed by `Relationship`. Reading past element 0 of that array is exactly what the API requires and exactly what Rust calls undefined behavior. | -| D-2 | **Shape: safe enumeration, not a topology renderer.** Enumeration is durable -- `GetLogicalProcessorInformationEx` gains relations rather than changing shape -- while *interpretation* (what constitutes a "domain" worth partitioning by) is exactly what CXL and further chiplet federation will churn. The durable thing belongs in the crate and the volatile thing above it. A consumer wanting an opinionated model builds it from these records; this crate does not ship one. | -| D-3 | **`ProcessorSet` is the one abstraction added above faithful records, because it is where the bugs actually are.** Handing back raw `usize` masks would be safe FFI rather than a safe API: callers assume a single processor group, assume 64 or fewer processors, and lose the group when they flatten to an index. `ProcessorSet` carries `(group, mask)` correctly and spans groups. Everything else stays a faithful mirror of what Win32 reported. | -| D-4 | **Domains are open-kinded rather than a fixed set of relation types.** Forced by the Linux cross-check (see the design session): Linux models `die`, `cluster`, and on s390x `book` and `drawer`, none of which Windows reports and none of which cache domains reliably approximate -- Zen 2 had two L3 domains per die, so cache and die genuinely differ. Enumerating every level any architecture will ever have is a losing game, so a domain is `{ kind, id, processors, ...kind-specific attributes }` with well-known kinds documented rather than closed. The cost is honest: a policy filters `kind == "cache" && level == 3` instead of reading a typed `topology.caches`, which is weaker typing at the schema boundary in exchange for surviving hardware nobody has shipped yet. Rust-side typed accessors over the open representation keep the ergonomic loss confined to the JSON. | -| D-5 | **A NUMA node is modelled as a memory domain that may contain no processors, not as a processor grouping that happens to have memory.** Linux exposes `has_cpu` / `has_memory` per node precisely because memory-only nodes are now ordinary: CXL memory expanders, persistent memory in system-RAM mode, HBM tiers, and GPU-attached memory on coherent parts all appear that way. An earlier draft of this schema defined a node by its processor list, which makes a CXL expander a degenerate case rather than a first-class one -- backwards for the hardware direction this repository expects. This is the correction the Linux comparison was most valuable for, because Windows' own model is currently impoverished here and looking only at Windows would have missed it. | -| D-6 | **Domains reference processors; they do not nest.** No hierarchy is imposed, so the schema never asserts that packages contain nodes or that nodes contain cache domains. Chiplets and CXL already violate those assumptions, and Linux's own levels do not form a strict hierarchy either -- clusters cut across cache domains on some ARM parts. Refusing to impose one is what lets a synthetic description describe a machine whose shape we have not seen. | -| D-7 | **A processor is identified by `(group, number)`, never by a flat index.** The Windows processor group is the hard affinity boundary -- a thread's affinity is a `GROUP_AFFINITY` and cannot span groups -- so flattening destroys the one constraint a planner must respect. A description sourced from a system without groups simply has one group, which is lossless in that direction. | -| D-8 | **The JSON schema is explicitly not covered by this crate's semver contract**, following the precedent set by `windows-file-watcher`'s D-71 for its scenario schema. This is load-bearing rather than incidental: it is precisely what makes D-9's deferrals safe rather than merely convenient. A schema v2 when HMAT-class hardware matters is permitted, so the cost of *not* pre-building for it is bounded. The Rust API is covered by semver as always. | -| D-9 | **Deliberately excluded, with reasons.** See the detail section below. **No work is scheduled against any of it** -- the absence of checklist items is intentional, not an oversight. | -| D-10 | **The description is platform-neutral; platform constraints live in the planner.** A description sourced from Linux will have one group possibly containing more than 64 processors, which is unrepresentable as a Windows affinity mask. The schema does not enforce the Windows limit. A Windows planner consuming such a description must reject or split it rather than silently emitting an affinity mask that cannot exist. Keeping the constraint in the planner is what allows a description of a machine to be written on, and for, a different platform. | -| D-11 | **A `Memory` domain's `memory_bytes` is `Option`, not a bare `u64`, because Windows's own enumeration cannot report it.** `GetLogicalProcessorInformationEx`'s NUMA-node relationship carries a processor set and a node number, never a capacity; measuring node memory would mean a different API entirely. A `MachineMemoryTopology` this crate discovers therefore always sets `memory_bytes: None` for every memory domain it produces from `RelationNumaNode`/`RelationNumaNodeEx`. Using `Some(0)` as a stand-in would be indistinguishable from "this node genuinely has no memory," which is exactly the CXL-expander case D-5 exists to represent honestly; `None` is the only choice that does not silently invent data. A hand-written or fed-in description may still supply a real value. | -| D-12 | **A topology carries its own provenance, and the untrusted value is the default.** This crate deliberately lets a topology be discovered, built by hand, or deserialized from a description written for a machine you do not have -- and until now the three were indistinguishable once built. [`Provenance`] is `Synthetic` by `Default`, so forgetting is safe and claiming is deliberate; only `discover` yields `Measured`; and deserialization can only ever *downgrade*, so a file cannot assert it is the machine you are on. | -| D-13 | **Every `Option` in this crate must say *which* absence it means.** "Not observed", "observed and absent", and "a computed answer that is negative" are three different facts, and an `Option` spells all three identically. Each one is documented at its site, and no field may mean more than one. See the detail section below, which audits every `Option` the crate has. | -| D-14 | **Windows's `LastLevelCacheIndex` is not `MachineMemoryTopology::outermost_partitioning_cache`, and neither is wrong.** Measured on the x64 development host: CPU Sets reports **one** LLC group over all sixteen processors, while the derivation reports **eight** partitions at L2. Windows names the *last* level; the derivation names the outermost level that *divides*. They answer different questions, so neither may be substituted for the other, and a consumer treating the CPU-set value as "the cache domain" would collapse eight groups into one on that machine. | -| D-15 | **A relation is identified by its `(kind, membership)`, not by any source's label -- so several observations of one relation are held as a *set*, never reduced on insert.** Measured, not assumed: the two Win32 sources agree exactly on the core partition (eight groups each) while labelling it completely differently (`[0,2,4,...,14]` against `[0,1,...,7]`). The "disagreement" a reduction would resolve is between *dictionaries*, not about the machine, and reducing would have to pick a label arbitrarily while discarding the other source's. Under membership identity the common case costs nothing -- one relation, two observations -- and a genuine contradiction stays representable. See the detail section below. | - -## D-12: provenance, and why the default points at distrust - -Three ways to obtain a `MachineMemoryTopology` are supported on purpose, and the crate's own front page advertises -the third: "deserialize one from JSON written for a machine you do not have". That is a feature -- it -is how a consumer tests against hardware it lacks, and this workspace needs it right now, because -`probe-core-affinity` must exercise NUMA selection logic on hosts that have exactly one NUMA node. - -The hazard is that **the resulting value looked exactly like a discovered one**. There is a passing -test in this crate that parses a *Linux-shaped* description, complete with an ACPI SLIT-style distance -matrix, on a Windows-only crate. Nothing downstream could tell that apart from the machine it was -running on. - -Three decisions make the marker hard to lose. - -**`Synthetic` is `Default`.** This is the load-bearing one. `MachineMemoryTopology::default()`, -`..Default::default()`, and every construction that simply does not think about provenance come out -tainted. A caller must do work to claim data is real, rather than work to admit it is not. The reverse -default would mean every forgetful construction silently asserts it read the machine -- which is -precisely the accident this exists to catch, and it would be catastrophically quiet. - -**The variants are ordered by trust**, `Synthetic < Restored < Measured`, so the derived `Ord` *is* the -trust order and `min` implements "never upgrade". `downgraded_to` is that one line, which is why there -is no second, subtly different rule anywhere: a ceiling is a maximum, not an assignment, so passing a -synthetic description through a loader does not launder it into a restored one. - -**Deserialization refuses any claim above `Restored`.** A hand-edited `"provenance": "measured"` is -ignored. This is the one place forgery rather than accident is refused, and the asymmetry is -deliberate: a line of code claiming `Measured` had to be written by someone who meant it, whereas a -JSON file is data that travels, gets copied between machines, and is edited by people who never read -this note. The marker is still *serialized*, so it is visible in the persisted form -- the goal is that -a tainted topology is loud, not that it is unwritable. - -The consequence to be aware of: **a measured topology cannot be archived and reloaded as measured.** -That is intended. What you reload is a description of a machine, and the fact that it was once read -from a real one does not make it a statement about the host doing the reading. - -**The threat model is accident, not forgery.** A caller who writes `provenance: Provenance::Measured` -over data they fabricated has lied deliberately, and no type in a crate with public fields prevents -that. Adding a private field with a constructor was considered and rejected: it would break the -hand-construction the crate deliberately supports (D-8's "plain data" property), for a guarantee that -only holds against an adversary this crate does not have. - -`from_relations` stamps `Synthetic` and `discover` overwrites it with `Measured`, rather than the -transform claiming it. `from_relations` is a pure function of whatever relations it is handed and -cannot know where they came from; putting the claim in `discover` keeps it attached to the act of -asking the operating system, so a future second caller of the transform does not silently inherit an -assertion it has not earned. - -## D-13: which absence an `Option` means - -An `Option` is three different facts wearing one shape, and this crate carries all three: - -1. **Not observed.** Nothing asked. The value may exist on this machine; we did not look, or there - is no way to look. -2. **Observed and absent.** Something asked, and the answer was that there is none. -3. **A negative result.** Not an absence at all: a computed answer whose value happens to be "no". - -A consumer that cannot tell (1) from (2) will eventually read one as the other, and the failure is -silent both ways -- treating "we did not look" as "there is none" invents a fact, and treating -"there is none" as "we did not look" sends a caller off to re-derive something already settled. - -**The rule: every `Option` documents which of the three it means, at its site, and no single -`Option` may mean more than one.** Where a field would otherwise have to mean two, that is the -signal to change the representation rather than to write a longer comment. - -This is the same reasoning [D-11](#d-11) already applied to `memory_bytes`, one level down: `Some(0)` -was rejected there because a sentinel would be indistinguishable from a real value. D-13 is that -argument carried up from "do not use a sentinel" to "say which absence you mean". - -### The audit - -| Site | Which absence | Notes | -|---|---|---| -| `MachineMemoryTopology::distances` | **not observed**, and unobservable here | Windows exposes no user-mode SLIT reader, so `discover` can never fill it. Populated only by a fed-in description. | -| `MachineMemoryTopology::cpu_sets` | **not observed** | `Some(v)` means the CPU-set API answered, and `v` may legitimately be empty; `None` means nothing asked, which is what a hand-built or deserialized topology is. | -| `DomainKind::Memory::memory_bytes` | **not observed** from `discover` | See below: a *description's* `None` is currently ambiguous, and that is the one gap this audit found. | -| `MachineMemoryTopology::processor` | lookup miss | Ordinary "no such element", not a fact about the machine. | -| `MachineMemoryTopology::outermost_partitioning_cache` | **negative result** (category 3) | `None` is the real answer "no level divides this machine", already documented as such. Not an absence, and must not be read as one. | - -### The one gap this audit found - -`memory_bytes` is unambiguous from `discover`, which always sets `None` for the reason D-11 gives. -It is **ambiguous from a description**: a description that omits the field and a description -written for a node whose capacity is genuinely unknown produce the same `None`, and nothing -distinguishes them. - -That is not fixable by documentation, because the two really are the same value today. It is fixed -by the representation, which is the subject of the open locality-model work -- see `SH-16.8` in -[CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md), where absence -becomes first-class rather than a shape. Recorded here so the gap is not rediscovered, and queued -there so it is not merely recorded. - -### Where this crate already got it wrong - -`Processor::capacity` is the counter-example, and it is worse than an ambiguous `Option`: it is a -**sentinel that collides with a legitimate value**. `0` means offline, *or* in no core domain, *or* -efficiency class zero -- and the third is every processor on every non-hybrid machine. A careful -caller cannot distinguish them at all, where an ambiguous `Option` at least admits it is absent. -Tracked as `SH-16.12`. `DomainKind::Core { efficiency_class }` carries the same fact with no -sentinel and is the interim answer. - -## D-14: Windows's last-level cache is a different question - -`SYSTEM_CPU_SET_INFORMATION::LastLevelCacheIndex` and -[`MachineMemoryTopology::outermost_partitioning_cache`](crate::MachineMemoryTopology::outermost_partitioning_cache) both look -like "which cache groups these processors", and they are not the same question. - -Measured on the x64 development host, sixteen processors: - -- CPU Sets reports **one** distinct `LastLevelCacheIndex`. That is the L3, which spans the machine. -- `outermost_partitioning_cache` reports **eight** partitions, at L2. - -Both are correct. Windows names the **last** level in the hierarchy, whether or not it divides -anything; the derivation names the outermost level that **does** divide, which is what a caller -sharding work needs and is why it exists. On a machine whose last level is shared by everything, -those answers differ by the whole width of the machine. - -The consequence worth stating plainly: **a consumer must not substitute one for the other.** Reading -`LastLevelCacheIndex` as "the cache domain" would have produced one shard group where the derivation -produces eight, and nothing about the value would have looked wrong. - -This is also the first concrete instance of two Win32 sources describing overlapping facts, which is -why `MachineMemoryTopology::cpu_sets` is carried beside the domains rather than merged into them. Deciding what a -consumer should do when the two disagree -- as opposed to answering different questions, which is -this case -- is `SH-16.13`. - -Kept honest by a test that asserts the *relationship* rather than this host's numbers: Windows's -grouping is never finer than the derived one, because the last level is at or outside whatever level -first divides the machine. - -## D-15: a relation is its membership, and observations are a set - -*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.1.* - -### The question, and why it looked balanced - -Two Win32 sources now report overlapping facts about the same processors: the relationship walk and -the CPU-set enumeration both describe a processor's core, its NUMA node and its efficiency class, -from different kernel paths. So the model must decide whether several observations of one relation -are **held as a set** or **reduced on insert** with the reduction recorded. - -Stated abstractly the two look balanced. A set is honest and pushes adjudication onto every caller; -a reduction is convenient and throws away the disagreement, which is the one thing a second observer -is for. That framing is what the question sat on for most of a day. - -### What measurement showed - -Compared on the x64 development host, as partitions rather than as labels: - -| | agreement | strength of the evidence | -|---|---|---| -| core partition | **identical**, eight groups each | **strong** -- eight non-trivial groups matched exactly | -| core *labels* | **completely different**: `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]` | -- | -| NUMA partition | identical, one group | weak: a single group matches trivially | -| efficiency class | identical, all zero | weak: all zero, which is the very value `Processor::capacity`'s sentinel collides with | - -**The two sources agree on the facts and disagree on the names.** CPU Sets numbers a core by its -first logical processor; the relationship walk numbers domains in discovery order. Neither is wrong, -and neither is a claim about the machine. - -### What follows - -**A relation is identified by `(kind, membership)`.** Which processors are grouped is the -observation; what a source calls that group is an attribute *of the observation*, not of the -relation. Two sources producing the same membership have observed **one** relation twice. - -That settles the question, and not by the argument the checklist item expected: - -- **Reduce-on-insert is not merely lossy, it is arbitrary.** Its job would be to resolve a conflict, - and the only conflict present is between labelling schemes -- where both labels are correct and - picking one is a coin toss. Meanwhile the fact that mattered needed no reduction at all, because - the sources agreed on it. -- **A set costs nothing in the common case.** Agreement means one relation carrying two observations, - not two competing relations. The feared duplication does not materialise where the sources agree, - which is the usual case. -- **And a genuine contradiction stays representable**: two sources claiming the same *kind* for - overlapping-but-unequal memberships. That is a real disagreement about the machine, and it is - exactly what a reduction would have hidden. - -### It also disposes of D-14's third case - -[D-14](#d-14) found that CPU Sets' `LastLevelCacheIndex` and the derived partitioning cache differ -- -one group against eight -- without either being wrong, because they answer different questions. That -looked like it would need a third state beside "agree" and "contradict". - -Under membership identity it needs nothing. Different memberships at different kinds are **different -relations**, so they never meet to disagree. The third case dissolves rather than being handled, -which is a better outcome than the vocabulary MMT-1.2 was going to have to invent. - -### The machinery for detecting contradiction already exists - -Overlapping-but-unequal sets *at the same kind* is precisely what -`MachineMemoryTopology::are_pairwise_disjoint` already checks for cache domains, and what -[D-5](#d-5)-era work established as the shape of a corrupt or hand-built topology. Generalising it -from "cache levels" to "any kind" is the whole of the contradiction check, rather than new -machinery. - -### What this does not establish - -The core comparison is strong; the other two are not, and saying so matters more than the headline. -The NUMA partition is one group on this host, so it would match under almost any bug. Efficiency -class is zero everywhere, which is both trivially matchable *and* the exact value -`Processor::capacity`'s sentinel is indistinguishable from -- so that row confirms nothing about -either source. A hybrid, multi-node machine would test all three properly, and none is available -here. - -## What was deliberately excluded (D-9) - -Recorded because what a design declines is as important as what it adopts, and because each of these was -considered and rejected rather than overlooked. Each entry states what would justify revisiting it. - -**HMAT-style attributed relations.** ACPI's Heterogeneous Memory Attribute Table supersedes SLIT, giving -per-initiator/per-target read and write latency and bandwidth -- four numbers where SLIT gives one scalar -- -and Linux already exposes it. A general edge list (`{ from, to, read_latency_ns, read_bandwidth_mbps, ... }`) -would absorb HMAT, asymmetry, and multi-hop CXL fabrics; the scalar distance matrix this schema keeps will -not. That was raised as an argument for building the edge list now, on the grounds that retrofitting it is -a breaking schema change. **Deferred anyway, and the deferral is safe because of D-8:** the schema carries -no stability promise, so a v2 is permitted. The trade taken is a simpler, hand-writable description now -against a schema break later. *Revisit when:* tiered-memory hardware is in scope for a consumer, or a -scalar distance demonstrably mismodels a machine somebody is tuning for. - -**Devices as topology participants and as initiators.** HMAT models initiators separately from targets -because GPUs, DMA-capable NICs, and NVMe controllers all initiate memory access, and for an I/O-focused -consumer the device is the locality question. Naming devices in the description would let a synthetic -topology drive ring planning against a device layout Windows cannot easily enumerate anyway (the -handle-to-device-node walk goes through SetupAPI/CfgMgr with real failure modes on spanned volumes, -Storage Spaces, network paths, and VHDs). **Excluded as scope, by the engineer's direction:** it changes -the crate's identity from processor topology to system topology, which is a materially larger surface than -the name implies and a larger promise than is wanted now. *Revisit when:* a consumer needs device-aligned -planning badly enough to accept that surface, at which point the crate probably wants a different name. - -**Queue and interrupt affinity.** NVMe queue-pair to CPU mapping is the mechanism by which -submission-core and completion-core locality actually happens, and it is what would let a plan align rings -with hardware queues. **Excluded:** it is downstream of devices being representable at all, so it cannot -precede the previous entry. - -**Power and thermal domains, cache partitioning (Intel RDT/CAT, ARM MPAM), and -confidential-computing memory-encryption domains.** All are real partitioning concepts that could map onto -ring assignment. **Excluded:** none of them is locality, each needs its own vocabulary, and D-4's open -domain kinds mean adding any of them later is additive rather than breaking -- so there is no cost to -waiting and no benefit to guessing at their shape now. - -**Memory tiering abstract distance.** Linux's tiering model assigns nodes an abstract distance for -promotion and demotion decisions. **Excluded** for the same reason as HMAT, and it would arrive with it. - -## What the Linux comparison established - -The cross-check was run to find future expansion directions rather than to validate the schema, and it did -both. Details are in the design session; the summary is that three things in the then-current draft were -genuinely violated -- memory-only nodes (D-5), fixed domain kinds (D-4), and missing online/offline state --- while three decisions held up unchanged: processor identity as `(group, number)` (D-7), -reference-don't-nest (D-6), and treating distances as optional, which Linux vindicated by actually having -SLIT where Windows does not. From 81f87b95825bdab78c6733459b1bf6534545998b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 22:53:06 -0400 Subject: [PATCH 247/361] docs(topology): pin down what MMT-1.2 actually has to decide The item said 'what a query returns when observations differ', which is too vague to decide on. Checked the specifics instead. Two shapes of conflict, not one. Partition conflicts -- same kind, memberships overlapping without being equal -- are what D-15's identity makes detectable. Attribute conflicts are different: same processor, same attribute, different scalar, as with efficiency class from the two sources. D-15 does not reach that shape and the item had not noticed it. A third case is not a source conflict at all. discover() makes two separate sequential Win32 calls, so a processor parked or hot-added between them means the two halves describe different instants. From a single observation a torn read is indistinguishable from a genuine inconsistency, and the topology is already a composite of two moments with nothing recording it. A fourth is within a single source: a processor named by two Core domains, from malformed firmware or a hand-built description. Unchecked today -- are_pairwise_disjoint runs only at query time, only for caches, and Core and Memory domains are never validated. --- crates/windows-topology-sys/CHECKLIST.md | 43 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index ee4e1082..9f3f7e29 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -69,9 +69,46 @@ wrongly after code exists. **What remains is narrower**: two sources claiming the same *kind* over overlapping-but-unequal memberships -- a real contradiction about the machine. Decide what a query returns then: a value plus a conflict marker, or the conflict itself, forcing the caller to adjudicate. - Note the detection machinery already exists. Overlapping-but-unequal sets at one kind is exactly - what `are_pairwise_disjoint` checks for cache domains today; generalising it from "cache levels" to - "any kind" is the whole of the check. + Note the detection machinery partly exists. Overlapping-but-unequal sets at one kind is exactly + what `are_pairwise_disjoint` checks for cache domains today -- though only at *query* time, inside + `outermost_partitioning_cache`, and `Core` and `Memory` domains are never validated at all. + + ### The specifics, since "observations differ" is too vague to decide on + + **Where it arises:** `discover()`, populating a `MachineMemoryTopology`. It makes **two separate, + sequential Win32 calls** -- `relation::discover()` then `cpu_set::enumerate()` -- and nothing + compares their results. + + **Two shapes of conflict, not one.** The item above describes only the first: + + - **A, partition conflict:** same kind, memberships overlap without being equal. GLPIE says a core + is `{0,1}`, CPU Sets groups `{0,1,2}` under one `CoreIndex`. `(kind, membership)` identity + from [D-15](DESIGN-NOTES.md#d-15) makes this detectable. + - **B, attribute conflict:** same processor, same attribute, **different scalar**. GLPIE's + `Core { efficiency_class }` against CPU Sets' `EfficiencyClass`. This is not a membership + question and D-15 does not reach it, which the item as first written did not notice. + + **A third case that is not a source conflict at all: the two calls are not atomic.** A processor + parked, unparked, hot-added or hot-removed between them means the two halves describe **different + instants**. That is not Windows contradicting itself -- it is us sampling twice -- and **from a + single observation a torn read is indistinguishable from a genuine inconsistency.** So the topology + is already a composite of two moments and nothing records that, which is true even when nothing + conflicts. + + **And a fourth, within a single source:** a processor named by two `Core` domains, from malformed + firmware or a hand-built description. Unchecked today. + + ### What to decide + + 1. Does `discover()` **detect** at populate time, or is a conflict something only a query surfaces? + Detecting costs a comparison over every overlapping fact; not detecting means a caller who never + asks the right question never learns. + 2. What happens when it does: **refuse** (an `Err` from `discover`, which would make a machine + unusable over a discrepancy that may be benign), **record and continue** (consistent with this + crate's posture of representing the awkward case), or **prefer a source** -- which is + reduce-on-insert wearing a different hat, and D-15 rejected it. + 3. Whether to record that a topology is a **composite of two instants** regardless of conflict, and + whether to spend a third call re-reading the first source to tell a torn read from a real one. - [ ] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that the model answers without further measurement. Degrade to a documented weaker policy, refuse, or From 9f26b628713252f91adb09e9d44098e626962686 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 22:57:40 -0400 Subject: [PATCH 248/361] docs(topology): correct an overstated concern in MMT-1.2 Called the two non-atomic Win32 calls a torn read. Wrong word, and it made a sampling concern sound like a memory-safety one. Nothing tears: each call returns its own self-consistent snapshot and the buffers are process-private. The accurate description is a non-atomic composite -- two individually-consistent reads at different instants. The example was wrong too, and more seriously. Parking cannot cause a disagreement: it changes a CPU-Sets-only field, GLPIE does not report parked state at all, and none of the three overlapping facts move when a core parks. CoreIndex and NumaNodeIndex are unchanged and EfficiencyClass is a static property of the core design. What remains is processor hot-add or hot-remove inside a microsecond window, needing dynamic partitioning. Real but rare, and with a distinctive signature rather than an ambiguous one: one source naming a processor the other does not, which is neither of the two conflict shapes. Downgraded accordingly, since it was being used as an argument against detection that it cannot support. The argument against refusing stands without it. A finding from checking it: online and parked are complementary rather than overlapping. Parked is not offline -- a parked processor is active and merely avoided -- so the two sources together give a fuller availability picture than either alone, which is an argument for consuming both that has nothing to do with conflict. --- crates/windows-topology-sys/CHECKLIST.md | 42 +++++++++++++++++++----- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 9f3f7e29..67bf46a7 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -88,12 +88,34 @@ wrongly after code exists. `Core { efficiency_class }` against CPU Sets' `EfficiencyClass`. This is not a membership question and D-15 does not reach it, which the item as first written did not notice. - **A third case that is not a source conflict at all: the two calls are not atomic.** A processor - parked, unparked, hot-added or hot-removed between them means the two halves describe **different - instants**. That is not Windows contradicting itself -- it is us sampling twice -- and **from a - single observation a torn read is indistinguishable from a genuine inconsistency.** So the topology - is already a composite of two moments and nothing records that, which is true even when nothing - conflicts. + **A third case that is not a source conflict at all: the two calls are not atomic** -- though this + was first written far too strongly, and the correction matters more than the original claim. + + It is **not a torn read**. Nothing tears: each Win32 call returns its own self-consistent snapshot, + and the buffers are process-private. The accurate description is a **non-atomic composite** -- two + individually-consistent reads taken at different instants. + + **And parking cannot cause it**, which was the example first given here. Parking changes a + CPU-Sets-only field; GLPIE does not report parked state at all, and none of the three overlapping + facts move when a core parks -- `CoreIndex` and `NumaNodeIndex` are unchanged, and + `EfficiencyClass` is a static property of the core's design. + + What actually remains is **processor hot-add or hot-remove** between the two calls, which needs + dynamic partitioning and is rare. The window is microseconds. Its signature is also distinctive + rather than ambiguous: CPU Sets naming a processor that has no `Processor` entry, or the reverse -- + which is neither shape A nor shape B, but a third thing, one source knowing a processor the other + does not. + + So this case is **real but rare**, and it should not carry weight it cannot bear. In particular it + is a weak argument against detecting conflicts, and the better argument against *refusing* on one + stands without it: a genuine inconsistency from a defective hypervisor is something a caller would + rather be told about and route around than be unable to run at all. + + **A finding that came out of checking it:** `online` (GLPIE, from `active_processors`) and `parked` + (CPU Sets) are **complementary, not overlapping**. Parked is not offline -- a parked processor is + active and the scheduler is merely avoiding it. So the two sources together give a *fuller* + availability picture than either alone, which is an argument for consuming both that has nothing to + do with conflict. **And a fourth, within a single source:** a processor named by two `Core` domains, from malformed firmware or a hand-built description. Unchecked today. @@ -107,8 +129,12 @@ wrongly after code exists. unusable over a discrepancy that may be benign), **record and continue** (consistent with this crate's posture of representing the awkward case), or **prefer a source** -- which is reduce-on-insert wearing a different hat, and D-15 rejected it. - 3. Whether to record that a topology is a **composite of two instants** regardless of conflict, and - whether to spend a third call re-reading the first source to tell a torn read from a real one. + 3. Whether to record that a topology is a **composite of two instants** regardless of conflict. + Cheap and true either way. The follow-on -- spending a third call re-reading the first source to + tell a stale composite from a real inconsistency -- is now hard to justify: the case it detects + needs processor hot-add inside a microsecond window, it would not be airtight anyway since the + machine can change during the third read too, and `discover()` is currently cheap enough that + callers may reasonably call it often. - [ ] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that the model answers without further measurement. Degrade to a documented weaker policy, refuse, or From 7c8539d38f7c5e82cf389f5cda4d7365de5996fa Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 23:04:11 -0400 Subject: [PATCH 249/361] docs(topology): retire the non-atomic-read case from MMT-1.2 Confirming the scenario made it clear it is not this item's problem. The claim was that discover()'s two calls are not atomic, so a hot-add between them leaves the halves describing different machines. True, and it proves too much: even a perfectly atomic discover() returns a topology that is stale the instant it returns, and a hot-add one microsecond afterwards leaves it equally wrong. The two-call window is a marginally larger instance of an unavoidable problem, not a distinct one. So the right question is not whether our two reads agreed but whether the machine is still what we planned against when we act -- which belongs to the executor and is already owned as M-inf.1 in windows-execution-plan, filed explicitly as a different problem. Severity agrees: on an add we merely fail to use a new processor, on a remove pinning fails loudly where it happens, and neither corrupts a decision silently. Dropped the composite-of-two-instants recording and the third re-read call with it, since both were answers to the retired question. If staleness is worth addressing, a snapshot wants a notion of when it was taken -- one fact, not a count of reads. MMT-1.2 keeps the two conflict shapes that arise from sources disagreeing about the same moment, which no atomicity would fix. The reasoning is kept rather than deleted because the disposal is the useful part. --- crates/windows-topology-sys/CHECKLIST.md | 62 +++++++++++++----------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 67bf46a7..b0cdf611 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -88,28 +88,35 @@ wrongly after code exists. `Core { efficiency_class }` against CPU Sets' `EfficiencyClass`. This is not a membership question and D-15 does not reach it, which the item as first written did not notice. - **A third case that is not a source conflict at all: the two calls are not atomic** -- though this - was first written far too strongly, and the correction matters more than the original claim. - - It is **not a torn read**. Nothing tears: each Win32 call returns its own self-consistent snapshot, - and the buffers are process-private. The accurate description is a **non-atomic composite** -- two - individually-consistent reads taken at different instants. - - **And parking cannot cause it**, which was the example first given here. Parking changes a - CPU-Sets-only field; GLPIE does not report parked state at all, and none of the three overlapping - facts move when a core parks -- `CoreIndex` and `NumaNodeIndex` are unchanged, and - `EfficiencyClass` is a static property of the core's design. - - What actually remains is **processor hot-add or hot-remove** between the two calls, which needs - dynamic partitioning and is rare. The window is microseconds. Its signature is also distinctive - rather than ambiguous: CPU Sets naming a processor that has no `Processor` entry, or the reverse -- - which is neither shape A nor shape B, but a third thing, one source knowing a processor the other - does not. - - So this case is **real but rare**, and it should not carry weight it cannot bear. In particular it - is a weak argument against detecting conflicts, and the better argument against *refusing* on one - stands without it: a genuine inconsistency from a defective hypervisor is something a caller would - rather be told about and route around than be unable to run at all. + **A third case was raised here and then removed, because it is not this item's problem.** Recorded + rather than deleted, since the reasoning that disposed of it is the useful part. + + The claim was that `discover()`'s two calls are not atomic, so a change between them leaves the two + halves describing different machines. Two corrections and then a dismissal: + + - **It is not a torn read.** Nothing tears -- each call returns a self-consistent snapshot and the + buffers are process-private. The accurate term is a *non-atomic composite*. + - **Parking cannot cause it**, which was the example first given. Parking changes a CPU-Sets-only + field; GLPIE does not report parked state, and none of the three overlapping facts move when a + core parks -- `CoreIndex` and `NumaNodeIndex` are unchanged, `EfficiencyClass` is static. + - **And the concern proves too much.** Even a perfectly atomic `discover()` returns a topology that + is stale the instant it returns. A hot-add one microsecond *afterwards* leaves it equally wrong, + and no internal atomicity fixes that. The two-call window is a marginally larger instance of an + unavoidable problem, not a distinct one. + + So the right question is not "were our two reads mutually consistent" but "is the machine still + what we planned against, when we act" -- which belongs to the executor and is **already owned**: + `M-inf.1` in [windows-execution-plan](../windows-execution-plan/CHECKLIST.md), filed explicitly as + "a different problem". The severity agrees: on an add we merely fail to use a new processor, and on + a remove pinning fails loudly where it happens. Neither corrupts a decision silently. + + **What this item keeps** is the two shapes above, which arise from sources disagreeing *about the + same moment* -- a defective hypervisor, malformed firmware, a hand-built description -- and which no + amount of atomicity would fix. + + The argument against *refusing* on a conflict does not depend on any of this: a genuine + inconsistency is something a caller would rather be told about and route around than be unable to + run at all. **A finding that came out of checking it:** `online` (GLPIE, from `active_processors`) and `parked` (CPU Sets) are **complementary, not overlapping**. Parked is not offline -- a parked processor is @@ -129,12 +136,11 @@ wrongly after code exists. unusable over a discrepancy that may be benign), **record and continue** (consistent with this crate's posture of representing the awkward case), or **prefer a source** -- which is reduce-on-insert wearing a different hat, and D-15 rejected it. - 3. Whether to record that a topology is a **composite of two instants** regardless of conflict. - Cheap and true either way. The follow-on -- spending a third call re-reading the first source to - tell a stale composite from a real inconsistency -- is now hard to justify: the case it detects - needs processor hot-add inside a microsecond window, it would not be airtight anyway since the - machine can change during the third read too, and `discover()` is currently cheap enough that - callers may reasonably call it often. + 3. ~~Whether to record that a topology is a composite of two instants.~~ **Dropped**, along with the + third call that would have distinguished a stale composite from a real inconsistency. Both were + answers to the question retired above. If staleness is worth addressing, what a snapshot wants is + *a notion of when it was taken* -- one fact, not a count of reads -- and that is a question for + whoever needs it, not a consequence of `discover()` making two calls. - [ ] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that the model answers without further measurement. Degrade to a documented weaker policy, refuse, or From e6de7d66d983ef2358914d6d75f8d1042a740a36 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 23:11:18 -0400 Subject: [PATCH 250/361] docs(topology): D-16 -- collect again, and retry is the discriminator The engineer supplied the answer this item had been circling: if the incoherence is detectable, re-initiate collection. Both calls are whole-machine enumerations and trivially inexpensive, so a retry costs almost nothing, and more than a couple of passes failing to find a coherent set is not plausible. I had framed the choice as refuse, record-and-continue, or prefer-a-source, and never considered asking again -- in a crate whose every compare-exchange loop is that same read-validate-retry shape. The framing caused the miss: asking what to STORE when sources disagree quietly excludes asking again. Retry also demolishes a claim this file made twice, that a transient inconsistency and a genuine one are indistinguishable from a single observation. True, and the conclusion drawn from it does not follow: stop using a single observation. Transience resolves on the next pass and what survives is proved genuine, so retry classifies as a side effect of fixing, and only what has already been classified reaches the representation question. Recorded that a topology must state whether it was collected coherently, and where not, what disagreed -- so a reader knows how far the parts may be correlated, which is a different question from whether any one part is accurate. That is D-13's principle applied to the object rather than to a field. Un-retired the case I had wrongly dismissed for proving too much. It does prove too much, and that is not a reason to do nothing: staleness after the fact is the executor's and already owned as M-inf.1, while incoherence during collection is ours, detectable and cheap to fix. --- crates/windows-topology-sys/CHECKLIST.md | 82 ++++++++++++--------- crates/windows-topology-sys/DESIGN-NOTES.md | 63 ++++++++++++++++ 2 files changed, 109 insertions(+), 36 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index b0cdf611..a974ccf2 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -88,35 +88,55 @@ wrongly after code exists. `Core { efficiency_class }` against CPU Sets' `EfficiencyClass`. This is not a membership question and D-15 does not reach it, which the item as first written did not notice. - **A third case was raised here and then removed, because it is not this item's problem.** Recorded - rather than deleted, since the reasoning that disposed of it is the useful part. + **A third case: `discover()`'s two calls are not atomic.** Raised, then twice mis-corrected, then + wrongly retired, and finally **answered by [D-16](DESIGN-NOTES.md#d-16): collect again.** The whole + path is kept because the wrong turns are instructive. - The claim was that `discover()`'s two calls are not atomic, so a change between them leaves the two - halves describing different machines. Two corrections and then a dismissal: + If the incoherence is detectable and harmful, **re-initiate collection**. Both calls are + whole-machine enumerations and trivially inexpensive, so a retry costs almost nothing, and more + than a couple of passes failing to find a coherent set is not plausible. + + **Retry is also the discriminator this item twice claimed could not exist.** The assertion was that + a transient inconsistency and a genuine one are indistinguishable *from a single observation* -- + true, and the conclusion that the model must therefore tolerate the ambiguity does not follow. Stop + using a single observation: transience resolves on the next pass, and what survives is *proved* + genuine. So only what has already been classified reaches the representation question below. + + The earlier missteps, kept short: - **It is not a torn read.** Nothing tears -- each call returns a self-consistent snapshot and the buffers are process-private. The accurate term is a *non-atomic composite*. - **Parking cannot cause it**, which was the example first given. Parking changes a CPU-Sets-only field; GLPIE does not report parked state, and none of the three overlapping facts move when a core parks -- `CoreIndex` and `NumaNodeIndex` are unchanged, `EfficiencyClass` is static. - - **And the concern proves too much.** Even a perfectly atomic `discover()` returns a topology that - is stale the instant it returns. A hot-add one microsecond *afterwards* leaves it equally wrong, - and no internal atomicity fixes that. The two-call window is a marginally larger instance of an - unavoidable problem, not a distinct one. - - So the right question is not "were our two reads mutually consistent" but "is the machine still - what we planned against, when we act" -- which belongs to the executor and is **already owned**: - `M-inf.1` in [windows-execution-plan](../windows-execution-plan/CHECKLIST.md), filed explicitly as - "a different problem". The severity agrees: on an add we merely fail to use a new processor, and on - a remove pinning fails loudly where it happens. Neither corrupts a decision silently. - - **What this item keeps** is the two shapes above, which arise from sources disagreeing *about the - same moment* -- a defective hypervisor, malformed firmware, a hand-built description -- and which no - amount of atomicity would fix. - - The argument against *refusing* on a conflict does not depend on any of this: a genuine - inconsistency is something a caller would rather be told about and route around than be unable to - run at all. + - **And then it was retired for proving too much** -- on the grounds that even an atomic + `discover()` returns a topology stale the instant it returns, so the two-call window is only a + larger instance of an unavoidable problem. True, and **not a reason to do nothing**: the two are + not equally addressable. Staleness after the fact is the executor's to validate, and is already + owned as `M-inf.1` in [windows-execution-plan](../windows-execution-plan/CHECKLIST.md). + Incoherence *during* collection is ours, detectable, and cheap to fix. + + The framing is what caused the miss. Asking "what do we **store** when sources disagree" admits + refuse, record, or prefer -- and quietly excludes "ask again", which is the standard shape every + compare-exchange loop in this workspace already uses. + + ### What is left to decide + + Retry removes the transient cases, so what reaches representation is proved genuine. Remaining: + + 1. **What is compared**, to call a collection coherent. The processor sets naming each other is the + hot-add signature; core and NUMA partitions and per-processor efficiency class are shapes A and B. + 2. **The bound**, and what exhausting it *means* -- not a failure to collect, but the **conclusion** + that the disagreement is genuine, and the point at which shapes A and B apply. + 3. **How a topology states its coherence.** Per [D-16](DESIGN-NOTES.md#d-16) it must say plainly + whether it was collected coherently and, where it was not, what disagreed -- so a reader knows how + far the parts may be **correlated**, which is a different question from whether any one part is + accurate. + 4. **Shape B still has no representation.** `(kind, membership)` identity does not reach a + per-processor scalar disagreement, and that gap is untouched by any of the above. + + Refusing outright remains rejected on its own merits: a genuine inconsistency is something a caller + would rather be told about and route around than be unable to run at all. **A finding that came out of checking it:** `online` (GLPIE, from `active_processors`) and `parked` (CPU Sets) are **complementary, not overlapping**. Parked is not offline -- a parked processor is @@ -127,20 +147,10 @@ wrongly after code exists. **And a fourth, within a single source:** a processor named by two `Core` domains, from malformed firmware or a hand-built description. Unchecked today. - ### What to decide - - 1. Does `discover()` **detect** at populate time, or is a conflict something only a query surfaces? - Detecting costs a comparison over every overlapping fact; not detecting means a caller who never - asks the right question never learns. - 2. What happens when it does: **refuse** (an `Err` from `discover`, which would make a machine - unusable over a discrepancy that may be benign), **record and continue** (consistent with this - crate's posture of representing the awkward case), or **prefer a source** -- which is - reduce-on-insert wearing a different hat, and D-15 rejected it. - 3. ~~Whether to record that a topology is a composite of two instants.~~ **Dropped**, along with the - third call that would have distinguished a stale composite from a real inconsistency. Both were - answers to the question retired above. If staleness is worth addressing, what a snapshot wants is - *a notion of when it was taken* -- one fact, not a count of reads -- and that is a question for - whoever needs it, not a consequence of `discover()` making two calls. + Two questions this block used to ask are now answered and are not repeated: whether `discover()` + detects at populate time (**yes** -- it must, in order to retry), and whether it may prefer a source + (**no** -- [D-15](DESIGN-NOTES.md#d-15) rejected reduce-on-insert, and preferring is that by another + name). What remains is listed above. - [ ] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that the model answers without further measurement. Degrade to a documented weaker policy, refuse, or diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 5a67c3e2..90e60b26 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -35,6 +35,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-13 | **Every `Option` in this crate must say *which* absence it means.** "Not observed", "observed and absent", and "a computed answer that is negative" are three different facts, and an `Option` spells all three identically. Each one is documented at its site, and no field may mean more than one. See the detail section below, which audits every `Option` the crate has. | | D-14 | **Windows's `LastLevelCacheIndex` is not `MachineMemoryTopology::outermost_partitioning_cache`, and neither is wrong.** Measured on the x64 development host: CPU Sets reports **one** LLC group over all sixteen processors, while the derivation reports **eight** partitions at L2. Windows names the *last* level; the derivation names the outermost level that *divides*. They answer different questions, so neither may be substituted for the other, and a consumer treating the CPU-set value as "the cache domain" would collapse eight groups into one on that machine. | | D-15 | **A relation is identified by its `(kind, membership)`, not by any source's label -- so several observations of one relation are held as a *set*, never reduced on insert.** Measured, not assumed: the two Win32 sources agree exactly on the core partition (eight groups each) while labelling it completely differently (`[0,2,4,...,14]` against `[0,1,...,7]`). The "disagreement" a reduction would resolve is between *dictionaries*, not about the machine, and reducing would have to pick a label arbitrarily while discarding the other source's. Under membership identity the common case costs nothing -- one relation, two observations -- and a genuine contradiction stays representable. See the detail section below. | +| D-16 | **Collection retries until coherent, and what survives a retry is a genuine disagreement.** There is no transactional way to read the two Win32 sources together, so `discover()` validates them against each other and, on incoherence, **re-collects** -- bounded, and cheap because both are whole-machine enumerations. Transient incoherence from a hot-add resolves on the next pass; incoherence that survives is not transience and must be represented. Retry is therefore the *discriminator* between the two, which no single observation can be. And because the data can change while it is being collected, a topology states plainly whether it was collected coherently, so a reader knows how far its parts may be correlated. | ## D-12: provenance, and why the default points at distrust @@ -242,6 +243,68 @@ class is zero everywhere, which is both trivially matchable *and* the exact valu either source. A hybrid, multi-node machine would test all three properly, and none is available here. +## D-16: retry until coherent, and represent what survives + +*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.2.* + +### The problem, stated without the wrong framing + +`discover()` reads two Win32 sources -- the relationship walk and the CPU-set enumeration -- and +there is **no transactional way to read them together**. So the pair may describe different instants, +and the model has to do something about it. + +An earlier reading of this file argued the case away, on the grounds that even an atomic `discover()` +returns a topology that is stale the moment it returns, so the two-call window is only a larger +instance of an unavoidable problem. That is true and it is not a reason to do nothing, because the +two are not equally addressable: staleness after the fact is the executor's to validate, while +incoherence *during* collection is ours, is detectable, and is cheap to fix. + +### The remedy is to collect again + +If the incoherence is detectable and harmful, **re-initiate collection**. Both calls are +whole-machine enumerations and trivially inexpensive, so a retry costs almost nothing, and it is not +plausible that more than a couple of passes fail to find a coherent set. + +This is the ordinary read-validate-retry shape, and the crate is already full of it -- every +compare-exchange loop in the workspace is the same idea. It was missed here because the question was +framed as "what do we *store* when sources disagree", which admits refuse, record, or prefer, and +quietly excludes "ask again". + +### Retry is the discriminator, which is the part that matters + +This file previously asserted, twice, that a transient inconsistency and a genuine one are +*indistinguishable from a single observation*. That is true, and the conclusion drawn from it -- that +the model must therefore tolerate the ambiguity -- does not follow. The answer is to stop using a +single observation. + +- **Transient incoherence resolves on the next pass.** A processor hot-added between two calls is not + hot-added again a microsecond later. +- **Incoherence that survives a retry is not transience.** It is a real disagreement about one moment + -- a defective hypervisor, malformed firmware, a hand-built description -- and no amount of + re-reading will settle it. + +So the retry classifies as a side effect of fixing. What reaches the representation question is only +what has already been *proved* genuine, which is a far smaller and better-defined set than "anything +that ever looked inconsistent". + +### And the collection's coherence is stated, not implied + +Because the data can change while it is being collected, a topology must say plainly whether it was +collected coherently -- and, where it was not, what disagreed. A reader can then tell how far the +parts may be **correlated** with each other, which is a different question from whether any single +part is accurate. + +This is the same principle as [D-13](#d-13) applied to the object rather than to a field: a fact +about how reliable the data is must be *stated*, because a reader cannot infer it from the data's +shape. + +### Bounds, and what the bound means + +The retry is bounded. Exhausting the bound is not a failure to collect -- it is the **conclusion** +that the disagreement is genuine, and the point at which the conflict representation +([MMT-1.2](CHECKLIST.md)'s shapes A and B) applies. The two are one mechanism: retry to remove the +transient cases, then represent whatever is left. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was From c8262489c66c59fc92c7ae39adadfe9834837c92 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 23:16:35 -0400 Subject: [PATCH 251/361] docs(topology): drop MMT-1.2's first question, which was never a decision What gets compared to call a collection coherent is an inventory, not a choice. It is fixed by the data -- whatever both sources report about the same thing -- and enumerating it is what writing the comparison does. Two prior decisions had already removed everything that made it look like a decision. D-15 closed the only trap, comparing labels rather than memberships, which would have flagged [0, 2, 4, ...] against [0, 1, 2, ...] as a conflict when the sources agree completely. And under D-16 a partial comparison is actively wrong, because an incoherence in an uncompared fact survives the retry and is never classified, defeating the mechanism -- so retry forces comparing everything. It was listed as a decision while the question was still detect-or-not-and-how-much, where scope would genuinely have been a knob. D-16 removed the knob and the item was carried forward anyway. The bound's meaning is settled by D-16 too; only the number remains, and it is small. What is actually open is how a topology states its coherence, and shape B, which membership identity does not reach. --- crates/windows-topology-sys/CHECKLIST.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index a974ccf2..3b9ad6eb 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -124,14 +124,28 @@ wrongly after code exists. Retry removes the transient cases, so what reaches representation is proved genuine. Remaining: - 1. **What is compared**, to call a collection coherent. The processor sets naming each other is the - hot-add signature; core and NUMA partitions and per-processor efficiency class are shapes A and B. + 1. ~~What is compared, to call a collection coherent.~~ **Not a decision -- an inventory, and it + falls out of the implementation.** What *can* be compared is fixed by the data: whatever both + sources report about the same thing, which is the processor sets naming each other, the core and + NUMA groupings, and per-processor efficiency class. The one trap -- comparing *labels* rather than + memberships, which would flag `[0, 2, 4, ...]` against `[0, 1, 2, ...]` as a conflict when the + sources fully agree -- is already closed by [D-15](DESIGN-NOTES.md#d-15). + And under [D-16](DESIGN-NOTES.md#d-16) a *partial* comparison is actively wrong: an incoherence in + an uncompared fact survives the retry and is never classified, defeating the mechanism. So retry + forces comparing everything, which makes the scope determined rather than chosen. + It was listed as a decision while the question was still "detect or not, and how much", where + scope would genuinely have been a knob. `D-16` removed the knob. + 2. **The bound**, and what exhausting it *means* -- not a failure to collect, but the **conclusion** - that the disagreement is genuine, and the point at which shapes A and B apply. + that the disagreement is genuine, and the point at which shapes A and B apply. The *meaning* is + settled by [D-16](DESIGN-NOTES.md#d-16); only the number is open, and it is small -- a couple of + passes failing to find a coherent set is not plausible. + 3. **How a topology states its coherence.** Per [D-16](DESIGN-NOTES.md#d-16) it must say plainly whether it was collected coherently and, where it was not, what disagreed -- so a reader knows how far the parts may be **correlated**, which is a different question from whether any one part is accurate. + 4. **Shape B still has no representation.** `(kind, membership)` identity does not reach a per-processor scalar disagreement, and that gap is untouched by any of the above. From 7b2b16eb777c16b4d5dc6fed09c4b5c2ba323977 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 23:20:54 -0400 Subject: [PATCH 252/361] docs(topology): D-17 -- incoherence is expected in the field, not exotic Windows is the only oracle available, and an oracle is not a proof. No documented guarantee of cross-API coherence has been found -- a statement about what we could establish, not proof none exists, but we may not rely on one we cannot cite. The mechanism is plausible and specific. GetLogicalProcessorInformationEx derives from ACPI tables and CPUID; GetSystemCpuSetInformation is the kernel's much later scheduler-side abstraction. They likely share a root but not a derivation path, and the newest facts -- hybrid efficiency classes, the last-level-cache index -- are exactly where one may be updated and the other not. Underneath both, firmware tables are populated incrementally by vendors against scenarios they test, and cross-checking two topology APIs is not a scenario anyone is likely to have tested. So the realistic sources are ordinary rather than exotic: hardware we do not have, prerelease hardware with defective UEFI tables, and new topology features arriving in one enumeration before the other. None are transient, so D-16's retry will not clear them, and they land by construction in the bucket the model must represent -- likely populated on exactly the machines a user most needs to get right. Three consequences. Refusing is out, because the places this occurs are the places where refusing makes the crate useless. A report must be precise enough to act on, since 'incoherent' is not actionable where naming the disagreeing fact is a bug report against a firmware table. And it is testable only synthetically, which is a second and unanticipated justification for the hand-built and deserialized construction paths this crate already documents as a feature. --- crates/windows-topology-sys/CHECKLIST.md | 14 ++++-- crates/windows-topology-sys/DESIGN-NOTES.md | 56 +++++++++++++++++++++ 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 3b9ad6eb..1aebec37 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -141,10 +141,16 @@ wrongly after code exists. settled by [D-16](DESIGN-NOTES.md#d-16); only the number is open, and it is small -- a couple of passes failing to find a coherent set is not plausible. - 3. **How a topology states its coherence.** Per [D-16](DESIGN-NOTES.md#d-16) it must say plainly - whether it was collected coherently and, where it was not, what disagreed -- so a reader knows how - far the parts may be **correlated**, which is a different question from whether any one part is - accurate. + 3. **How a topology states its coherence** -- and [D-17](DESIGN-NOTES.md#d-17) raises the bar on + this from "say whether" to "say what, precisely enough to act on". Persistent disagreement is + expected on hardware we do not have and on prerelease firmware, so the report is not a corner + case: it is the crate's output on exactly the machines a user most needs to understand. + "Incoherent" is not actionable. *"These two sources disagree about which core processor 6 belongs + to, one saying X and the other Y"* is a bug report against a firmware table or an OS enumeration. + A reader also needs it to know how far the parts may be **correlated**, which is a different + question from whether any one part is accurate. + Note this is only possible because [D-15](DESIGN-NOTES.md#d-15) keeps both observations: a + disagreement cannot be reported after it has been collapsed. 4. **Shape B still has no representation.** `(kind, membership)` identity does not reach a per-processor scalar disagreement, and that gap is untouched by any of the above. diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 90e60b26..d2715828 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -36,6 +36,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-14 | **Windows's `LastLevelCacheIndex` is not `MachineMemoryTopology::outermost_partitioning_cache`, and neither is wrong.** Measured on the x64 development host: CPU Sets reports **one** LLC group over all sixteen processors, while the derivation reports **eight** partitions at L2. Windows names the *last* level; the derivation names the outermost level that *divides*. They answer different questions, so neither may be substituted for the other, and a consumer treating the CPU-set value as "the cache domain" would collapse eight groups into one on that machine. | | D-15 | **A relation is identified by its `(kind, membership)`, not by any source's label -- so several observations of one relation are held as a *set*, never reduced on insert.** Measured, not assumed: the two Win32 sources agree exactly on the core partition (eight groups each) while labelling it completely differently (`[0,2,4,...,14]` against `[0,1,...,7]`). The "disagreement" a reduction would resolve is between *dictionaries*, not about the machine, and reducing would have to pick a label arbitrarily while discarding the other source's. Under membership identity the common case costs nothing -- one relation, two observations -- and a genuine contradiction stays representable. See the detail section below. | | D-16 | **Collection retries until coherent, and what survives a retry is a genuine disagreement.** There is no transactional way to read the two Win32 sources together, so `discover()` validates them against each other and, on incoherence, **re-collects** -- bounded, and cheap because both are whole-machine enumerations. Transient incoherence from a hot-add resolves on the next pass; incoherence that survives is not transience and must be represented. Retry is therefore the *discriminator* between the two, which no single observation can be. And because the data can change while it is being collected, a topology states plainly whether it was collected coherently, so a reader knows how far its parts may be correlated. | +| D-17 | **Persistent incoherence between two Win32 sources is expected in the field, not exotic -- so the crate reports it precisely and never refuses over it.** No documented guarantee of cross-API coherence has been found, the two enumerations plausibly derive by different paths, and the firmware tables behind them are populated incrementally against scenarios that do not necessarily include ours. The likely places to meet it are therefore hardware we do not have and prerelease hardware with defective UEFI tables -- exactly where refusing would make the crate useless, and exactly where a precise report is worth more than a correct-looking answer. It is also **only testable synthetically**, which is what the hand-built and deserialized paths are for. | ## D-12: provenance, and why the default points at distrust @@ -305,6 +306,61 @@ that the disagreement is genuine, and the point at which the conflict representa ([MMT-1.2](CHECKLIST.md)'s shapes A and B) applies. The two are one mechanism: retry to remove the transient cases, then represent whatever is left. +## D-17: incoherence in the field, and what it demands + +*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.2.* + +### Windows is the oracle, and an oracle is not a proof + +There is no alternative to treating Windows as authoritative about the machine -- nothing else can +see what it sees. But "authoritative" and "internally consistent across every API" are different +claims, and only the first is available. + +**No documented guarantee of cross-API coherence has been found.** That is a statement about what we +could establish, not proof that none exists; the point is that we may not rely on one we cannot cite. + +The mechanism for divergence is plausible and specific. +`GetLogicalProcessorInformationEx` derives from the ACPI tables and CPUID. +`GetSystemCpuSetInformation` is the kernel's much later scheduler-side abstraction. They very likely +share a root, but not a derivation path -- and the newest facts, hybrid efficiency classes and the +last-level-cache index, are exactly where one path may be updated and the other not. + +Underneath both, the firmware tables are **populated incrementally**, by vendors, against the +scenarios those vendors test. A shard-per-core NUMA-aware runtime interrogating two topology APIs and +cross-checking them is not a scenario anyone is likely to have tested. + +### So this is expected, not exotic + +The earlier framing of a persistent disagreement as "a defective hypervisor" made it sound rare +enough to handle badly. The realistic sources are much more ordinary: + +- **hardware we do not have**, where nothing has ever cross-checked the two paths; +- **prerelease hardware with defective UEFI tables**, which is a normal stage of a platform's life + rather than a fault condition; +- **new topology features** arriving in one enumeration before the other. + +None of those are transient, so [D-16](#d-16)'s retry will not clear them. They land, by construction, +in the bucket of things the model must represent -- and that bucket is likely to be populated on +exactly the machines a user cares most about getting right. + +### What that demands + +**Refusing is out**, and not merely as a preference. The places this is most likely to occur are the +places where the crate refusing would make it useless -- new hardware, being brought up, by someone +who needs to know what the machine looks like. A library that declines to describe a machine because +two of Windows's own APIs disagree has converted a report into an outage. + +**A report has to be precise enough to act on.** "Incoherent" is not actionable; "these two sources +disagree about which core processor 6 belongs to, one saying X and the other Y" is a bug report +against a firmware table or an OS enumeration. That is a genuinely useful thing for this crate to +produce, and it is only possible because [D-15](#d-15) keeps both observations rather than reconciling +them -- **a disagreement cannot be reported after it has been collapsed.** + +**And it is testable only synthetically.** By definition this arises on hardware we do not have, so +the hand-built and deserialized construction paths are how the incoherent cases get exercised at all. +That is a second, unanticipated justification for a facility this crate already has and already +documents as a feature. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was From 87bc3c8a9ec0419af3bb7a46d99c77cdcc261d4f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 23:27:31 -0400 Subject: [PATCH 253/361] docs: split recording from reporting, and give reporting to the probe I had written into D-17 that the topology crate should produce something precise enough to file a bug. That is the same layering error as outermost_partitioning_cache: a downstream concern placed in the crate that states facts. The engineer corrected it -- record the data available; recording the inconsistency is adequate. So D-17 now says the topology records what each source claimed, and reporting belongs to the probe tools, along with the identifying provenance an actionable report needs. Also made explicit that D-16 and D-17 are orthogonal: one is data shifting while it is collected and is fixed by collecting again, the other is stably inconsistent data that no retry helps and the model writes down. They share a detection mechanism and nothing else. Opened M7 in the placement tool for the reporting half. PT-7.1 surfaces the recorded inconsistencies, which nothing in the workspace currently looks at, and marks an inconsistent machine as a valid and arguably more valuable submission rather than rejecting it. PT-7.2 adds mainboard and BIOS provenance, suppressible with the suppression recorded, following MachineDescription's existing pattern. That tool already carries the review this needs, which is why it is the right home: the runner sees real values before sending, the README lists what is collected, and suppression is recorded rather than merely absent. It also already establishes that suppression must not be oversold, that a pre-release part is identified at least as well by its topology, and that the tool cannot make an NDA machine safe to submit from. Firmware provenance sits under that same caveat and arguably deepens it, since a BIOS version can pin a board revision more precisely than a CPU model names a part. --- CHECKLIST-placement-tool.md | 33 +++++++++++++++++++ crates/windows-topology-sys/CHECKLIST.md | 24 ++++++++------ crates/windows-topology-sys/DESIGN-NOTES.md | 35 ++++++++++++++++----- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 43d6ba9d..3127f42c 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -614,3 +614,36 @@ so this is not a corner case there, it is the common one. a real result here** -- "the sets behaved equivalently under both interference models, and here are the migration counts showing the scheduler was genuinely exercised" retires a long-standing doubt, and is worth as much as a difference would be. + +## M7: report what Windows contradicts about itself + +Opened by [D-17](crates/windows-topology-sys/DESIGN-NOTES.md) in the topology crate, which establishes +that two Win32 topology sources can be **stably** inconsistent -- and that this is expected on hardware +we do not have and on prerelease firmware, rather than being exotic. + +The division of labour is deliberate and follows the same facts-versus-policy line the rest of this +workspace uses. **`windows-topology-sys` records the disagreement**; it does not report it, because a +crate that states facts should not be in the business of producing bug reports. **This tool reports +it**, because reporting is what this tool is for, and because the provenance that makes such a report +actionable is identifying and therefore belongs behind the review this tool already applies. + +- [ ] **PT-7.1** -- **Surface the topology's recorded inconsistencies**, in the tool's output and in + the submission record. This is the only place they become visible to anyone: the topology crate keeps + what each source said, and nothing in the workspace currently looks at it. + Report what disagreed and what each source claimed, not merely that something did -- "incoherent" is + not actionable, and the point of collecting from strangers' machines is to learn something specific + about hardware nobody here can buy. + An inconsistent machine is **still a valid submission**, and should be marked rather than rejected; + it is arguably a *more* valuable one, since it is evidence of something no local run can produce. + +- [ ] **PT-7.2** -- **Add the firmware provenance an inconsistency report needs to be actionable** -- + mainboard and BIOS version at minimum -- suppressible by the runner, with the suppression recorded + rather than merely absent, exactly as `MachineDescription`'s model handling already does. + **Weigh it against the existing honesty about what suppression buys.** This checklist already + establishes that the flag "must not be oversold", that a pre-release part "is identified at least as + well by its topology", and that the tool "cannot make an NDA-covered machine safe to submit from, and + must not imply that it can". Firmware provenance sits under that same caveat and arguably deepens it: + a BIOS version can pin a specific board revision more precisely than a CPU model names a part. + So the honest framing is unchanged rather than weakened -- if the hardware is confidential, the right + answer remains not to send it -- but the README's list of what is collected must grow to match, per + `PT-4.3`, and the runner must still see the real values before deciding, per `PT-4.5`. diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 1aebec37..d1d40ec5 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -141,16 +141,20 @@ wrongly after code exists. settled by [D-16](DESIGN-NOTES.md#d-16); only the number is open, and it is small -- a couple of passes failing to find a coherent set is not plausible. - 3. **How a topology states its coherence** -- and [D-17](DESIGN-NOTES.md#d-17) raises the bar on - this from "say whether" to "say what, precisely enough to act on". Persistent disagreement is - expected on hardware we do not have and on prerelease firmware, so the report is not a corner - case: it is the crate's output on exactly the machines a user most needs to understand. - "Incoherent" is not actionable. *"These two sources disagree about which core processor 6 belongs - to, one saying X and the other Y"* is a bug report against a firmware table or an OS enumeration. - A reader also needs it to know how far the parts may be **correlated**, which is a different - question from whether any one part is accurate. - Note this is only possible because [D-15](DESIGN-NOTES.md#d-15) keeps both observations: a - disagreement cannot be reported after it has been collapsed. + 3. **How a topology records its coherence.** *Records*, not reports -- an earlier draft of this item + said "precisely enough to file a bug", which put a downstream concern in the crate that states + facts, the same layering error as `outermost_partitioning_cache`. + What is required is what each source said, kept rather than collapsed, so a reader can tell how + far the parts may be **correlated** -- a different question from whether any one part is accurate. + Turning that into something actionable, with the identifying provenance an actionable report + needs, is the probe tools' job and is tracked as **M7** in + [CHECKLIST-placement-tool.md](../../CHECKLIST-placement-tool.md). + > **-> CROSS-COMPONENT HANDOFF:** the reporting half is `PT-7.1` and `PT-7.2` in + > [CHECKLIST-placement-tool.md](../../CHECKLIST-placement-tool.md). That tool already carries the + > review this needs -- the runner sees real values before sending (`PT-4.5`), the README lists what + > is collected (`PT-4.3`), and suppression is recorded rather than merely absent. + Only possible because [D-15](DESIGN-NOTES.md#d-15) keeps both observations: a disagreement cannot + be reported after it has been collapsed. 4. **Shape B still has no representation.** `(kind, membership)` identity does not reach a per-processor scalar disagreement, and that gap is untouched by any of the above. diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index d2715828..02130115 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -36,7 +36,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-14 | **Windows's `LastLevelCacheIndex` is not `MachineMemoryTopology::outermost_partitioning_cache`, and neither is wrong.** Measured on the x64 development host: CPU Sets reports **one** LLC group over all sixteen processors, while the derivation reports **eight** partitions at L2. Windows names the *last* level; the derivation names the outermost level that *divides*. They answer different questions, so neither may be substituted for the other, and a consumer treating the CPU-set value as "the cache domain" would collapse eight groups into one on that machine. | | D-15 | **A relation is identified by its `(kind, membership)`, not by any source's label -- so several observations of one relation are held as a *set*, never reduced on insert.** Measured, not assumed: the two Win32 sources agree exactly on the core partition (eight groups each) while labelling it completely differently (`[0,2,4,...,14]` against `[0,1,...,7]`). The "disagreement" a reduction would resolve is between *dictionaries*, not about the machine, and reducing would have to pick a label arbitrarily while discarding the other source's. Under membership identity the common case costs nothing -- one relation, two observations -- and a genuine contradiction stays representable. See the detail section below. | | D-16 | **Collection retries until coherent, and what survives a retry is a genuine disagreement.** There is no transactional way to read the two Win32 sources together, so `discover()` validates them against each other and, on incoherence, **re-collects** -- bounded, and cheap because both are whole-machine enumerations. Transient incoherence from a hot-add resolves on the next pass; incoherence that survives is not transience and must be represented. Retry is therefore the *discriminator* between the two, which no single observation can be. And because the data can change while it is being collected, a topology states plainly whether it was collected coherently, so a reader knows how far its parts may be correlated. | -| D-17 | **Persistent incoherence between two Win32 sources is expected in the field, not exotic -- so the crate reports it precisely and never refuses over it.** No documented guarantee of cross-API coherence has been found, the two enumerations plausibly derive by different paths, and the firmware tables behind them are populated incrementally against scenarios that do not necessarily include ours. The likely places to meet it are therefore hardware we do not have and prerelease hardware with defective UEFI tables -- exactly where refusing would make the crate useless, and exactly where a precise report is worth more than a correct-looking answer. It is also **only testable synthetically**, which is what the hand-built and deserialized paths are for. | +| D-17 | **Persistent incoherence between two Win32 sources is expected in the field, not exotic -- so the crate *records* it and never refuses over it.** No documented guarantee of cross-API coherence has been found, the two enumerations plausibly derive by different paths, and the firmware tables behind them are populated incrementally against scenarios that do not necessarily include ours. The likely places to meet it are hardware we do not have and prerelease hardware with defective UEFI tables -- exactly where refusing would make the crate useless. **Recording is this crate's job; reporting is the probe tools'**, and the identifying provenance an actionable report needs lives there too, behind the review the probe already applies. Orthogonal to [D-16](#d-16), which is about data shifting *while* it is collected. Only testable synthetically. | ## D-12: provenance, and why the default points at distrust @@ -343,24 +343,45 @@ None of those are transient, so [D-16](#d-16)'s retry will not clear them. They in the bucket of things the model must represent -- and that bucket is likely to be populated on exactly the machines a user cares most about getting right. -### What that demands +### What that demands, and of whom **Refusing is out**, and not merely as a preference. The places this is most likely to occur are the places where the crate refusing would make it useless -- new hardware, being brought up, by someone who needs to know what the machine looks like. A library that declines to describe a machine because two of Windows's own APIs disagree has converted a report into an outage. -**A report has to be precise enough to act on.** "Incoherent" is not actionable; "these two sources -disagree about which core processor 6 belongs to, one saying X and the other Y" is a bug report -against a firmware table or an OS enumeration. That is a genuinely useful thing for this crate to -produce, and it is only possible because [D-15](#d-15) keeps both observations rather than reconciling -them -- **a disagreement cannot be reported after it has been collapsed.** +**This crate records the inconsistency. It does not report it.** An earlier draft of this decision +said the crate should produce something "precise enough to file a bug", which is the same layering +error as `outermost_partitioning_cache`: a downstream concern written into the crate that states +facts. Recording is what is required here, and it is adequate -- what each source said, kept as +[D-15](#d-15)'s set, because **a disagreement cannot be reported after it has been collapsed.** + +**Reporting belongs to the probe tools**, which already exist for turning a machine into something a +person can act on. That is also where the *provenance* an actionable report needs -- mainboard, +firmware version, and the like -- belongs, and it must stay there: those fields are potentially +identifying, so they are subject to the same review the probe already applies. + +That review is not new machinery to build. `windows-placement-probe`'s submission path is built on +"the paste is the channel" -- a human copies the output and pastes it deliberately, so they see what +they are sending -- and `MachineDescription::read` already takes a `suppress_model` flag, recording +in `model_suppressed` *that* a field was withheld rather than leaving it inferred from absence, which +is [D-13](#d-13) again. New provenance follows that pattern rather than inventing one. **And it is testable only synthetically.** By definition this arises on hardware we do not have, so the hand-built and deserialized construction paths are how the incoherent cases get exercised at all. That is a second, unanticipated justification for a facility this crate already has and already documents as a feature. +### Orthogonal to D-16, and worth keeping separate + +[D-16](#d-16) is about data **shifting while it is collected** -- transient, resolved by collecting +again. This decision is about data that is **stably inconsistent** -- no retry helps, and the model +records it. + +They share a detection mechanism and nothing else. Conflating them would make one look like a +special case of the other, and the responses are opposite: one is fixed by asking again, the other by +writing down what was seen. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was From 4554ca9b44ca67b7b498ebc33e0115d2aab764cd Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 23:35:13 -0400 Subject: [PATCH 254/361] docs(topology): close MMT-1.2 with D-18 -- an observation is (subject, claim, source) D-15 identified a relation by (kind, membership), which reaches a partition disagreement and does not reach the other shape at all. Efficiency class is the example: GLPIE reports it on the owning core domain, CPU Sets per processor, and if they differ there is no membership to compare because the subject is one processor and one attribute. Generalise the subject rather than inventing a second mechanism. An observation is (subject, claim, source), where a subject is either a relation identity or a processor attribute. Everything above it is unchanged -- observations of one subject are a set, agreement is one subject observed twice, disagreement is a set with more than one distinct claim. D-15 had simply described the subject too narrowly, having been derived from the one case measurable at the time. Two smaller answers ride along. A topology records that collection concluded incoherently, which is a fact about the process that nothing in the data expresses; and it records which subjects disagreed, which is derivable but recorded anyway, because leaving a fact to be re-derived is exactly what SH-16.9 documents going wrong three times in two different ways. A rendered report is not recorded -- D-17 puts that in the probe tools. The bound is a small documented constant, cheap even when exhausted. Its meaning was already settled by D-16: exhaustion is the conclusion that the disagreement is genuine, not a failure to collect. Also recorded the engineer's framing that an incoherent machine description is two-sided -- valuable in the long run as a potential firmware bug report, an annoyance in the short run -- replacing my one-sided 'arguably more valuable'. The tool should mark it clearly without dressing up a nuisance as a prize. --- CHECKLIST-placement-tool.md | 10 +++- crates/windows-topology-sys/CHECKLIST.md | 31 +++++++++++- crates/windows-topology-sys/DESIGN-NOTES.md | 55 +++++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 3127f42c..7d0d0ec2 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -633,8 +633,14 @@ actionable is identifying and therefore belongs behind the review this tool alre Report what disagreed and what each source claimed, not merely that something did -- "incoherent" is not actionable, and the point of collecting from strangers' machines is to learn something specific about hardware nobody here can buy. - An inconsistent machine is **still a valid submission**, and should be marked rather than rejected; - it is arguably a *more* valuable one, since it is evidence of something no local run can produce. + An inconsistent machine is **still a valid submission**, and should be marked rather than rejected. + **Its value is genuinely two-sided, and the tool should not pretend otherwise.** In the long run it + is the more valuable submission -- evidence of something no local run can produce, and potentially a + bug report against a firmware table. In the short run it is an **annoyance**: a run whose numbers a + reader must qualify, from a machine whose description cannot be taken at face value. + So mark it plainly enough that a runner is not left wondering whether their machine is broken or + their run is wasted, and plainly enough that a reader of the submission knows which parts to trust -- + without dressing up a nuisance as a prize. - [ ] **PT-7.2** -- **Add the firmware provenance an inconsistency report needs to be actionable** -- mainboard and BIOS version at minimum -- suppressible by the runner, with the suppression recorded diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index d1d40ec5..6ef72388 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -28,7 +28,7 @@ Presence and observation are facts to represent, not shapes to infer from. | Milestone | State | What it is waiting on | |---|---|---| -| M1 settle what is still open | 1 of 5 done | nothing -- these are decisions, and they gate the rest | +| M1 settle what is still open | 2 of 5 done | nothing -- these are decisions, and they gate the rest | | M2 the granularity model | parked | M1 | | M3 observation and provenance | parked | M1 | | M4 the queries | parked | M2, M3 | @@ -60,7 +60,7 @@ wrongly after code exists. matchable and the exact value `Processor::capacity`'s sentinel is indistinguishable from, so that row confirms nothing. A hybrid, multi-node machine would test all three; none is available. -- [ ] **MMT-1.2** -- **What a query returns when observations differ.** ~~And there are three cases, +- [x] **MMT-1.2** -- **What a query returns when observations differ.** ~~And there are three cases, not two~~ -- **the third case dissolved.** [D-14](DESIGN-NOTES.md#d-14) found that CPU Sets reports one last-level-cache group where the derivation reports eight L2 partitions, neither wrong because they answer **different questions**, and this item was going to have to invent vocabulary for it. @@ -159,6 +159,33 @@ wrongly after code exists. 4. **Shape B still has no representation.** `(kind, membership)` identity does not reach a per-processor scalar disagreement, and that gap is untouched by any of the above. + ### Closed by [D-18](DESIGN-NOTES.md#d-18) + + **Shape B (4):** an observation is `(subject, claim, source)`, and a **subject** is either a relation + identity `(kind, membership)` or a processor attribute `(processor, attribute)`. The mechanism above + it is unchanged -- observations of one subject are a set, agreement is one subject observed twice, + disagreement is a set with more than one distinct claim. So the second shape needs no second + mechanism. D-15 had simply described the subject too narrowly, having been derived from the one case + that was measurable at the time. + + **Recording coherence (3):** two facts, one derivable and one not. *That* collection concluded + incoherently is a fact about the process -- the retry ran, the bound was exhausted, the sources still + disagreed -- and nothing in the data says so, so it is recorded. *Which* subjects disagreed is + derivable, and is recorded anyway: leaving it to be re-derived is exactly the arrangement `SH-16.9` + documents going wrong three times in two different ways. A rendered report is **not** recorded; per + [D-17](DESIGN-NOTES.md#d-17) that belongs to the probe tools. + + **The bound (2):** a small documented constant, cheap even when exhausted, since a persistently + inconsistent machine pays only a few extra whole-machine enumerations. Its meaning was already + settled by [D-16](DESIGN-NOTES.md#d-16) -- exhaustion is the **conclusion** that the disagreement is + genuine, not a failure to collect, and `discover()` still returns a topology. + + **What made this closable** was not one insight but the arsenal accumulating: D-15 gave the identity, + D-16 removed the transient cases so only proved-genuine ones needed representing, D-17 moved + reporting out of the crate, and D-18 widened D-15's subject. Three of the item's four questions + dissolved rather than being answered -- one already covered by a prior decision, one forced by the + retry mechanism, one a constant with a rationale. + Refusing outright remains rejected on its own merits: a genuine inconsistency is something a caller would rather be told about and route around than be unable to run at all. diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 02130115..9df1bb7c 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -37,6 +37,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-15 | **A relation is identified by its `(kind, membership)`, not by any source's label -- so several observations of one relation are held as a *set*, never reduced on insert.** Measured, not assumed: the two Win32 sources agree exactly on the core partition (eight groups each) while labelling it completely differently (`[0,2,4,...,14]` against `[0,1,...,7]`). The "disagreement" a reduction would resolve is between *dictionaries*, not about the machine, and reducing would have to pick a label arbitrarily while discarding the other source's. Under membership identity the common case costs nothing -- one relation, two observations -- and a genuine contradiction stays representable. See the detail section below. | | D-16 | **Collection retries until coherent, and what survives a retry is a genuine disagreement.** There is no transactional way to read the two Win32 sources together, so `discover()` validates them against each other and, on incoherence, **re-collects** -- bounded, and cheap because both are whole-machine enumerations. Transient incoherence from a hot-add resolves on the next pass; incoherence that survives is not transience and must be represented. Retry is therefore the *discriminator* between the two, which no single observation can be. And because the data can change while it is being collected, a topology states plainly whether it was collected coherently, so a reader knows how far its parts may be correlated. | | D-17 | **Persistent incoherence between two Win32 sources is expected in the field, not exotic -- so the crate *records* it and never refuses over it.** No documented guarantee of cross-API coherence has been found, the two enumerations plausibly derive by different paths, and the firmware tables behind them are populated incrementally against scenarios that do not necessarily include ours. The likely places to meet it are hardware we do not have and prerelease hardware with defective UEFI tables -- exactly where refusing would make the crate useless. **Recording is this crate's job; reporting is the probe tools'**, and the identifying provenance an actionable report needs lives there too, behind the review the probe already applies. Orthogonal to [D-16](#d-16), which is about data shifting *while* it is collected. Only testable synthetically. | +| D-18 | **An observation is `(subject, claim, source)`, where a subject is either a relation identity or a processor attribute -- which closes the last gap in [D-15](#d-15).** Membership identity reaches partition disagreements but not per-processor scalars like efficiency class; generalising the *subject* rather than inventing a second mechanism covers both with one rule. Two smaller answers ride along: a topology records **that** collection concluded incoherently **and which subjects disagreed** -- the latter because a consumer forced to re-derive the comparison is the SH-16.9 failure repeating -- and the retry bound is a small documented constant whose exhaustion is a *conclusion*, not a failure. | ## D-12: provenance, and why the default points at distrust @@ -382,6 +383,60 @@ They share a detection mechanism and nothing else. Conflating them would make on special case of the other, and the responses are opposite: one is fixed by asking again, the other by writing down what was seen. +## D-18: what an observation is, and the last of MMT-1.2 + +*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.2, which this closes.* + +### The gap D-15 left + +[D-15](#d-15) identified a relation by `(kind, membership)` and held several observations of one +relation as a set. That reaches a **partition** disagreement -- two sources grouping processors +differently under the same kind -- and it does not reach the other shape at all. + +Efficiency class is the example. `GetLogicalProcessorInformationEx` reports it on the owning core +domain; CPU Sets reports it per processor. If they differ, there is no membership to compare: the +subject is one processor and one attribute, not a set of processors. + +### Generalise the subject, not the mechanism + +An **observation is `(subject, claim, source)`**, where a subject is either + +- a **relation identity** -- `(kind, membership)`, per D-15 -- or +- a **processor attribute** -- `(processor, attribute)`. + +The mechanism above it does not change. Observations of one subject are held as a set; agreement is +one subject observed twice; disagreement is a set with more than one distinct claim. So the second +shape needs no second mechanism, and a query answers both the same way. + +This is what "hold a set" always meant; D-15 simply described the subject too narrowly, having been +derived from the one case that was measurable at the time. + +### A topology records that it concluded incoherently, and which subjects disagreed + +Two facts, and only one of them is derivable. + +**That collection concluded incoherently is not derivable.** It is a fact about the *process* -- the +retry ran, the bound was exhausted, and the sources still disagreed -- and nothing in the data says +so. It has to be recorded. + +**Which subjects disagreed is derivable, and is recorded anyway.** A consumer could find them by +comparing the observations itself. That is precisely the arrangement `SH-16.9` documents going wrong: +a fact left to be re-derived was re-derived three times, in two different ways. Recording the list +costs almost nothing and removes the reason to reconstruct it. + +What is *not* recorded is a rendered report. [D-17](#d-17) puts that in the probe tools, along with +the identifying provenance that makes a report actionable. + +### The bound is a constant with a rationale + +The retry bound is small -- a couple of passes failing to find a coherent set is not plausible for a +transient -- and it is cheap even when exhausted, since a persistently inconsistent machine pays only +a few extra whole-machine enumerations per `discover()`. + +Its *meaning* is the part worth writing down, and [D-16](#d-16) already did: exhausting the bound is +not a failure to collect, it is the **conclusion** that the disagreement is genuine. `discover()` +still returns a topology. The bound is where transience stops being a possible explanation. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was From 3497c2fc5344437ed09a4396090483bea5626b77 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Wed, 2 Sep 2026 23:43:00 -0400 Subject: [PATCH 255/361] docs(topology): name the two conflict shapes instead of labelling them A and B The two shapes are defined descriptively where MMT-1.2 introduces them (partition conflict, attribute conflict), but every later reference dropped the descriptive half and carried only the bare letter, which is opaque to a reader arriving at the reference first. Completed item: (no checklist item -- editorial follow-up to MMT-1.2) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 10 +++++----- crates/windows-topology-sys/DESIGN-NOTES.md | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 6ef72388..b0070882 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -137,9 +137,9 @@ wrongly after code exists. scope would genuinely have been a knob. `D-16` removed the knob. 2. **The bound**, and what exhausting it *means* -- not a failure to collect, but the **conclusion** - that the disagreement is genuine, and the point at which shapes A and B apply. The *meaning* is - settled by [D-16](DESIGN-NOTES.md#d-16); only the number is open, and it is small -- a couple of - passes failing to find a coherent set is not plausible. + that the disagreement is genuine, and the point at which the partition and attribute shapes + apply. The *meaning* is settled by [D-16](DESIGN-NOTES.md#d-16); only the number is open, and it + is small -- a couple of passes failing to find a coherent set is not plausible. 3. **How a topology records its coherence.** *Records*, not reports -- an earlier draft of this item said "precisely enough to file a bug", which put a downstream concern in the crate that states @@ -156,12 +156,12 @@ wrongly after code exists. Only possible because [D-15](DESIGN-NOTES.md#d-15) keeps both observations: a disagreement cannot be reported after it has been collapsed. - 4. **Shape B still has no representation.** `(kind, membership)` identity does not reach a + 4. **The attribute shape has no representation.** `(kind, membership)` identity does not reach a per-processor scalar disagreement, and that gap is untouched by any of the above. ### Closed by [D-18](DESIGN-NOTES.md#d-18) - **Shape B (4):** an observation is `(subject, claim, source)`, and a **subject** is either a relation + **The attribute shape (4):** an observation is `(subject, claim, source)`, and a **subject** is either a relation identity `(kind, membership)` or a processor attribute `(processor, attribute)`. The mechanism above it is unchanged -- observations of one subject are a set, agreement is one subject observed twice, disagreement is a set with more than one distinct claim. So the second shape needs no second diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 9df1bb7c..85d4c24e 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -304,7 +304,7 @@ shape. The retry is bounded. Exhausting the bound is not a failure to collect -- it is the **conclusion** that the disagreement is genuine, and the point at which the conflict representation -([MMT-1.2](CHECKLIST.md)'s shapes A and B) applies. The two are one mechanism: retry to remove the +([MMT-1.2](CHECKLIST.md)'s partition and attribute shapes) applies. The two are one mechanism: retry to remove the transient cases, then represent whatever is left. ## D-17: incoherence in the field, and what it demands From 9c9db70c76dc8a1796232ba25ae8971afa2d9766 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 00:08:01 -0400 Subject: [PATCH 256/361] docs(topology): present a unified view when the sources align (D-19) The design being assembled had every answer carry a coherence state, so no caller could read a value without considering that the two Win32 sources might have disagreed about it. That is the sentinel mistake in another form: it lets a case that essentially never happens shape every caller on every machine, and it turns a local defect global -- one contested core would present the whole machine as uncertain. D-15 had already concluded the opposite ("a set costs nothing in the common case ... the feared duplication does not materialise where the sources agree, which is the usual case") and was simply not applied. Under (kind, membership) identity the unification is free: agreeing sources have observed one relation, so there is no merge step, and the individual per-source views hang off it rather than being replaced by it. Consequences recorded: a contested subject is a relation the unified view does not cover, which is D-13's not-observed, so MMT-1.3 and EP-1.4 become one decision rather than one per reason a fact is missing (M3+.4 is answered outright, with "both"); relations must carry attributes as well as memberships, or Memory { memory_bytes } has nowhere to live (new M2+.6); and M4+.1 is re-cut so the ordered collection is the query surface with the pairwise helper derived from it -- the requirement itself asked the answer to carry the block containing both processors, which makes it a question about the partition, and a pairwise-primary surface would re-create the SH-16.9 reconstruction one level up. Swept "pairwise" across both components: 21 mentions, 3 updated, the rest either are_pairwise_disjoint or the design session, which is an append-only Tier 3 record and is corrected in the checklists that cite it rather than rewritten. Completed item: (design decision recorded mid-M1; MMT-1.3 narrowed, M3+.4 closed) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-execution-plan/CHECKLIST.md | 11 ++++ crates/windows-execution-plan/DESIGN-NOTES.md | 7 +- crates/windows-topology-sys/CHECKLIST.md | 49 +++++++++++--- crates/windows-topology-sys/DESIGN-NOTES.md | 66 +++++++++++++++++++ 4 files changed, 124 insertions(+), 9 deletions(-) diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/windows-execution-plan/CHECKLIST.md index b1e454c8..dcebf71a 100644 --- a/crates/windows-execution-plan/CHECKLIST.md +++ b/crates/windows-execution-plan/CHECKLIST.md @@ -112,6 +112,11 @@ whether the topology can answer it today -- so the model is designed against a r filed independently before anyone noticed. Taken separately they can disagree: a planner that degrades in a way the model does not support, or a model offering a fallback no consumer wants. Answer them together, in the session. + **Narrowed by [D-19](../windows-topology-sys/DESIGN-NOTES.md#d-19).** The item says "decide per + query", and that is now more work than the model requires. A subject the two sources genuinely + contested is one the unified view does not cover, which is indistinguishable from not-observed to a + consumer -- so this is one decision about one degradation path, not one per reason a fact is + missing. The three candidate behaviours are unchanged. - [ ] **EP-1.5** -- **Hand the resulting requirements to the design session** as the consumer-side input it asked for, and record in the session which of them the settled model answers and which @@ -121,6 +126,12 @@ whether the topology can answer it today -- so the model is designed against a r follow from them -- a pairwise query must exist, the order must be total, an answer must be able to be an upper bound, and a measured number must carry what it measured. That was the part the model designer needs in front of them, and it did not depend on the model existing. + **One of the four has since been corrected**, and it is recorded here rather than rewritten in the + session, which is an append-only record of what was handed over. "A pairwise query must exist" is + right about the requirement and wrong about the shape: per + [windows-topology-sys](../windows-topology-sys/CHECKLIST.md) `M4+.1` the ordered collection is the + surface and the pairwise query is derived from it, because an answer obliged to carry the block + containing both processors is a question about the partition rather than about the pair. The *coverage* half -- recording which requirements the settled model answers and which it deliberately does not -- can only be written once there is a settled model. It stays open here. > **-> CROSS-COMPONENT HANDOFF:** next work is in the repository root -> diff --git a/crates/windows-execution-plan/DESIGN-NOTES.md b/crates/windows-execution-plan/DESIGN-NOTES.md index cdc8ac3a..3938e29c 100644 --- a/crates/windows-execution-plan/DESIGN-NOTES.md +++ b/crates/windows-execution-plan/DESIGN-NOTES.md @@ -208,7 +208,12 @@ this query is the cause of that defect, not a separate problem. - A granularity order derived from **observed set inclusion**, not firmware level numbers, so a measured-only tier and a machine with no L3 both have positions. -- A pairwise query over it, returning minimal shared granularities plus their membership. +- Access to that order **as a collection**, with a pairwise helper derived from it, returning minimal + shared granularities plus their membership. Stated here first as a pairwise query, which + [windows-topology-sys](../windows-topology-sys/CHECKLIST.md) `M4+.1` corrected: requiring the answer + to carry the block containing both processors makes it a question about the partition, not the pair, + and a pairwise-primary surface would force the planner into the O(n^2) reconstruction that `SH-16.9` + records going wrong three times. The three *requirements* below are unchanged; only the shape is. - Unobserved granularities represented, so an answer can be an upper bound and say so. - A top element, so the query is total. diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index b0070882..482e8225 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -30,7 +30,7 @@ Presence and observation are facts to represent, not shapes to infer from. |---|---|---| | M1 settle what is still open | 2 of 5 done | nothing -- these are decisions, and they gate the rest | | M2 the granularity model | parked | M1 | -| M3 observation and provenance | parked | M1 | +| M3 observation and provenance | parked | M1 (1 of 4 answered early, by D-19) | | M4 the queries | parked | M2, M3 | | M5 the defects this subsumes | parked | M4 | @@ -206,6 +206,11 @@ wrongly after code exists. - [ ] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that the model answers without further measurement. Degrade to a documented weaker policy, refuse, or answer with an explicit "chosen without knowing X" marker. + **Narrowed by [D-19](DESIGN-NOTES.md#d-19), which is worth noting because it removes two thirds of + the item.** A contested subject was going to need its own answer beside "not observed"; it does + not. The unified view simply does not cover it, and *not covered* is [D-13](DESIGN-NOTES.md#d-13)'s + not-observed. So this is **one** decision about one degradation path -- a fact the consumer needed + and did not get -- rather than a separate answer per reason the fact is missing. > **-> CROSS-COMPONENT PREREQUISITE:** this is the same decision as `EP-1.4` in > [windows-execution-plan](../windows-execution-plan/CHECKLIST.md), seen from the model's side > rather than the consumer's. They were filed independently before anyone noticed. **Take them @@ -248,7 +253,16 @@ Parked on M1. Shape recorded so it is not lost. - [ ] **M2+.5** -- Make absence first-class per [D-13](DESIGN-NOTES.md#d-13): **not observed**, **observed and absent**, and **a negative result** are three different facts that an `Option` - spells identically. + spells identically. Per [D-19](DESIGN-NOTES.md#d-19) this also carries the contested case -- a + subject the sources genuinely disagreed on is one the unified view does not cover, which is *not + observed*, so no fourth state is added. + +- [ ] **M2+.6** -- **Relations carry attributes, not only memberships.** Required by + [D-19](DESIGN-NOTES.md#d-19): once the relation set *is* the unified model, + `DomainKind::Memory { memory_bytes }` and `Core { efficiency_class, simultaneous_multithreading }` + have nowhere to live unless a relation holds a payload alongside its processor set. Nothing else in + M2 provides this, and it was noticed only when the unified view was written down -- the + membership-only framing had quietly assumed relations were bare sets. ## M3: observation and provenance @@ -268,9 +282,14 @@ Parked on M1. ninety-nine measured relations and one synthetic reading `SYNTHETIC` -- or the maximum, which is dishonest. Trust belongs to an *answer*. -- [ ] **M3+.4** -- Carry both observers without merging, per MMT-1.1's decision. `Topology::cpu_sets` +- [x] **M3+.4** -- Carry both observers without merging, per MMT-1.1's decision. `Topology::cpu_sets` already lands this way; this item is whether that stays a parallel list or becomes observations attached to relations. + **Answered by [D-19](DESIGN-NOTES.md#d-19): both.** The question presented the two as alternatives, + and they are not. Observations attach to relations, which is what makes the *unified* view exist at + all; the raw per-source list stays for a caller that wants what one source said, verbatim. That is + what "a unified model in addition to the individual ones" means concretely. No implementation is + owed here -- M2+.6 and M4 carry the surface -- so this item is closed as a decision, not as code. ## M4: the queries @@ -278,11 +297,25 @@ Parked on M2 and M3. Each is a requirement from [windows-execution-plan](../windows-execution-plan/DESIGN-NOTES.md), stated there against a real caller rather than invented here. -- [ ] **M4+.1** -- **Pairwise proximity**, over an **unordered** pair, returning the minimal shared - granularities, **their membership** (so a caller can size an MPSC fan-in without re-deriving the - grouping), and whether a finer granularity went **unobserved** so the answer can be an upper bound - and say so. This is the query with no equivalent today, and its absence is why the partitioning - rule got re-derived three times. +- [ ] **M4+.1** -- **The ordered relations are the query surface; pairwise proximity is a method on + them.** The requirement arrived from [EP-D-2](../windows-execution-plan/DESIGN-NOTES.md#ep-d-2) as a + *pairwise* query returning the minimal shared granularities, **their membership**, and whether a + finer granularity went **unobserved** so the answer can be an upper bound and say so. All three + requirements stand. The **shape** does not, and the requirement says so itself: it asks the answer + to carry the whole block containing both processors, "or the planner asks O(n^2) times and + reconstructs the grouping". An answer that must carry the block is not about the pair -- the pair is + an index into a partition. + Everything the planner does is an operation on the partitions: choosing domain granularity is + *selecting one*, sizing an MPSC fan-in is *a block's cardinality*, choosing a channel is *the finest + block containing both*. Pairwise is three lines over that. The reverse is derivable too, but only by + union-find over O(n^2) queries -- which is exactly the reconstruction `SH-16.9` records three + consumers performing, two of them differently. Building pairwise as the primary surface would ship + the stated requirement and re-create the defect one level up. + Both are provided; **the collection is primary and the pairwise helper is derived from it**, so + there is one implementation of the grouping. + *Terminology:* it is a **poset with a top**, not a lattice -- M2+.4's incomparable granularities + mean meets need not be unique, which is also why a pairwise function has to return a *set* and is + an awkward face on an ordered collection. - [ ] **M4+.2** -- The **shard-set** surface (EP-D-1): identity as `(group, number)`, online, core membership and SMT, efficiency class **without a sentinel**, and availability. diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 85d4c24e..d718e4df 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -38,6 +38,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-16 | **Collection retries until coherent, and what survives a retry is a genuine disagreement.** There is no transactional way to read the two Win32 sources together, so `discover()` validates them against each other and, on incoherence, **re-collects** -- bounded, and cheap because both are whole-machine enumerations. Transient incoherence from a hot-add resolves on the next pass; incoherence that survives is not transience and must be represented. Retry is therefore the *discriminator* between the two, which no single observation can be. And because the data can change while it is being collected, a topology states plainly whether it was collected coherently, so a reader knows how far its parts may be correlated. | | D-17 | **Persistent incoherence between two Win32 sources is expected in the field, not exotic -- so the crate *records* it and never refuses over it.** No documented guarantee of cross-API coherence has been found, the two enumerations plausibly derive by different paths, and the firmware tables behind them are populated incrementally against scenarios that do not necessarily include ours. The likely places to meet it are hardware we do not have and prerelease hardware with defective UEFI tables -- exactly where refusing would make the crate useless. **Recording is this crate's job; reporting is the probe tools'**, and the identifying provenance an actionable report needs lives there too, behind the review the probe already applies. Orthogonal to [D-16](#d-16), which is about data shifting *while* it is collected. Only testable synthetically. | | D-18 | **An observation is `(subject, claim, source)`, where a subject is either a relation identity or a processor attribute -- which closes the last gap in [D-15](#d-15).** Membership identity reaches partition disagreements but not per-processor scalars like efficiency class; generalising the *subject* rather than inventing a second mechanism covers both with one rule. Two smaller answers ride along: a topology records **that** collection concluded incoherently **and which subjects disagreed** -- the latter because a consumer forced to re-derive the comparison is the SH-16.9 failure repeating -- and the retry bound is a small documented constant whose exhaustion is a *conclusion*, not a failure. | +| D-19 | **When the sources align -- which is the usual case -- a *unified* view is presented, in addition to the individual per-source ones.** A design that made every answer carry a coherence state was the sentinel mistake in another form: it let a case that essentially never happens shape every caller on every machine, and it made a *local* defect global, presenting a machine with one contested core as entirely uncertain. [D-15](#d-15) had already concluded the opposite and was simply not applied. Under `(kind, membership)` identity the unification is **free** -- agreeing sources have observed one relation, so there is no merge step -- and a contradiction contests only those processors at that kind. A contested subject needs no new vocabulary: it is a relation the unified view does not cover, which is [D-13](#d-13)'s *not observed*, so a consumer implements one degradation path rather than three. Requires that relations carry **attributes** as well as memberships. | ## D-12: provenance, and why the default points at distrust @@ -437,6 +438,71 @@ Its *meaning* is the part worth writing down, and [D-16](#d-16) already did: exh not a failure to collect, it is the **conclusion** that the disagreement is genuine. `discover()` still returns a topology. The bound is where transience stops being a possible explanation. +## D-19: the unified view, presented in addition to the individual ones + +*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.3. Corrects a pessimistic reading of +[D-15](#d-15) that had been carried into the M2/M4 plan.* + +### The error this corrects + +Asked what the planner needs from this layer, the design being assembled had every answer carry a +coherence state -- clean, not observed, or contested -- so that a caller could never read a value +without considering that the sources might have disagreed about it. + +That is the sentinel mistake in another form. It lets a case that essentially never happens dictate +the shape every caller sees on every machine, and it does something worse than cost ergonomics: **it +makes a local defect global.** If two sources disagree about one core's membership, that says +nothing about the NUMA partition, the other cores, or any efficiency class -- yet a coherence state +threaded through the API would present a machine with one firmware quirk as entirely uncertain. + +**Usually the data aligns.** When it does, a unified model can be presented, *in addition to* the +individual per-source ones rather than instead of them. + +### D-15 had already concluded this + +The correction did not need new evidence, because [D-15](#d-15) states it outright: "A set costs +nothing in the common case. Agreement means one relation carrying two observations, not two competing +relations. The feared duplication does not materialise where the sources agree, which is the usual +case." + +So the pessimistic surface was built on top of a decision that had already taken the optimistic view. +This is [restatement drift](../../DESIGN-NOTES.md#restatement-drift) in its cheapest-to-avoid form -- not a +restatement that went stale, but a conclusion already reached and then not applied. + +### The unification is free, because it happens at identification + +Under `(kind, membership)` identity there is **no merge step**. Two sources that agree have observed +*one* relation, by construction. So: + +- **The unified model is the relation set.** In the usual case it is already the clean model a + consumer wants; nothing had to be reconciled to produce it. +- **The individual views hang off it** as each relation's observations, available for provenance and + diagnosis without standing in the common path. The raw per-source list (`cpu_sets`) stays for a + caller that wants a source verbatim. +- **A contradiction is local.** Two relations of the same kind whose memberships overlap without + being equal contest *those* processors at *that* kind, and nothing else. + +This answers a question M3+.4 had left open -- whether a second observer stays a parallel list or +becomes observations attached to relations -- with **both**, which is what "a unified model in +addition to the individual ones" means concretely. + +### A contested subject needs no new vocabulary + +The unified view is total and clean except over the subjects [D-16](#d-16)'s retry has already named +as genuinely contested. Asking about one of those is asking about a relation the unified view does +not cover, and "not covered" is [D-13](#d-13)'s *not observed* -- not a fourth kind of absence. + +So a consumer implements **one** degradation path, for a fact it needed and did not get, which it +requires anyway for facts no source reported. Whether that path degrades, refuses, or marks the +answer is MMT-1.3's question, and it is one decision rather than three. + +### What this asks of the model + +One thing that is not otherwise provided: **relations carry attributes.** Once the relation set *is* +the unified model, `DomainKind::Memory { memory_bytes }` has nowhere to live unless a relation can +hold a payload as well as a membership. `Core { efficiency_class, simultaneous_multithreading }` is +the same requirement. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was From 14ac8a5d5df3ff22ef91d630c3e44f26e68cd66e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 00:16:23 -0400 Subject: [PATCH 257/361] docs(topology): the Win32 boundary decides distances (D-20) The engineer's ruling on MMT-1.4, and it is a scope boundary rather than a judgement about the field: this crate does not go below the Win32 topology APIs, so if they do not report a fact, the crate does not have it. ACPI carries SLIT and no Win32 API surfaces it, so inter-node distance is not a fact this crate can state and MachineMemoryTopology::distances is deleted rather than filled. Stating it as a boundary is the durable part -- it settles the next "Windows does not expose X but the firmware does" question in advance. Two supporting findings, neither offered as the reason: distances could never carry Measured provenance by construction (hand-construction is Synthetic, deserialization only downgrades per D-12), and it has zero read sites -- windows-platform-probes' render_node_distances reads the probe's own measured Observation, not this field. What is given up is named rather than skated past: D-10's platform-neutral description can no longer carry Linux SLIT data. That was a real capability, and the two-component split is what makes losing it acceptable -- distance reaches a planner through the synthesizer's measurement, which a fed-in description cannot substitute for, so the "measurement refused, inherit from the description" fallback is exactly what this rules out. Removal spawned as M5+.5, explicitly ungated on M4 since the reshape is not what fixes this one. SH-16.11 marked answered in the opposite direction to what it proposed. Completed item: MMT-1.4: Does `distances` survive at all? Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 8 ++- crates/windows-topology-sys/CHECKLIST.md | 26 +++++++-- crates/windows-topology-sys/DESIGN-NOTES.md | 60 +++++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index d13ec633..cdba269f 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -941,7 +941,13 @@ predicted about a 222-commit branch. sentinel**, so it is a cleaner source for the field whose `capacity` encoding collides with "unknown". -- [ ] **SH-16.11** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **`MachineMemoryTopology::distances` is a field for a fact Win32 cannot supply, it is never +- [ ] **SH-16.11** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** + **And now ANSWERED, in the opposite direction to what this item proposed.** + [D-20](crates/windows-topology-sys/DESIGN-NOTES.md#d-20) rules that the crate does not go below the + Win32 topology APIs, so a fact Win32 does not report is not one the crate has: `distances` is + **deleted, not filled**. The removal is `M5+.5` in + [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md). Everything + below is the reasoning that led there and is kept for that; it no longer describes work. **`MachineMemoryTopology::distances` is a field for a fact Win32 cannot supply, it is never populated, and the measurement that would fill it already exists elsewhere.** `discover()` hardcodes `distances: None`, every other construction sets `None`, and no consumer reads the field. Windows exposes no API for NUMA node distance -- ACPI carries SLIT, Win32 does not surface diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 482e8225..2567d1ef 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -217,12 +217,19 @@ wrongly after code exists. > together** -- answering either alone risks a planner that degrades in a way the model does not > support, or a model offering a fallback no consumer wants. -- [ ] **MMT-1.4** -- **Does `distances` survive at all?** The two-component architecture says the +- [x] **MMT-1.4** -- **Does `distances` survive at all?** The two-component architecture says the *synthesizer* measures, with the caller's permission, for its own scenario -- so a measured number is its working state and its justification for a choice, not a property of the machine. That reverses this session's earlier conclusion that measured facts must live in the model, which assumed a single component. If it holds, `distances` is **deleted rather than filled**, which is the opposite of what the release checklist proposed. Decide before removing anything. + **Decided by the engineer, and the ruling is a scope boundary rather than a judgement about the + field: this crate does not go below the Win32 topology APIs, so if they do not provide distance + data, we do not have distance data.** Recorded as [D-20](DESIGN-NOTES.md#d-20). `distances` is + deleted. + **What the check found:** zero read sites. `render_node_distances` in `windows-platform-probes` + reads the *probe's own* measured `Observation`, not this field, so the one thing that looked like a + consumer is not one. Removal is spawned as **M5+.5** and is not gated on the reshape. - [ ] **MMT-1.5** -- **Does the synthesizer live in this crate, and therefore what is this crate called?** Recorded as open rather than settled: see @@ -330,8 +337,9 @@ caller rather than invented here. ## M5: the defects this subsumes -Parked on M4. Each already exists as a defect; the reshape is what fixes them, so they are listed -here rather than fixed separately and then re-fixed. +Parked on M4, **except M5+.5, which is independent and ready now**. Each of the others already +exists as a defect that the reshape is what fixes, so they are listed here rather than fixed +separately and then re-fixed. - [ ] **M5+.1** -- `Processor::capacity` uses `0` as both a legitimate efficiency class and a "not known" sentinel, and the two collide on **every non-hybrid machine**. Worse than an ambiguous @@ -348,3 +356,15 @@ here rather than fixed separately and then re-fixed. - [ ] **M5+.4** -- `windows-placement-probe` **refuses a partially-covering cache level** that this crate deliberately hands back, failing an entire measurement run over a topology this crate considers describable. M2+.5 gives it the vocabulary to accept one. + +- [ ] **M5+.5** -- **Delete `MachineMemoryTopology::distances` and the `Distances` type**, per + [D-20](DESIGN-NOTES.md#d-20). **Not gated on M4**: the reshape does not fix this one, deletion + does, so it does not wait for the rest of M5. + A **breaking change to a published crate** (0.1.0), so the commit takes the Conventional Commits + `!` marker. It is not a *parse* break -- the crate does not `deny_unknown_fields`, so a description + carrying `"distances"` still deserializes and the field is ignored. + Two things to do rather than skip: keep the Linux-shaped description test, retargeted to assert the + field is now **ignored** rather than deleting the evidence that such a description parses; and note + in the doc comment that round-tripping such a description no longer preserves it, since that is a + real if small behaviour change and a silent drop is exactly what this crate has objected to + elsewhere. diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index d718e4df..a261b4f0 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -39,6 +39,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-17 | **Persistent incoherence between two Win32 sources is expected in the field, not exotic -- so the crate *records* it and never refuses over it.** No documented guarantee of cross-API coherence has been found, the two enumerations plausibly derive by different paths, and the firmware tables behind them are populated incrementally against scenarios that do not necessarily include ours. The likely places to meet it are hardware we do not have and prerelease hardware with defective UEFI tables -- exactly where refusing would make the crate useless. **Recording is this crate's job; reporting is the probe tools'**, and the identifying provenance an actionable report needs lives there too, behind the review the probe already applies. Orthogonal to [D-16](#d-16), which is about data shifting *while* it is collected. Only testable synthetically. | | D-18 | **An observation is `(subject, claim, source)`, where a subject is either a relation identity or a processor attribute -- which closes the last gap in [D-15](#d-15).** Membership identity reaches partition disagreements but not per-processor scalars like efficiency class; generalising the *subject* rather than inventing a second mechanism covers both with one rule. Two smaller answers ride along: a topology records **that** collection concluded incoherently **and which subjects disagreed** -- the latter because a consumer forced to re-derive the comparison is the SH-16.9 failure repeating -- and the retry bound is a small documented constant whose exhaustion is a *conclusion*, not a failure. | | D-19 | **When the sources align -- which is the usual case -- a *unified* view is presented, in addition to the individual per-source ones.** A design that made every answer carry a coherence state was the sentinel mistake in another form: it let a case that essentially never happens shape every caller on every machine, and it made a *local* defect global, presenting a machine with one contested core as entirely uncertain. [D-15](#d-15) had already concluded the opposite and was simply not applied. Under `(kind, membership)` identity the unification is **free** -- agreeing sources have observed one relation, so there is no merge step -- and a contradiction contests only those processors at that kind. A contested subject needs no new vocabulary: it is a relation the unified view does not cover, which is [D-13](#d-13)'s *not observed*, so a consumer implements one degradation path rather than three. Requires that relations carry **attributes** as well as memberships. | +| D-20 | **This crate does not go below the Win32 topology APIs, so if they do not report a fact, the crate does not have it -- and `distances` is therefore deleted rather than filled.** The engineer's ruling, and it is a **scope boundary** rather than a judgement about the field: ACPI carries SLIT, no Win32 API surfaces it, and reading firmware directly would be going below the boundary. Two supporting findings, neither of which is the reason: `distances` could never carry `Measured` provenance **by construction** (its only inputs are hand-construction, which is `Synthetic`, and deserialization, which per [D-12](#d-12) can only downgrade), and it has **zero read sites** -- `windows-platform-probes`' `render_node_distances` reads the probe's own measured `Observation`, not this field. What is lost is named rather than skated past: [D-10](#d-10)'s platform-neutral description can no longer carry Linux SLIT data. That capability was real, and it is given up because the two-component split routes distance through the synthesizer's *measurement*, which a fed-in description cannot substitute for. | ## D-12: provenance, and why the default points at distrust @@ -503,6 +504,65 @@ the unified model, `DomainKind::Memory { memory_bytes }` has nowhere to live unl hold a payload as well as a membership. `Core { efficiency_class, simultaneous_multithreading }` is the same requirement. +## D-20: the Win32 boundary, and the deletion of `distances` + +*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.4. Supersedes `SH-16.11` in +[CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md), which proposed +filling the field.* + +### The ruling is about scope, not about the field + +**This crate does not go below the Win32 topology APIs.** What they report, it reports; what they do +not, it does not have. ACPI carries SLIT, and reading firmware to recover it would be going below +that boundary -- so inter-node distance is not a fact this crate can state, and +`MachineMemoryTopology::distances` is deleted rather than filled. + +Stating it as a boundary matters more than the field it disposes of, because the boundary answers the +next instance too. Any future "Windows does not expose X, but the firmware does" question is already +settled: not here. + +### Two findings that support it, and are not the reason + +Both were established before the ruling, and neither would have been sufficient on its own: + +- **`distances` could never carry `Measured` provenance, by construction.** Its only two input paths + are hand-construction, which is [D-12](#d-12)'s `Synthetic` default, and deserialization, which + D-12 permits only to *downgrade*. So every value it could ever hold is one the crate's own + provenance rule says not to trust as describing the machine you are on. +- **It has zero read sites.** The one thing that looked like a consumer is not one: + `windows-platform-probes`' `render_node_distances` reads the *probe's own* measured `Observation`, + which is its handoff-cost measurement, not this field. + +The second is deliberately not offered as a justification. "Nothing reads it" is never on its own a +reason to remove a capability; it is evidence about cost, and the cost here is zero. + +### What is given up + +[D-10](#d-10) made a description deliberately platform-neutral, and the Linux comparison specifically +vindicated optional distances -- "Linux *has* SLIT where Windows does not". A description sourced +from Linux can no longer carry that matrix. **That capability was real and it is being given up.** + +What makes it acceptable is the two-component split, which changed the premise it rode in on. A +description exists so a planner can be tested against hardware nobody owns; but distance now reaches +a planner through the *synthesizer's measurement*, taken with the caller's permission for its own +scenario. A fed-in description cannot substitute for that, because it is not a measurement of the +machine the plan will run on -- so a fixture supplying distance through the topology would be feeding +a channel the planner does not read. + +The fallback that would have justified keeping it -- measurement refused, so inherit a distance from +the description -- is exactly what this decision rules out. On a real machine, permission denied +means no distance data. + +### The deletion is breaking but not a parse break + +The crate is published at 0.1.0, so removing a public field is a breaking change and its commit takes +the Conventional Commits `!` marker. Deserialization is unaffected: nothing in the crate sets +`deny_unknown_fields`, so a description carrying `"distances"` still parses and the field is ignored. + +It does mean such a description no longer **round-trips** -- the value is dropped on read and absent +on write. That is a silent drop, which this crate has objected to elsewhere, so it is documented at +the site rather than left to be discovered. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was From f9a772b9414ef019ef0be64da01fab234278b6a1 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 00:22:12 -0400 Subject: [PATCH 258/361] feat(topology)!: remove MachineMemoryTopology::distances and the Distances type BREAKING CHANGE: the public field `MachineMemoryTopology::distances` and the public `Distances` type are removed, per D-20. This crate does not go below the Win32 topology APIs, so inter-node distance -- which ACPI carries as SLIT and no Win32 API surfaces -- is not a fact it states. Not a parse break. Nothing here sets deny_unknown_fields, so an existing description carrying "distances" still deserializes and the field is ignored. It no longer round-trips: the value is dropped on read and absent on write. The Linux-shaped description test survives rather than being deleted, keeping its POPULATED matrix so that what it proves is that such a description still parses, and gaining an assertion that the value does not reappear on re-serialize -- so the silent drop is asserted rather than assumed. distances_is_expected_to_be_square went with the type it tested (125 -> 124). Three fixtures in windows-placement-probe's fingerprint tests updated. Zero read sites existed: render_node_distances reads the probe's own measured Observation, not this field. Swept two stale statements that a narrower fix would have left behind: the D-13 audit row, and the Linux-comparison summary, which recorded optional distances among the decisions that HELD UP. That finding was sound about the schema and is reversed by a ruling about scope; both now say so. Verified: cargo check --workspace --all-targets --all-features, clippy, and fmt clean; 124 topology tests and 216 placement-probe tests pass. Completed item: M5+.5: Delete `MachineMemoryTopology::distances` and the `Distances` type Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/fingerprint/tests.rs | 3 -- crates/windows-topology-sys/CHECKLIST.md | 11 ++++++- crates/windows-topology-sys/DESIGN-NOTES.md | 8 ++++- crates/windows-topology-sys/src/domain.rs | 23 ------------- .../windows-topology-sys/src/domain/tests.rs | 14 -------- crates/windows-topology-sys/src/lib.rs | 2 +- crates/windows-topology-sys/src/topology.rs | 5 +-- .../src/topology/tests.rs | 32 ++++++++++++------- 8 files changed, 39 insertions(+), 59 deletions(-) diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index 874def74..c31e547c 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -391,7 +391,6 @@ mod from_topology { MachineMemoryTopology { processors, domains, - distances: None, cpu_sets: None, ..Default::default() } @@ -659,7 +658,6 @@ mod multi_group_conversion { MachineMemoryTopology { processors, domains, - distances: None, cpu_sets: None, ..Default::default() } @@ -737,7 +735,6 @@ mod multi_group_conversion { id: 0, processors: ProcessorSet::from_group_mask(0, mask), }], - distances: None, cpu_sets: None, ..Default::default() } diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 2567d1ef..b13eb477 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -357,7 +357,7 @@ separately and then re-fixed. crate deliberately hands back, failing an entire measurement run over a topology this crate considers describable. M2+.5 gives it the vocabulary to accept one. -- [ ] **M5+.5** -- **Delete `MachineMemoryTopology::distances` and the `Distances` type**, per +- [x] **M5+.5** -- **Delete `MachineMemoryTopology::distances` and the `Distances` type**, per [D-20](DESIGN-NOTES.md#d-20). **Not gated on M4**: the reshape does not fix this one, deletion does, so it does not wait for the rest of M5. A **breaking change to a published crate** (0.1.0), so the commit takes the Conventional Commits @@ -368,3 +368,12 @@ separately and then re-fixed. in the doc comment that round-tripping such a description no longer preserves it, since that is a real if small behaviour change and a silent drop is exactly what this crate has objected to elsewhere. + **Done.** Field, type, and re-export removed; three call sites in `windows-placement-probe`'s + fingerprint fixtures updated. The Linux-shaped test survives as + `a_linux_shaped_description_parses_and_its_distances_are_ignored`, keeping the **populated** matrix + so what it proves is that an existing description still parses, and gaining an assertion that the + value does **not** reappear on re-serialize -- the silent drop asserted rather than assumed. + `distances_is_expected_to_be_square` was deleted with the type it tested (125 tests to 124). + Two stale statements sweeps found and fixed: the [D-13](DESIGN-NOTES.md#d-13) audit row, and the + Linux-comparison summary, which had recorded optional distances as a decision that *held up* -- + sound about the schema, and reversed by a ruling about scope. diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index a261b4f0..59c49eb2 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -114,7 +114,7 @@ argument carried up from "do not use a sentinel" to "say which absence you mean" | Site | Which absence | Notes | |---|---|---| -| `MachineMemoryTopology::distances` | **not observed**, and unobservable here | Windows exposes no user-mode SLIT reader, so `discover` can never fill it. Populated only by a fed-in description. | +| `MachineMemoryTopology::distances` | ~~**not observed**, and unobservable here~~ | **Removed by [D-20](#d-20).** The row is kept because it is what made the field's position clear: an `Option` that could only ever be "not observed" was describing a fact outside the crate's Win32 boundary, and the honest answer turned out to be deleting the field rather than documenting which absence it meant. | | `MachineMemoryTopology::cpu_sets` | **not observed** | `Some(v)` means the CPU-set API answered, and `v` may legitimately be empty; `None` means nothing asked, which is what a hand-built or deserialized topology is. | | `DomainKind::Memory::memory_bytes` | **not observed** from `discover` | See below: a *description's* `None` is currently ambiguous, and that is the one gap this audit found. | | `MachineMemoryTopology::processor` | lookup miss | Ordinary "no such element", not a fact about the machine. | @@ -610,3 +610,9 @@ genuinely violated -- memory-only nodes (D-5), fixed domain kinds (D-4), and mis -- while three decisions held up unchanged: processor identity as `(group, number)` (D-7), reference-don't-nest (D-6), and treating distances as optional, which Linux vindicated by actually having SLIT where Windows does not. + +**The third of those has since been reversed by [D-20](#d-20).** Optional distances held up against +Linux, and that finding was sound on its own terms -- but it was a conclusion about the *schema*, and +D-20 is a ruling about the crate's *scope*: this crate does not go below the Win32 topology APIs, so a +fact only firmware reports is not one it carries at all. The field is deleted, and the capability the +Linux comparison vindicated is knowingly given up. diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index 8481f4ac..c5626a87 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -176,29 +176,6 @@ pub struct Domain { pub processors: ProcessorSet, } -/// A scalar relative-distance matrix over one domain kind. -/// -/// Deliberately not the HMAT attributed-relation model (per-initiator, -/// per-target read/write latency and bandwidth): that was considered and -/// declined for now, see D-9 in `DESIGN-NOTES.md`. Windows exposes no -/// user-mode SLIT reader, so a [`crate::MachineMemoryTopology`] this crate discovers never -/// populates this; it exists for a fed-in description sourced from a system -/// that does report it. -#[derive(Clone, Debug, PartialEq, Eq)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub struct Distances { - /// Which domain kind the matrix's rows and columns index. Kept as a - /// plain string, naming a [`DomainKind`] the way its JSON `kind` tag - /// reads (with the `serde` feature), because domain kinds are themselves - /// open (D-4). - pub over: String, - /// The distance matrix, in the order those domains appear in - /// [`crate::MachineMemoryTopology::domains`] filtered to `over`. Square; - /// `matrix[i][i]` is conventionally `10`, Windows's and ACPI SLIT's own - /// "local" value. - pub matrix: Vec>, -} - /// Manual `Serialize`/`Deserialize` for the open-kinded types. /// /// `AttributeValue` and `Domain` cannot be `#[derive(Serialize, Deserialize)]`: diff --git a/crates/windows-topology-sys/src/domain/tests.rs b/crates/windows-topology-sys/src/domain/tests.rs index cb971e70..1cbe7ea7 100644 --- a/crates/windows-topology-sys/src/domain/tests.rs +++ b/crates/windows-topology-sys/src/domain/tests.rs @@ -88,20 +88,6 @@ fn attribute_value_supports_nested_structures() { ); } -#[test] -fn distances_is_expected_to_be_square() { - let distances = Distances { - over: "memory".to_string(), - matrix: vec![vec![10, 21], vec![21, 10]], - }; - assert!( - distances - .matrix - .iter() - .all(|row| row.len() == distances.matrix.len()) - ); -} - // --- serde (M3.3) --- #[cfg(feature = "serde")] diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 7531c017..a1544bfe 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -78,7 +78,7 @@ mod walk; #[cfg(windows)] pub use cpu_set::CpuSet; #[cfg(windows)] -pub use domain::{AttributeValue, Distances, Domain, DomainKind, Processor, ProcessorId}; +pub use domain::{AttributeValue, Domain, DomainKind, Processor, ProcessorId}; pub use processor_set::ProcessorSet; pub use provenance::Provenance; #[cfg(windows)] diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 07739d8b..494cf823 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -4,7 +4,7 @@ use std::io; use crate::cpu_set::CpuSet; -use crate::domain::{Distances, Domain, DomainKind, Processor, ProcessorId}; +use crate::domain::{Domain, DomainKind, Processor, ProcessorId}; use crate::provenance::Provenance; use crate::relation::{self, Relations}; @@ -25,8 +25,6 @@ pub struct MachineMemoryTopology { pub processors: Vec, /// Every domain. pub domains: Vec, - /// An optional scalar distance matrix. - pub distances: Option, /// What `GetSystemCpuSetInformation` reported, as **its own observation**. /// /// Windows describes processors through two APIs, and this is the second @@ -155,7 +153,6 @@ impl MachineMemoryTopology { Self { processors, domains, - distances: None, cpu_sets: None, // Synthetic, not measured: this is a pure transform of whatever // relations it was handed, and cannot know where they came from. diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 0cb76a61..d0effd9b 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -72,7 +72,6 @@ fn synthetic() -> MachineMemoryTopology { processors: ProcessorSet::empty(), }, ], - distances: None, cpu_sets: None, // Named rather than defaulted, so this fixture states what it is. The // helper is called `synthetic` and now says so in the value too. @@ -225,11 +224,18 @@ mod serde_tests { /// A description shaped like what a Linux system would produce: a /// single processor group (Linux has no group concept), a memory-only - /// node, and a populated scalar distance matrix -- all things Windows - /// itself never reports through this crate's own discovery, but that a - /// fed-in description can legitimately carry (D-10). + /// node, and a populated scalar distance matrix. + /// + /// The matrix is **ignored** as of D-20 in `DESIGN-NOTES.md`: this crate does not go below + /// the Win32 topology APIs, so inter-node distance is not a fact it + /// states, and the field it used to be read into is gone. The test keeps + /// the populated matrix rather than dropping it, because what needs + /// proving is that such a description still *parses* -- nothing here sets + /// `deny_unknown_fields`, so an existing Linux-shaped description does not + /// become unreadable. It does not round-trip: the value is dropped on read + /// and absent on write. #[test] - fn a_linux_shaped_description_with_a_memory_only_node_and_distances_parses() { + fn a_linux_shaped_description_parses_and_its_distances_are_ignored() { let json = r#"{ "processors": [ {"id": {"group": 0, "number": 0}, "online": true, "capacity": 1024}, @@ -249,9 +255,14 @@ mod serde_tests { topology.memory_domains().any(|d| d.processors.is_empty()), "the CXL-shaped node must survive" ); - let distances = topology.distances.expect("distances present"); - assert_eq!(distances.over, "memory"); - assert_eq!(distances.matrix, vec![vec![10, 40], vec![40, 10]]); + + // The half that D-20 changed: re-serializing does not carry the matrix + // back out, so the drop is silent and is asserted rather than assumed. + let round_tripped = serde_json::to_string(&topology).expect("serialize"); + assert!( + !round_tripped.contains("distances"), + "distances must not reappear on write: {round_tripped}" + ); } /// The other half of D-10: a single "group" holding more than 64 @@ -304,7 +315,6 @@ fn struct_update_syntax_from_default_stays_untrusted() { // only the fields they care about, and provenance is exactly the field // nobody thinks to name. let topology = MachineMemoryTopology { - distances: None, cpu_sets: None, ..Default::default() }; @@ -389,7 +399,7 @@ mod serde_provenance { assert_eq!(reloaded.processors, measured.processors); assert_eq!(reloaded.domains, measured.domains); - assert_eq!(reloaded.distances, measured.distances); + assert_eq!(reloaded.cpu_sets, measured.cpu_sets); } } @@ -593,7 +603,6 @@ fn split_l1_machine(cores: u32, last_level: u8) -> MachineMemoryTopology { MachineMemoryTopology { processors: Vec::new(), domains, - distances: None, cpu_sets: None, provenance: Provenance::Synthetic, } @@ -610,7 +619,6 @@ fn cache_levels_are_empty_when_no_cache_is_reported() { let topo = MachineMemoryTopology { processors: Vec::new(), domains: Vec::new(), - distances: None, cpu_sets: None, provenance: Provenance::Synthetic, }; From 8f55e2e256276ca2073b2cb2db005e238742d11b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 09:14:22 -0400 Subject: [PATCH 259/361] docs(topology): record the four-part architecture and settle the crate name (EP-D-4) The engineer's position shift. The planner is `topology-planner` -- no `windows-` prefix -- taking a goal description (shape deferred for litigation, by direction), querying an abstracted idealized model of processors, memory, storage, interconnects, distances and bottlenecks, and emitting a JSON-serializable platform-neutral plan. Two kinds of adapter bracket it: one exposing the planner's traits over the Windows topology objects, one realizing a plan as buffers, rings and threads with the user's processing code inserted at the appropriate steps. Closes MMT-1.5, and not on a naming preference: a crate on one side of an adapter boundary is what `-sys` names, so windows-topology-sys stays a pure Win32 wrapper and keeps its name. M1 is now 4 of 5. Resolves three decisions that were in tension over where distance lives. D-20 removed it from the facts crate (Win32 does not report it); EP-D-3 established the planner needs a DIRECTED cost a SLIT scalar cannot express; D-9 deferred HMAT-style attributed relations until scalar distance demonstrably mismodels a machine. "Interconnects and bottlenecks" is that attributed-edge shape, and it lands in the abstract model -- so D-9's deferral in the facts crate stands unreopened and the measurement condition still applies before claiming asymmetry is real. Storage lands the same way: D-9 excluded it from a processor-topology crate, and the abstract model was never scoped to one. Also settles COMPONENT.md's "two graphs, one word": machine memory topology, abstract topology, and planned topology are now distinct. Recorded as open rather than assumed: the component layout (if the traits live in the planner, the inward adapter depends on it, which points the wrong way), who owns measurement now that distance is a property of the abstract model, and the fact that MMT-1.3 / EP-1.4's consumer is now the adapter rather than the planner. EP-D-1..3 survive unchanged -- they are requirements, and requirements survive a change of binding. Completed item: MMT-1.5: Does the synthesizer live in this crate, and therefore what is this crate called? Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-execution-plan/CHECKLIST.md | 23 +++-- crates/windows-execution-plan/DESIGN-NOTES.md | 96 ++++++++++++++++++- crates/windows-topology-sys/CHECKLIST.md | 14 ++- 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/windows-execution-plan/CHECKLIST.md index dcebf71a..be2576e7 100644 --- a/crates/windows-execution-plan/CHECKLIST.md +++ b/crates/windows-execution-plan/CHECKLIST.md @@ -1,15 +1,22 @@ -# Checklist: the execution-domain planner +# Checklist: the topology planner -Plans the mapping from a `MachineMemoryTopology` to a set of execution domains. See -[COMPONENT.md](COMPONENT.md) for what this crate is and why it is separate from both the topology -crate and the runtime. +Plans an arrangement of execution domains from a stated **goal** plus an **abstracted idealized** +description of a machine. See [COMPONENT.md](COMPONENT.md) for what this crate is and why it is +separate from both the topology crate and the runtime, and +[EP-D-4](DESIGN-NOTES.md#ep-d-4) for the architecture it now sits in. + +**The component is being re-scoped**, per [EP-D-4](DESIGN-NOTES.md#ep-d-4). It is named +`topology-planner`; it queries an abstract model covering processors, memory, storage, interconnects, +distances and bottlenecks rather than `MachineMemoryTopology` directly; and **adapters** bracket it +-- one exposing its traits over the Windows topology objects, one realizing a plan as buffers, rings +and threads. The directory is still `windows-execution-plan` pending the layout decision that +EP-D-4 leaves open. **M2+ onward are written against the superseded shape and are not yet re-cut.** ## Where this stands **Nothing is implemented.** M1 is the only active milestone, and it is deliberately a *requirements* milestone rather than an implementation one: its output is the concrete statement -of what `windows-topology-sys` must answer, which the open design session needs in order to settle -the model. +of what the model must answer, which the open design session needs in order to settle it. > **-> CROSS-COMPONENT PREREQUISITE:** M2 onwards cannot begin until > [DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) @@ -20,8 +27,8 @@ the model. | Milestone | State | What it is waiting on | |---|---|---| | M1 the input contract | 3 done, 2 blocked | as far as it can go before the model exists | -| M1+ scenario and naming | open | the session; neither is a model question | -| M2+ the plan as a value | parked | M1, and the topology model landing | +| M1+ scenario and naming | **partly answered** | the name is settled (EP-D-4); the goal input is deferred for litigation, by direction | +| M2+ the plan as a value | parked, **and needs re-cutting** | the layout decision, then M1 | | M3+ the policies | parked | M2+ | | M-inf parked | ungated | not scheduled, deliberately | diff --git a/crates/windows-execution-plan/DESIGN-NOTES.md b/crates/windows-execution-plan/DESIGN-NOTES.md index 3938e29c..f88d2d31 100644 --- a/crates/windows-execution-plan/DESIGN-NOTES.md +++ b/crates/windows-execution-plan/DESIGN-NOTES.md @@ -1,11 +1,18 @@ -# Design notes: the execution-domain planner +# Design notes: the topology planner Current canonical decisions for this component. See [COMPONENT.md](COMPONENT.md) for what the component is; see [CHECKLIST.md](CHECKLIST.md) for what is planned. -While M1 runs, most entries here are **queries** rather than choices: the planner's requirements -on `windows-topology-sys`, stated precisely enough that the topology model can be designed against -a real caller instead of against a guess. +`EP-D-1` through `EP-D-3` are **queries** rather than choices: the planner's requirements, stated +precisely enough that the topology model could be designed against a real caller instead of against +a guess. They were written when the planner was to read `windows_topology_sys::MachineMemoryTopology` +directly; [EP-D-4](#ep-d-4) rebinds them to traits over an abstract model, which changes what +satisfies them and not what they require. + +[EP-D-4](#ep-d-4) is the first genuine **choice** here, and it re-scopes the component: the planner +is `topology-planner`, it plans against an abstracted idealized machine, and adapters bracket it on +both sides. **The component's own name and directory still say `windows-execution-plan`** and are +pending the layout decision EP-D-4 leaves open. ## Decision index @@ -14,6 +21,7 @@ a real caller instead of against a guess. | EP-D-1 | **The shard-set query**: what the planner must know to choose which processors host a domain, and what today's model cannot tell it. | | EP-D-2 | **The proximity query**: how close two processors are, which selects the channel between their domains. Takes an **unordered** pair; the model has no answer today. | | EP-D-3 | **The residency query**: where a domain's pool lives, and which side of a cross-domain pair should host a shared ring. **Ordered**, and the half the model cannot answer is structurally unanswerable rather than merely unpopulated. | +| EP-D-4 | **The four-part architecture, and the planner's name.** The engineer's position: the planner is **`topology-planner`** (no `windows-` prefix); it takes a **goal** description (shape deferred for litigation), queries an **abstracted idealized** model covering processors, memory, storage, interconnects, distances and bottlenecks, and emits a **JSON-serializable, platform-neutral** plan. Two kinds of **adapter** bracket it: one exposing the planner's traits over the Windows topology objects, one **realizing** a plan as buffers, rings and threads with the user's code inserted at the right steps. Settles `MMT-1.5` (the facts crate keeps its `-sys` name), the "two graphs, one word" ambiguity, and where distance lives -- the attributed interconnect shape D-9 sketched goes in the abstract model, so D-9's deferral in the facts crate stands unreopened. | ## EP-D-1: the shard-set query @@ -324,3 +332,83 @@ description of a number whose meaning depends on how it was obtained. not become every consumer's constant. - And, before reopening D-9 on the asymmetry argument: a multi-node measurement showing the directions actually differ. + +## EP-D-4: the four-part architecture, and the planner's name + +*The engineer's position, 2026-09-03. This is a **choice**, not one of M1's queries, and it +re-scopes the component that records it.* + +### What was decided + +**The planner is `topology-planner`** -- deliberately with no `windows-` prefix. + +- **Input**: a description of the **goal** of the topology -- what the caller intends the + arrangement to achieve. Its shape is **explicitly deferred for litigation**, which is a named + deferral rather than an omission. +- **What it queries**: an **abstracted, idealized** description of the machine, covering + **processors, memory, storage (NVMe), interconnects, distances, and bottlenecks**. Not + Windows-shaped, and materially richer than what any one platform reports. +- **Output**: a data structure that **serializes to JSON** and is **still abstracted from Windows**. +- **Adapters, in two directions**: + - *inward* -- exposing the traits the planner needs **over the topology objects already designed**, + so `windows_topology_sys::MachineMemoryTopology` becomes one source feeding the abstract model; + - *outward* -- **realizing** a planned topology in the current process as buffers, rings and + threads, with the user's processing code inserted at the appropriate steps. + +### What it settles + +**The crate-naming question** (`MMT-1.5` in +[windows-topology-sys](../windows-topology-sys/CHECKLIST.md)). The planner does not live in +`windows-topology-sys`, which therefore stays a pure Win32 wrapper and keeps its `-sys` name. The +decisive point is not preference but the adapter boundary: a crate on one side of an adapter is +exactly what `-sys` names, and [D-20](../windows-topology-sys/DESIGN-NOTES.md#d-20) already scoped +that crate to "what the Win32 topology APIs report". + +**"Two graphs, one word"** -- [COMPONENT.md](COMPONENT.md) flagged that both the input and the output +are graphs of processors and relations, so "topology" named all of them and distinguished none. Three +things are now distinct: the **machine memory topology** (Windows facts), the **abstract topology** +(idealized, multi-source, platform-neutral), and the **planned topology** (the output). The word is +shared deliberately; the qualifier carries the distinction. + +**Where distance lives**, which three decisions had left in tension: + +- [D-20](../windows-topology-sys/DESIGN-NOTES.md#d-20) removed `distances` from the facts crate, + because Win32 does not report it and that crate does not go below Win32. +- [EP-D-3](#ep-d-3) established that the planner needs a **directed** cost, which a SLIT-shaped + scalar cannot express. +- `windows-topology-sys` D-9 deferred HMAT-style attributed relations until scalar distance + "demonstrably mismodels a machine somebody is tuning for" -- a trigger this component *approaches* + and, lacking multi-node hardware, has not met. + +The abstract model resolves all three without disturbing any: **interconnects and bottlenecks** are +the attributed-edge shape D-9 sketched, and they live in the abstract model, so D-9's deferral in the +facts crate **stands unreopened** while the need it named is met elsewhere. The measurement condition +still applies before claiming asymmetry is real; it just no longer gates the schema. + +**Storage becomes representable**, which `windows-topology-sys` D-9 also excluded -- on the grounds +that it "changes the crate's identity from processor topology to system topology". That exclusion was +about *that crate* and still holds. NVMe belongs to the abstract model, which was never scoped to a +processor topology. + +### What it opens + +- **Component layout.** How many crates, and where the traits live. If the traits are defined in the + planner, the inward adapter depends on the planner, which points the wrong way for a crate whose + job is to describe a machine. An abstract-model crate that both depend on avoids that, at the cost + of a fourth component. **Not yet decided.** +- **Who measures.** The previous framing had this component measuring with permission. If distance is + a property of the abstract model, measurement plausibly belongs to whatever *populates* that model + -- an adapter -- rather than to the planner. The three-stage split (observe / synthesize / execute) + survives; which component owns the middle stage does not obviously. +- **`MMT-1.3` / `EP-1.4`'s consumer changed.** Both ask what a consumer does with a fact that was not + observed. That consumer is no longer the planner reading `MachineMemoryTopology` directly -- it is + the **inward adapter**, deciding how an absent Windows fact appears in the abstract model. The + decision is still one decision, and it is still to be taken jointly, but it is taken at a boundary + that did not exist when both items were written. + +### What survives unchanged + +[EP-D-1](#ep-d-1), [EP-D-2](#ep-d-2) and [EP-D-3](#ep-d-3) are **requirements**, and requirements +survive a change of binding. Each stated what the planner must know and why; what changes is that +they are now satisfied by traits over an abstract model rather than by methods on a Windows type. +They were written against a real caller, which is what makes them portable in this way. diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index b13eb477..19d98061 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -28,7 +28,7 @@ Presence and observation are facts to represent, not shapes to infer from. | Milestone | State | What it is waiting on | |---|---|---| -| M1 settle what is still open | 2 of 5 done | nothing -- these are decisions, and they gate the rest | +| M1 settle what is still open | 4 of 5 done | MMT-1.3, which is joint with the planner's `EP-1.4` | | M2 the granularity model | parked | M1 | | M3 observation and provenance | parked | M1 (1 of 4 answered early, by D-19) | | M4 the queries | parked | M2, M3 | @@ -231,12 +231,22 @@ wrongly after code exists. reads the *probe's own* measured `Observation`, not this field, so the one thing that looked like a consumer is not one. Removal is spawned as **M5+.5** and is not gated on the reshape. -- [ ] **MMT-1.5** -- **Does the synthesizer live in this crate, and therefore what is this crate +- [x] **MMT-1.5** -- **Does the synthesizer live in this crate, and therefore what is this crate called?** Recorded as open rather than settled: see [windows-execution-plan/COMPONENT.md](../windows-execution-plan/COMPONENT.md). The naming follows the merge rather than leading it -- while this crate is only a Win32 wrapper, `-sys` is correct for it; if it gains a synthesizer that measures, it stops being one and the name should change then. + **Answered by the engineer's architectural shift, recorded as + [EP-D-4](../windows-execution-plan/DESIGN-NOTES.md#ep-d-4): no.** The planner is a separate + component named **`topology-planner`** -- with no `windows-` prefix, because it plans against an + abstracted idealized machine and emits a platform-neutral plan. So this crate does not gain the + synthesizer, remains a pure Win32 wrapper, and **keeps its name**. + What settles it is not a naming preference but the shift's second half: the planner queries an + *abstract* model through traits, and **adapters** bridge this crate's objects to those traits. A + crate that is one side of an adapter boundary is exactly what `-sys` names. + [D-20](DESIGN-NOTES.md#d-20) reinforces it from the other direction -- a crate whose scope is + "what the Win32 topology APIs report" is a `-sys` crate by construction. ## M2: the granularity model From 14036d0d6379eaf2f891f91aee7d5f847832502f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 11:29:08 -0400 Subject: [PATCH 260/361] docs(topology-planner): settle the component layout and rename the component (EP-D-5) The abstract model and the traits the planner queries live in their own crate, `topology-model`, which the planner and both adapters depend on. Nothing depends on `topology-planner` except a caller that wants to plan. Putting the traits in the planner would make a crate whose only job is to DESCRIBE A MACHINE depend on one that applies POLICY, and would mean anyone reading a topology pulls in planning policy they did not ask for. That is the outermost_partitioning_cache defect arriving as a dependency edge rather than as an API. Two consequences derived from the same rule rather than decided separately, and called out as derived so they can be corrected: - The PLAN TYPE also lives in `topology-model`. The realizer consumes a plan; if the type lived in the planner, execution would depend on policy. COMPONENT.md already argued a plan is a VALUE -- inspectable, comparable, reviewable before anything is pinned -- and a value type belongs with the vocabulary, not with the policy that produced it. - The inward adapter and the realizer are SEPARATE crates despite both being Windows adapters. Their dependency sets barely overlap: one needs windows-topology-sys, the other needs the runtime. Directory renamed crates/windows-execution-plan -> crates/topology-planner, with all 30 path and prose references updated across 8 files. COMPONENT.md rewritten against the new architecture rather than patched; git reports the other two files as renames at 95% and 85% similarity. Recorded as still open: the adapters' names (naming here has been getting decided by whoever writes the first type), who owns measurement now that `topology-model` depends on nothing and so cannot measure, and whether `topology-model` eventually separates into machine description and plan vocabulary -- together for now because splitting on speculation costs more than merging on evidence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-io-domains.md | 6 +- CHECKLIST-ship-topology-and-queues.md | 4 +- PLANS.md | 2 +- .../CHECKLIST.md | 14 ++- crates/topology-planner/COMPONENT.md | 115 ++++++++++++++++++ .../DESIGN-NOTES.md | 67 +++++++++- crates/windows-execution-plan/COMPONENT.md | 98 --------------- crates/windows-topology-sys/CHECKLIST.md | 14 +-- ...SESSION-2026-09-02-cache-locality-model.md | 4 +- 9 files changed, 203 insertions(+), 121 deletions(-) rename crates/{windows-execution-plan => topology-planner}/CHECKLIST.md (95%) create mode 100644 crates/topology-planner/COMPONENT.md rename crates/{windows-execution-plan => topology-planner}/DESIGN-NOTES.md (85%) delete mode 100644 crates/windows-execution-plan/COMPONENT.md diff --git a/CHECKLIST-io-domains.md b/CHECKLIST-io-domains.md index 6841467e..43d3189d 100644 --- a/CHECKLIST-io-domains.md +++ b/CHECKLIST-io-domains.md @@ -571,7 +571,7 @@ it now lists five, and the count is dropped rather than maintained.) M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard", which presupposes a mapping naming which thread, which node and which shard -- and that mapping was unowned: M32's other four contracts are all about the queue, and no item anywhere computed the plan. - It is now [crates/windows-execution-plan](crates/windows-execution-plan/COMPONENT.md), a component + It is now [crates/topology-planner](crates/topology-planner/COMPONENT.md), a component of its own, because it applies **policy** over the topology's facts and reasonable clients will choose differently. Nothing here needs to decide it; this item exists so a reader of M33+ does not conclude the mapping is obvious, which is how it went missing. @@ -581,7 +581,7 @@ it now lists five, and the count is dropped rather than maintained.) > [CHECKLIST.md](CHECKLIST.md); the items are held here until M32 settles, then move to the component that owns them. > > **The plan M33+ executes comes from -> [crates/windows-execution-plan/CHECKLIST.md](crates/windows-execution-plan/CHECKLIST.md)**, which is +> [crates/topology-planner/CHECKLIST.md](crates/topology-planner/CHECKLIST.md)**, which is > itself gated on the locality-model design session. So M33+ has two prerequisites, not one. ## M33+ -- The domain runtime (gated on M32) @@ -774,7 +774,7 @@ Parked, not pending. Shape recorded so it is not lost, per the `M{n}+` conventio `(producer.numa_node, consumer.numa_node)` and its comment states that "both *directions* are kept", with `by_node_pair` adding that "each hop is measured once per ring placement, so there are two". Four measurements per undirected edge, not one. Found while stating - [EP-D-3](crates/windows-execution-plan/DESIGN-NOTES.md#ep-d-3), whose whole subject is that + [EP-D-3](crates/topology-planner/DESIGN-NOTES.md#ep-d-3), whose whole subject is that residency is directional, so a parked item asserting the opposite would have been read as evidence against it. The probe prints the resulting table, names the cheapest and dearest hop, and says outright whether the spread is small enough for the single diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index cdba269f..ed663eb4 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -958,7 +958,7 @@ predicted about a 222-commit branch. **without further measurement**, a consumer shaping memory allocation must today either run the probe at decision time -- forbidden -- or guess. Gated on SH-16.8, and on the open question of which component owns the measurement phase. - **Corrected while stating [EP-D-3](crates/windows-execution-plan/DESIGN-NOTES.md#ep-d-3): the + **Corrected while stating [EP-D-3](crates/topology-planner/DESIGN-NOTES.md#ep-d-3): the wording above reads as an oversight, and it is not one.** The field is documented as being for a fed-in description, because Windows exposes no user-mode SLIT reader -- accurate, and deliberate. Two sharper problems replace the one this item claimed. @@ -987,7 +987,7 @@ predicted about a 222-commit branch. offline, *or* is online but named by no `Core` domain, *or* genuinely has efficiency class zero. The third is **every processor on every non-hybrid machine**, so the sentinel is not a rare collision -- it is the usual value. - Found by [crates/windows-execution-plan](crates/windows-execution-plan/DESIGN-NOTES.md#ep-d-1) + Found by [crates/topology-planner](crates/topology-planner/DESIGN-NOTES.md#ep-d-1) EP-1.1 while checking what a shard planner can rely on, and it is worse for that consumer than for most: Windows orders efficiency class with `0` as **least** performant, so on a hybrid part an unknown processor is indistinguishable from an efficiency core. A policy excluding efficiency cores diff --git a/PLANS.md b/PLANS.md index d09ee37e..1a7737d1 100644 --- a/PLANS.md +++ b/PLANS.md @@ -18,7 +18,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| | [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | -| [crates/windows-execution-plan/CHECKLIST.md](crates/windows-execution-plan/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a `MachineMemoryTopology` to a set of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 is a *requirements* milestone and is the only active one -- it states what the topology must answer, feeding the open locality-model session, which asked what shape is most useful to consumers and is being answered by naming one. M2+ and M3+ are parked on that session concluding. EP-1.1 is done and already earned its keep: checking the shard-set query against the model found `Processor::capacity` using `0` as both a valid efficiency class and a "not known" sentinel, which collide on every non-hybrid machine (filed as SH-16.12). | [crates/windows-execution-plan/DESIGN-NOTES.md](crates/windows-execution-plan/DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | +| [crates/topology-planner/CHECKLIST.md](crates/topology-planner/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a stated **goal** plus an abstracted idealized machine description to a set of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 is a *requirements* milestone and is the only active one -- it states what the topology must answer, feeding the open locality-model session, which asked what shape is most useful to consumers and is being answered by naming one. M2+ and M3+ are parked on that session concluding, and are additionally **awaiting a re-cut**: EP-D-4 and EP-D-5 re-scoped the component into four parts (`topology-model` holding the abstract machine description, the planner's traits and the plan type; `topology-planner`; an inward Windows adapter; an outward realizer), and only M1 has been reconciled with that. EP-1.1 is done and already earned its keep: checking the shard-set query against the model found `Processor::capacity` using `0` as both a valid efficiency class and a "not known" sentinel, which collide on every non-hybrid machine (filed as SH-16.12). | [crates/topology-planner/DESIGN-NOTES.md](crates/topology-planner/DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14, M15 and M16 are the three later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. M16 is the SH-3.1.1 diff review, the first to read the branch as a diff rather than react to a comment: seven findings, six fixed, including a publish-workflow regression this branch had introduced two commits earlier and a soundness hole in the crate about to freeze its API. Its remaining four are blocked on [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), which began by asking whether collapsing a seven-kind, any-depth topology onto a single cache boundary is the right projection and has since settled that presence and observation must be modeled rather than collapsed into an `Option`. **That work now gates the merge**: unlike M14 and M15, which concern a defect in an implementation that can ship disclosed, M16 concerns the shape of the public model `windows-topology-sys` 0.2.0 would publish, and a published model cannot be reshaped without another break. So M3 waits on M16, and M16 waits on the session. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | diff --git a/crates/windows-execution-plan/CHECKLIST.md b/crates/topology-planner/CHECKLIST.md similarity index 95% rename from crates/windows-execution-plan/CHECKLIST.md rename to crates/topology-planner/CHECKLIST.md index be2576e7..67b375af 100644 --- a/crates/windows-execution-plan/CHECKLIST.md +++ b/crates/topology-planner/CHECKLIST.md @@ -5,12 +5,14 @@ description of a machine. See [COMPONENT.md](COMPONENT.md) for what this crate i separate from both the topology crate and the runtime, and [EP-D-4](DESIGN-NOTES.md#ep-d-4) for the architecture it now sits in. -**The component is being re-scoped**, per [EP-D-4](DESIGN-NOTES.md#ep-d-4). It is named -`topology-planner`; it queries an abstract model covering processors, memory, storage, interconnects, -distances and bottlenecks rather than `MachineMemoryTopology` directly; and **adapters** bracket it --- one exposing its traits over the Windows topology objects, one realizing a plan as buffers, rings -and threads. The directory is still `windows-execution-plan` pending the layout decision that -EP-D-4 leaves open. **M2+ onward are written against the superseded shape and are not yet re-cut.** +**The component has been re-scoped**, per [EP-D-4](DESIGN-NOTES.md#ep-d-4) and +[EP-D-5](DESIGN-NOTES.md#ep-d-5). It is named `topology-planner` and the directory now matches; it +queries an abstract model covering processors, memory, storage, interconnects, distances and +bottlenecks rather than `MachineMemoryTopology` directly; and **adapters** bracket it -- one exposing +the model's traits over the Windows topology objects, one realizing a plan as buffers, rings and +threads. The model, its traits, and the plan type live in a separate `topology-model` crate that +everything depends on and that depends on nothing. +**M2+ onward are written against the superseded shape and are not yet re-cut.** ## Where this stands diff --git a/crates/topology-planner/COMPONENT.md b/crates/topology-planner/COMPONENT.md new file mode 100644 index 00000000..d1f556ce --- /dev/null +++ b/crates/topology-planner/COMPONENT.md @@ -0,0 +1,115 @@ +# topology-planner + +**Planned, not built.** This directory currently holds a plan and no code. It becomes a crate +when [CHECKLIST.md](CHECKLIST.md) M2 begins; until then it exists so the work has an owner and a +place, rather than living as an assumption inside somebody else's milestone. + +Named without a `windows-` prefix on purpose: it plans against an abstracted idealized machine and +emits a platform-neutral plan, so nothing in it is Windows-specific. See +[DESIGN-NOTES.md](DESIGN-NOTES.md) -> `EP-D-4` for the architecture, and `EP-D-5` for the layout. + +## What it is + +A **planner**. It takes two inputs and produces a third thing: + +- **a stated goal** -- what the caller intends the arrangement to achieve. Its shape is deliberately + **deferred for litigation**; that is a named deferral, not an omission. +- **an abstracted idealized description of a machine** -- processors, memory, storage, interconnects, + distances and bottlenecks. Not Windows-shaped, and richer than any single platform reports. It is + **mockable by construction**: a description of a machine nobody has is an ordinary input, which is + what makes this component testable without the hardware it plans for. + +From those it produces **a plan**: which processors host domains, where each thread pins, which +memory node each allocates from, what channel connects each pair, and where each channel's buffer +lives. The plan **serializes to JSON** and stays abstracted from Windows. + +**It may ask.** Planning is a negotiation, not a pure function: the component may call back to its +caller through traits for clarifying information the goal did not settle. Which questions those are +is not yet known, and knowing them is what decides whether that is one trait or several. + +## The four components, and which way the arrows point + +| Component | Platform | Depends on | +|---|---|---| +| `topology-model` | neutral | nothing | +| `topology-planner` (this one) | neutral | `topology-model` | +| the inward adapter | Windows | `topology-model`, `windows-topology-sys` | +| the outward adapter (the realizer) | Windows | `topology-model`, the runtime crates | + +`topology-model` holds the abstract machine description, **the traits the planner queries**, and +**the plan type**. Everything depends on it; **nothing depends on this crate**. + +That is the whole point of the arrangement. If the traits lived here, an adapter whose only job is +to describe a machine would have to depend on a planner, and anyone wanting to read a topology would +pull in planning policy they did not ask for. The plan type is here for the same reason one level +down: the realizer *executes* a plan and has no business depending on the policy that chose it. + +## Two kinds of adapter + +**Inward** -- exposes the model's traits over the topology objects already designed, so +`windows_topology_sys::MachineMemoryTopology` becomes one source feeding the abstract model. It is +one source among several: storage and interconnect facts do not come from there, and neither do +measured numbers. + +**Outward (the realizer)** -- takes a plan and **realizes** it in the current process: buffers, +rings and threads, with the user's processing code inserted at the appropriate steps. + +They are separate crates despite both being Windows adapters, because their dependency sets barely +overlap -- the inward one needs only `windows-topology-sys`, while the realizer needs the runtime. +Fusing them would mean anyone reading a topology pulls in the whole runtime. + +## Why the planner is separate from the facts + +Because two different kinds of statement were being made by one crate. + +**`windows-topology-sys` states facts.** Which processors exist, what they share, at what +granularity, how that was established, and what was measured. It never says "use an SPSC ring +here", because that is not a fact about the machine. + +**This crate applies policy.** One domain per core or per thread? Are efficiency cores peers or +excluded? SPSC everywhere, or SPSC within a cache domain and something else across one? Those are +choices, they depend on the workload, and reasonable clients will differ. + +Keeping them in one crate has a specific failure mode, already observed: a policy answer gets +mistaken for a fact and consumers bind to it. `outermost_partitioning_cache` is that -- a single +policy choice ("give me one boundary to shard on") sitting in the facts crate, which three +consumers then re-derived differently. See +[CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md) SH-16.9. + +## The seam, and how to tell if it is in the right place + +**The planner must not re-derive anything.** If it has to work out for itself which cache level +partitions the machine, or reconstruct a mapping the model already knows, the seam is wrong and the +missing query belongs in `topology-model` -- or, if it is a Windows fact, in the inward adapter. + +That test is the reason this component is being planned *before* the topology model is finished +rather than after: its input requirements are the concrete statement of what the model has to +answer, and they feed the open design session directly. + +## Why it is not the runtime either + +The runtime (M33+, spanning `windows-ioring-sys`, `windows-thread-ambient-sys` and +`windows-namespace-request-sys`) *executes* a plan: it creates the threads, binds them, allocates +the pools, constructs the rings. This crate decides what that plan should be, and the realizer +bridges the two. + +Separating them means a plan is a **value** -- inspectable, comparable, testable against a +synthetic topology for a machine nobody has, and reviewable by a human before anything is pinned +or allocated. A planner fused into the runtime can only be tested by running it on the machine it +plans for, which is exactly the class of test this repository has repeatedly found inadequate. + +The arrangement it plans for is the one +[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M33+ describes -- "one pinned thread, its +`IoRing`, its node-local registered pool, its shard" -- which is a Seastar-style shard-per-core +runtime. + +## Status and gating + +Blocked on +[DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md). +The planner's central query -- "how close are these two processors?" -- has no answer in the +current topology model, and what shape it takes is the subject of that session. + +The name is settled; the crate does not exist yet. It is deliberately absent from +`release-please-config.json`, the publish workflow's tag patterns, and the workspace manifest until +there is code to publish. diff --git a/crates/windows-execution-plan/DESIGN-NOTES.md b/crates/topology-planner/DESIGN-NOTES.md similarity index 85% rename from crates/windows-execution-plan/DESIGN-NOTES.md rename to crates/topology-planner/DESIGN-NOTES.md index f88d2d31..c569ae7b 100644 --- a/crates/windows-execution-plan/DESIGN-NOTES.md +++ b/crates/topology-planner/DESIGN-NOTES.md @@ -11,8 +11,8 @@ satisfies them and not what they require. [EP-D-4](#ep-d-4) is the first genuine **choice** here, and it re-scopes the component: the planner is `topology-planner`, it plans against an abstracted idealized machine, and adapters bracket it on -both sides. **The component's own name and directory still say `windows-execution-plan`** and are -pending the layout decision EP-D-4 leaves open. +both sides. [EP-D-5](#ep-d-5) then settles the layout EP-D-4 left open, and the directory has been +renamed to match. ## Decision index @@ -22,6 +22,7 @@ pending the layout decision EP-D-4 leaves open. | EP-D-2 | **The proximity query**: how close two processors are, which selects the channel between their domains. Takes an **unordered** pair; the model has no answer today. | | EP-D-3 | **The residency query**: where a domain's pool lives, and which side of a cross-domain pair should host a shared ring. **Ordered**, and the half the model cannot answer is structurally unanswerable rather than merely unpopulated. | | EP-D-4 | **The four-part architecture, and the planner's name.** The engineer's position: the planner is **`topology-planner`** (no `windows-` prefix); it takes a **goal** description (shape deferred for litigation), queries an **abstracted idealized** model covering processors, memory, storage, interconnects, distances and bottlenecks, and emits a **JSON-serializable, platform-neutral** plan. Two kinds of **adapter** bracket it: one exposing the planner's traits over the Windows topology objects, one **realizing** a plan as buffers, rings and threads with the user's code inserted at the right steps. Settles `MMT-1.5` (the facts crate keeps its `-sys` name), the "two graphs, one word" ambiguity, and where distance lives -- the attributed interconnect shape D-9 sketched goes in the abstract model, so D-9's deferral in the facts crate stands unreopened. | +| EP-D-5 | **The component layout: `topology-model` is its own crate, and dependencies point one way.** The abstract model and the traits the planner queries live in `topology-model`, which the planner and both adapters depend on; nothing depends on `topology-planner`. Putting the traits in the planner would make a crate whose job is to *describe a machine* depend on one that applies *policy* -- the same defect as `outermost_partitioning_cache`, arriving as a dependency edge instead of an API. Two consequences derived from the same rule rather than decided separately: **the plan type also lives in `topology-model`** (otherwise the realizer depends on the planner), and the inward adapter and the realizer are **separate crates** (their dependency sets barely overlap, and fusing them would make reading a topology pull in the whole runtime). | ## EP-D-1: the shard-set query @@ -412,3 +413,65 @@ processor topology. survive a change of binding. Each stated what the planner must know and why; what changes is that they are now satisfied by traits over an abstract model rather than by methods on a Windows type. They were written against a real caller, which is what makes them portable in this way. + +## EP-D-5: the component layout, and which way dependencies point + +*The engineer's choice, following [EP-D-4](#ep-d-4). Recorded separately because EP-D-4 explicitly +left it open.* + +### The decision + +**The abstract model and the traits the planner queries live in their own crate, `topology-model`, +which both the planner and the adapters depend on.** + +| Component | Platform | Depends on | +|---|---|---| +| `topology-model` | neutral | nothing | +| `topology-planner` | neutral | `topology-model` | +| inward adapter | Windows | `topology-model`, `windows-topology-sys` | +| outward adapter (realizer) | Windows | `topology-model`, the runtime crates | + +Everything depends on `topology-model`; **nothing depends on `topology-planner`** except a caller +that actually wants to plan. + +### Why not put the traits in the planner + +Because the arrow points the wrong way. An adapter whose job is to describe a machine would have to +depend on a planner in order to describe it, and anyone wanting to read a topology would pull in +planning policy they did not ask for. That is the same defect +[COMPONENT.md](COMPONENT.md) already records in a different place -- `outermost_partitioning_cache`, +a policy answer sitting where facts are stated -- arriving as a dependency edge rather than as an +API. + +### The same rule decides where the plan type goes, one level down + +This is a **derived** consequence rather than a separately-taken decision, and it is called out +because it is easy to miss: the realizer consumes a plan. If the plan type lived in +`topology-planner`, the realizer would depend on the planner -- policy dragged in by a component +whose only job is to execute. + +So **the plan type lives in `topology-model` too**, alongside the machine vocabulary. The crate is +"the shared vocabulary", not merely "the machine description". This is consistent with +[COMPONENT.md](COMPONENT.md)'s existing argument that a plan is a **value** -- inspectable, +comparable, reviewable before anything is pinned or allocated. A value type belongs with the +vocabulary, not with the policy that produced it. + +### Two Windows adapters, not one + +Also derived. They are both Windows adapters and it is tempting to fuse them, but their dependency +sets barely overlap: the inward one needs `windows-topology-sys`, the realizer needs the runtime +(`windows-ioring-sys`, `windows-waitable-queues`, `windows-thread-ambient-sys`). Fusing them would +mean anyone reading a topology pulls in the whole runtime, which is the same "do not drag in what +the caller did not ask for" rule that decided the layout in the first place. + +### What is still open + +- **The adapters' names.** Deliberately not settled here; naming has been getting decided by + whoever writes the first type, and this component has already been renamed once. +- **Who measures.** Carried forward from [EP-D-4](#ep-d-4) and not resolved by the layout: if + distance is a property of the abstract model, measurement plausibly belongs to whatever populates + that model. `topology-model` depends on nothing, so it cannot measure; that puts the measurement in + an adapter or in a fifth thing. +- **Whether `topology-model` is one crate or eventually two.** The machine description and the plan + vocabulary are different enough that they might separate later. They are together now because + splitting on speculation costs more than merging on evidence. diff --git a/crates/windows-execution-plan/COMPONENT.md b/crates/windows-execution-plan/COMPONENT.md deleted file mode 100644 index ca3923b8..00000000 --- a/crates/windows-execution-plan/COMPONENT.md +++ /dev/null @@ -1,98 +0,0 @@ -# windows-execution-plan - -**Planned, not built.** This directory currently holds a plan and no code. It becomes a crate -when [CHECKLIST.md](CHECKLIST.md) M2 begins; until then it exists so the work has an owner and a -place, rather than living as an assumption inside somebody else's milestone. - -## What it is - -A **synthesizer**. It takes two inputs and produces a third thing: - -- **the observed machine** -- what Windows reports, plus whatever else is trivially available. This - is `windows_topology_sys::MachineMemoryTopology`, and it is **mockable**: a description of a machine nobody has - is a first-class input, which is what makes this component testable without the hardware it plans - for. -- **a description of the desired function** -- the scenario. What the caller intends to run, in - enough detail that a measurement taken on its behalf means something. - -From those it synthesizes **a concrete description of the arrangement to construct**: which -processors host domains, where each thread pins, which memory node each allocates from, what channel -connects each pair, and where each channel's buffer lives. - -Two things follow that a "takes a topology, returns a plan" description would miss. - -**It may ask.** Planning is a negotiation, not a pure function: the component may call back to its -caller through traits, for clarifying information the scenario did not settle. Which questions those -are is not yet known, and knowing them is what decides whether that is one trait or several. - -**It may measure, with permission.** This is the component that probes, and it is the right one -- -because a measured number is only meaningful alongside *what it measured*. The probe's existing -figures are nanoseconds for one ring-handoff pattern at one message size; a component that knows the -scenario can measure the right thing, where a `MachineMemoryTopology::discover` that measured could not, having -no idea what the caller intends. - -So there are three stages, each honest about its cost: **observe** (cheap, no choices), **synthesize** -(may measure, with permission), **execute** (no I/O, no probing). - -The arrangement it plans for is the one -[CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) M33+ describes -- "one pinned thread, its -`IoRing`, its node-local registered pool, its shard" -- which is a Seastar-style shard-per-core -runtime. - -## Two graphs, one word - -Both inputs and the output are graphs of processors and their relations, so "topology" fits all of -them and distinguishes none. That ambiguity is live and unresolved: what the machine **is** and what -we intend to **build on it** are different enough that a reader seeing `MachineMemoryTopology` twice will -eventually take one for the other. Naming is tracked as an open decision rather than settled by -whoever writes the first type. - -## Why it is separate - -Because two different kinds of statement were being made by one crate. - -**`windows-topology-sys` states facts.** Which processors exist, what they share, at what -granularity, how that was established, and what was measured. It never says "use an SPSC ring -here", because that is not a fact about the machine. - -**This crate applies policy.** One domain per core or per thread? Are efficiency cores peers or -excluded? SPSC everywhere, or SPSC within a cache domain and something else across one? Those are -choices, they depend on the workload, and reasonable clients will differ. - -Keeping them in one crate has a specific failure mode, already observed: a policy answer gets -mistaken for a fact and consumers bind to it. `outermost_partitioning_cache` is that -- a single -policy choice ("give me one boundary to shard on") sitting in the facts crate, which three -consumers then re-derived differently. See -[CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md) SH-16.9. - -## The seam, and how to tell if it is in the right place - -**The planner must not re-derive anything.** If it has to work out for itself which cache level -partitions the machine, or reconstruct a mapping the topology already knows, the seam is wrong and -the missing query belongs in `windows-topology-sys`. - -That test is the reason this crate is being planned *before* the topology model is finished rather -than after: its input requirements are the concrete statement of what the model has to answer, and -they feed the open design session directly. - -## Why it is not the runtime either - -The runtime (M33+, spanning `windows-ioring-sys`, `windows-thread-ambient-sys` and -`windows-namespace-request-sys`) *executes* a plan: it creates the threads, binds them, allocates -the pools, constructs the rings. This crate decides what that plan should be. - -Separating them means a plan is a **value** -- inspectable, comparable, testable against a -synthetic topology for a machine nobody has, and reviewable by a human before anything is pinned -or allocated. A planner fused into the runtime can only be tested by running it on the machine it -plans for, which is exactly the class of test this repository has repeatedly found inadequate. - -## Status and gating - -Blocked on -[DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md). -The planner's central query -- "how close are these two processors?" -- has no answer in the -current topology model, and what shape it takes is the subject of that session. - -The crate name is provisional. Changing it is cheap now and expensive once it is in -`release-please-config.json`, the publish workflow's tag patterns, and the manifest -- so it is -deliberately absent from all three until the name is ratified. diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 19d98061..9373363b 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -7,7 +7,7 @@ point here. Design decisions live in [DESIGN-NOTES.md](DESIGN-NOTES.md). The session that produced this plan is [DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md); the consumer whose requirements shaped it is -[windows-execution-plan](../windows-execution-plan/DESIGN-NOTES.md). +[topology-planner](../topology-planner/DESIGN-NOTES.md). The crate's *original* design session, which produced the model this plan reshapes, is [DESIGN-SESSION-2026-08-22-topology-schema.md](design-sessions/DESIGN-SESSION-2026-08-22-topology-schema.md). @@ -113,7 +113,7 @@ wrongly after code exists. `discover()` returns a topology stale the instant it returns, so the two-call window is only a larger instance of an unavoidable problem. True, and **not a reason to do nothing**: the two are not equally addressable. Staleness after the fact is the executor's to validate, and is already - owned as `M-inf.1` in [windows-execution-plan](../windows-execution-plan/CHECKLIST.md). + owned as `M-inf.1` in [topology-planner](../topology-planner/CHECKLIST.md). Incoherence *during* collection is ours, detectable, and cheap to fix. The framing is what caused the miss. Asking "what do we **store** when sources disagree" admits @@ -212,7 +212,7 @@ wrongly after code exists. not-observed. So this is **one** decision about one degradation path -- a fact the consumer needed and did not get -- rather than a separate answer per reason the fact is missing. > **-> CROSS-COMPONENT PREREQUISITE:** this is the same decision as `EP-1.4` in - > [windows-execution-plan](../windows-execution-plan/CHECKLIST.md), seen from the model's side + > [topology-planner](../topology-planner/CHECKLIST.md), seen from the model's side > rather than the consumer's. They were filed independently before anyone noticed. **Take them > together** -- answering either alone risks a planner that degrades in a way the model does not > support, or a model offering a fallback no consumer wants. @@ -233,12 +233,12 @@ wrongly after code exists. - [x] **MMT-1.5** -- **Does the synthesizer live in this crate, and therefore what is this crate called?** Recorded as open rather than settled: see - [windows-execution-plan/COMPONENT.md](../windows-execution-plan/COMPONENT.md). The naming follows + [topology-planner/COMPONENT.md](../topology-planner/COMPONENT.md). The naming follows the merge rather than leading it -- while this crate is only a Win32 wrapper, `-sys` is correct for it; if it gains a synthesizer that measures, it stops being one and the name should change then. **Answered by the engineer's architectural shift, recorded as - [EP-D-4](../windows-execution-plan/DESIGN-NOTES.md#ep-d-4): no.** The planner is a separate + [EP-D-4](../topology-planner/DESIGN-NOTES.md#ep-d-4): no.** The planner is a separate component named **`topology-planner`** -- with no `windows-` prefix, because it plans against an abstracted idealized machine and emits a platform-neutral plan. So this crate does not gain the synthesizer, remains a pure Win32 wrapper, and **keeps its name**. @@ -311,11 +311,11 @@ Parked on M1. ## M4: the queries Parked on M2 and M3. Each is a requirement from -[windows-execution-plan](../windows-execution-plan/DESIGN-NOTES.md), stated there against a real +[topology-planner](../topology-planner/DESIGN-NOTES.md), stated there against a real caller rather than invented here. - [ ] **M4+.1** -- **The ordered relations are the query surface; pairwise proximity is a method on - them.** The requirement arrived from [EP-D-2](../windows-execution-plan/DESIGN-NOTES.md#ep-d-2) as a + them.** The requirement arrived from [EP-D-2](../topology-planner/DESIGN-NOTES.md#ep-d-2) as a *pairwise* query returning the minimal shared granularities, **their membership**, and whether a finer granularity went **unobserved** so the answer can be an upper bound and say so. All three requirements stand. The **shape** does not, and the requirement says so itself: it asks the answer diff --git a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md index 695b4b35..6a4e894d 100644 --- a/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md +++ b/design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md @@ -265,7 +265,7 @@ SH-16.11. "Most useful for consumers" was answered by naming one: a Seastar-style shard-per-core runtime building SPSC/MPSC rings between pinned threads over NUMA-local buffers. Walking that construction produced a requirements list, and it is now owned by -[crates/windows-execution-plan](../crates/windows-execution-plan/CHECKLIST.md) M1 rather than being +[crates/topology-planner](../crates/topology-planner/CHECKLIST.md) M1 rather than being carried in this session as prose. The walk found the load-bearing query is **pairwise proximity** -- "how close are these two @@ -286,7 +286,7 @@ presupposes a plan naming which thread, which node and which shard. That is now ### The consumer's requirements, stated Three queries, recorded in full as EP-D-1, EP-D-2 and EP-D-3 in -[the planner's design notes](../crates/windows-execution-plan/DESIGN-NOTES.md). Summarised here +[the planner's design notes](../crates/topology-planner/DESIGN-NOTES.md). Summarised here because a model designed without them in view is what produced the current one. | Query | Shape | What the model must answer | From d9d0dae44d88d8bd33fc2c8a5874b55bee19d42b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:08:23 -0400 Subject: [PATCH 261/361] docs(topology): the crate publishes a refined view of the platform (D-21) The engineer's clarification. The model was originally expected to couple tightly to the solver, which is why its reshape was planned against the planner's requirements. The adapter between the platform data model and the planner relieves that tension, so the crate's job is the refinement itself. The scope test becomes "is this a refinement of what Windows reports?", never "does the planner need it?" -- and a planner requirement with no platform correspondence is the adapter's problem, not a gap here. D-20 drew the lower bound (not below the Win32 topology APIs); D-21 draws the upper. Unblocks the reshape for PR #56: - MMT-1.3 is closed. What a consumer DOES with an unobserved fact is not a question about a refined view of platform data; the model owes only that the absence be representable and distinguishable, which is D-13 and M2+.5. The behavioural half is EP-1.4's alone and is no longer a joint decision. Recorded as a planning defect rather than quietly fixed: that item had gated M2, and through it M3, M4 and M5, so a decision that was never the model's to make was holding the entire reshape. It landed in a "decisions that shape everything below" milestone because it LOOKED foundational. - M1 is 5 of 5. M2 and M3 are ready; nothing waits on the planner. - M4 re-justified: M4+.2 and M4+.3 are ordinary facts stated without sentinels, M4+.4 fixes a rule the PROBES restated three times in two crates. EP-D-1..3 are kept as EVIDENCE the shape is right rather than as its justification -- stating them found the Processor::capacity sentinel collision that reviewing the model alone had not. Also discharges two stale gates the sweep found. The release checklist said SH-3.4 waits on the design session concluding: it has, as D-13..D-21, and the MMT plan is what it produced. And SH-16.5's blocked prototype is now superseded rather than resumed -- under D-19 the unified relation set replaces the single per-processor cache-domain lookup it added, so landing it would have shipped the collapse the session existed to remove. topology-planner is deferred past the PR by direction and contributes only planning documents to it. Completed item: MMT-1.3: What a consumer does when a needed fact was not observed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 27 ++++++- PLANS.md | 2 +- crates/topology-planner/CHECKLIST.md | 24 ++++-- crates/topology-planner/COMPONENT.md | 16 +++- crates/windows-topology-sys/CHECKLIST.md | 86 ++++++++++++++------- crates/windows-topology-sys/DESIGN-NOTES.md | 65 ++++++++++++++++ 6 files changed, 177 insertions(+), 43 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index ed663eb4..c945c370 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -224,8 +224,23 @@ deliberate, which is SH-3.1.1's job below. **M16 is the exception, by decision.** Its locality-model work (SH-16.5, SH-16.8, SH-16.9, SH-16.10) is a merge blocker: the model it replaces is the one `windows-topology-sys` 0.2.0 would publish, and shipping a public surface that is already known to be the wrong shape is what the -milestone exists to avoid. So SH-3.4 waits on it, and the design session it depends on must -conclude first. +milestone exists to avoid. So SH-3.4 waits on it. + +**Updated 2026-09-03 -- what that work now is, and what discharges the gate.** All six of those items +are superseded into the `MMT-*` plan in +[crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md), so the gate is +discharged by **MMT M2 through M5 landing in this PR**, which is the engineer's direction. Two things +that previously stood in the way are gone: + +- **The design session no longer gates it.** The session's open questions were answered as `D-13` + through `D-21` in + [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md); the + `MMT-*` plan is what it produced. Read "the MMT plan concludes" wherever the earlier wording said + "the session concludes". +- **The reshape no longer waits on the planner.** [D-21](crates/windows-topology-sys/DESIGN-NOTES.md#d-21) + establishes that `windows-topology-sys` publishes a refined view of what the platform publishes, + with an adapter absorbing the planner's needs -- so the reshape is self-justified. `topology-planner` + contributes only planning documents to this PR and is deliberately deferred past it. - [x] **SH-3.1** -- ~~Open the pull request~~ **-- already open since 2026-08-31 as a draft.** Checked off as *superseded by events*, not as done: the item asked for something that had already happened @@ -835,7 +850,13 @@ predicted about a 222-commit branch. is this processor in", which **is** the single-boundary collapse that session is about, so landing it would prejudge the outcome. The prototype compiled, and its topology-side tests passed and were sabotage-verified; it was reverted deliberately and preserved outside the repository as - `sh-16.5-prototype.patch`. The contradiction is real and stays unfixed until the session concludes. + `sh-16.5-prototype.patch`. + **Unblocked 2026-09-03, and superseded rather than resumed.** The session's questions were answered + as `D-13` through `D-21`, and the answer is *not* the primitive this item prototyped: under + [D-19](crates/windows-topology-sys/DESIGN-NOTES.md#d-19) the unified relation set with its + inclusion order replaces a single per-processor cache-domain lookup, so the prototype would have + landed the collapse the session existed to remove. The contradiction is fixed by `MMT` **M2+.5** and + **M5+.4** instead. The patch is kept as the record of what was tried and why it was not taken. - [ ] **SH-16.8** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **The locality model collapses a seven-kind, any-depth topology onto one cache boundary, and nothing records that as a choice.** Raised by the engineer during the SH-16.5 fix, and diff --git a/PLANS.md b/PLANS.md index 1a7737d1..6bd824d1 100644 --- a/PLANS.md +++ b/PLANS.md @@ -18,7 +18,7 @@ plans tracker: [crates/windows-file-enumeration-sys/PLANS.md](crates/windows-fil | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| | [CHECKLIST-mutation-survivors.md](CHECKLIST-mutation-survivors.md) | not started | Work queued from the workspace-wide cargo-mutants sweep of 2026-09-02, whose findings are kept in [mutation-sweeps/2026-09-02/](mutation-sweeps/2026-09-02/README.md) rather than re-derived -- the run took roughly fourteen hours. 2,792 caught, 1,112 survived, 198 timed out. **The headline numbers mislead in three ways and the README says how**: a timeout in a blocking-API crate is usually a detection that lost its name rather than a gap (measured: one of `windows-waitable-queues`' 120 timeouts fails four tests in 0.00s when re-injected alone), a low score on an executable probe crate is measuring the wrong thing, and three kinds of survivor -- equivalent mutants, unreachable code, and constants that want a `const` assertion -- are not missing tests at all. M1 covers the shipping crates; M2 holds the two crates that are not libraries and whose scope is an engineer's decision; M3 re-runs and prunes rather than hand-editing the tool's output into a second source of truth. | [mutation-sweeps/2026-09-02/README.md](mutation-sweeps/2026-09-02/README.md) | -| [crates/topology-planner/CHECKLIST.md](crates/topology-planner/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a stated **goal** plus an abstracted idealized machine description to a set of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 is a *requirements* milestone and is the only active one -- it states what the topology must answer, feeding the open locality-model session, which asked what shape is most useful to consumers and is being answered by naming one. M2+ and M3+ are parked on that session concluding, and are additionally **awaiting a re-cut**: EP-D-4 and EP-D-5 re-scoped the component into four parts (`topology-model` holding the abstract machine description, the planner's traits and the plan type; `topology-planner`; an inward Windows adapter; an outward realizer), and only M1 has been reconciled with that. EP-1.1 is done and already earned its keep: checking the shard-set query against the model found `Processor::capacity` using `0` as both a valid efficiency class and a "not known" sentinel, which collide on every non-hybrid machine (filed as SH-16.12). | [crates/topology-planner/DESIGN-NOTES.md](crates/topology-planner/DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | +| [crates/topology-planner/CHECKLIST.md](crates/topology-planner/CHECKLIST.md) | in progress | **Planned, not built** -- the directory holds a plan and no code, and becomes a crate when M2 begins. Owns the mapping from a stated **goal** plus an abstracted idealized machine description to a set of execution domains: which processors host a domain, where each thread pins, which memory node it allocates from, what channel connects each pair, and where each channel's buffer lives. Filed because that mapping was **unowned**: [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M32 lists the contracts "the runtime cannot be written without" and all of them concern the queue, while M33+.1 opens with "one pinned thread, its `IoRing`, its node-local registered pool, its shard" -- presupposing a plan nothing computed. Separate from `windows-topology-sys` because that crate states **facts** and this one applies **policy**; fusing them is what produced `outermost_partitioning_cache`, a policy answer sitting in the facts crate that three consumers then re-derived differently (SH-16.9). M1 was a *requirements* milestone -- it states what the topology must answer, and it fed the locality-model session, which has since concluded as `D-13`..`D-21`. **The component is deferred past PR #56 by direction**: it contributes only planning documents there, and per `D-21` the topology reshape lands without it, since `windows-topology-sys` publishes a refined view of what the platform publishes and an adapter absorbs the rest. M2+ and M3+ are parked on that session concluding, and are additionally **awaiting a re-cut**: EP-D-4 and EP-D-5 re-scoped the component into four parts (`topology-model` holding the abstract machine description, the planner's traits and the plan type; `topology-planner`; an inward Windows adapter; an outward realizer), and only M1 has been reconciled with that. EP-1.1 is done and already earned its keep: checking the shard-set query against the model found `Processor::capacity` using `0` as both a valid efficiency class and a "not known" sentinel, which collide on every non-hybrid machine (filed as SH-16.12). | [crates/topology-planner/DESIGN-NOTES.md](crates/topology-planner/DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | | [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) | in progress | M30-M32: a published queue crate (SPSC and bounded-array MPSC, with a lazily created manual-reset doorbell whose reset cannot be separated from the observation that there is nothing to take -- achieved by ordering plus a re-check rather than by a lock, per D-9 and D-15), then the three contract decisions -- ordering, correlation, backpressure -- the domain runtime cannot be written without. M33+ parks the runtime itself, the creation-time-affinity thread builder, the namespace `Outcome` extension, the client-side `ThreadpoolWait` fan-in helper, and the durability crate. M-inf holds three items each gated on a specific measurement rather than on taste. The N=1 path is the whole first deliverable and depends on no NUMA hardware. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md](design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md) | | [CHECKLIST.md](CHECKLIST.md) | in progress | M19: propagate the 2026-08-27 platform measurements (IoRing registration replaces the table; the completion-port/`IoRing` fork; `runs_long` as the growth mechanism; the measured 512 default maximum) into the crates whose code or documentation currently assumes otherwise. M20: decide the session-independent path form, now that path resolution is measured to follow the impersonated token's logon session. M21: reconcile with the impersonation and enumeration crates that landed during the session. M34 carries the review-driven repairs raised while shipping the placement tool: M34.1 (the reusable sabotage harness) is done, and M34.2 (route the placement tool's output through a sink rather than writing to stdout from many sites) and M34.3 (archive twelve completed item bodies) are open. | [DESIGN-NOTES.md](DESIGN-NOTES.md#remoting-synchronous-namespace-operations) | | [CHECKLIST-ship-topology-and-queues.md](CHECKLIST-ship-topology-and-queues.md) | in progress | Release `windows-topology-sys` 0.2.0 and `windows-waitable-queues` 0.1.0, which everything shorter-term depends on. Deliberately redundant with [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md): that file plans the design, this one plans the release, and a release has failure modes a design checklist does not surface. Two were found while writing it -- `windows-waitable-queues-v*` is missing from the publish workflow's tag list, so release-please would tag it and nothing would publish it, silently; and `windows-ioring-sys` is published against `windows-topology-sys = "0.1.0"`. (That second finding was later **corrected at SH-2.2**: the pin is a *dev*-dependency, which consumers never resolve, so it obliges a pin update but no release.) M1 settled the public surface before it was public (done, archived); M2 repairs the plumbing; M3 lands the branch; M4 releases; M5 verifies from outside the workspace; M6 is long-running validation and gates the queue crate's release specifically. M7-M13 were seven PR #56 review rounds (done, archived); M14, M15 and M16 are the three later rounds and carry the file's open work -- M15 owns the fix for an ABA hole that ships **disclosed rather than fixed**, so it does not block the release. M16 is the SH-3.1.1 diff review, the first to read the branch as a diff rather than react to a comment: seven findings, six fixed, including a publish-workflow regression this branch had introduced two commits earlier and a soundness hole in the crate about to freeze its API. Its remaining four are blocked on [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md), which began by asking whether collapsing a seven-kind, any-depth topology onto a single cache boundary is the right projection and has since settled that presence and observation must be modeled rather than collapsed into an `Option`. **That work now gates the merge**: unlike M14 and M15, which concern a defect in an implementation that can ship disclosed, M16 concerns the shape of the public model `windows-topology-sys` 0.2.0 would publish, and a published model cannot be reshaped without another break. So M3 waits on M16, and M16 waits on the session. The file opens with a status table. | [crates/windows-topology-sys/DESIGN-NOTES.md](crates/windows-topology-sys/DESIGN-NOTES.md), [crates/windows-waitable-queues/DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md) | diff --git a/crates/topology-planner/CHECKLIST.md b/crates/topology-planner/CHECKLIST.md index 67b375af..8f1f8a02 100644 --- a/crates/topology-planner/CHECKLIST.md +++ b/crates/topology-planner/CHECKLIST.md @@ -20,17 +20,21 @@ everything depends on and that depends on nothing. *requirements* milestone rather than an implementation one: its output is the concrete statement of what the model must answer, which the open design session needs in order to settle it. -> **-> CROSS-COMPONENT PREREQUISITE:** M2 onwards cannot begin until -> [DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) -> concludes and `SH-16.8` lands in -> [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). The -> planner's central query has no answer in the current model. +**Deferred past PR #56, by direction.** This component contributes only planning documents to that +PR and no code. `windows-topology-sys`'s reshape lands there without it: per +[D-21](../windows-topology-sys/DESIGN-NOTES.md#d-21) that crate publishes a refined view of what the +platform publishes, and an **adapter** absorbs whatever this component needs beyond it, so the two +are no longer coupled. + +The design session that gated M2 onward **has concluded** -- its questions were answered as `D-13` +through `D-21`, and the `MMT-*` plan is what it produced. M2+ is now gated on this component's own +prerequisites rather than on someone else's decision. | Milestone | State | What it is waiting on | |---|---|---| -| M1 the input contract | 3 done, 2 blocked | as far as it can go before the model exists | +| M1 the input contract | 4 done, 1 open | `EP-1.5`'s coverage half, which wants a settled model | | M1+ scenario and naming | **partly answered** | the name is settled (EP-D-4); the goal input is deferred for litigation, by direction | -| M2+ the plan as a value | parked, **and needs re-cutting** | the layout decision, then M1 | +| M2+ the plan as a value | parked, **and needs re-cutting** | re-cut against EP-D-4/EP-D-5, then the topology reshape landing | | M3+ the policies | parked | M2+ | | M-inf parked | ungated | not scheduled, deliberately | @@ -126,6 +130,12 @@ whether the topology can answer it today -- so the model is designed against a r contested is one the unified view does not cover, which is indistinguishable from not-observed to a consumer -- so this is one decision about one degradation path, not one per reason a fact is missing. The three candidate behaviours are unchanged. + **And it is no longer a duplicate**, per [D-21](../windows-topology-sys/DESIGN-NOTES.md#d-21). + `windows-topology-sys` publishes a refined view of what the platform publishes; what a consumer + *does* with an unobserved fact is not a question about that view. That crate owes only that the + absence be representable and distinguishable, which its `M2+.5` implements. `MMT-1.3` is closed on + those grounds, so **this item is now this component's decision alone** -- there is nothing left to + take jointly, and it no longer blocks anything in the model crate. - [ ] **EP-1.5** -- **Hand the resulting requirements to the design session** as the consumer-side input it asked for, and record in the session which of them the settled model answers and which diff --git a/crates/topology-planner/COMPONENT.md b/crates/topology-planner/COMPONENT.md index d1f556ce..439bac09 100644 --- a/crates/topology-planner/COMPONENT.md +++ b/crates/topology-planner/COMPONENT.md @@ -105,10 +105,18 @@ runtime. ## Status and gating -Blocked on -[DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md). -The planner's central query -- "how close are these two processors?" -- has no answer in the -current topology model, and what shape it takes is the subject of that session. +**Deferred past PR #56, by the engineer's direction.** This component contributes only planning +documents to that PR and no code. The topology reshape it fed requirements into is landing there +without it, because [D-21](../windows-topology-sys/DESIGN-NOTES.md#d-21) establishes that +`windows-topology-sys` publishes a refined view of what the platform publishes and an **adapter** +absorbs whatever this component needs beyond that -- so the reshape is self-justified and the two are +no longer coupled. + +The design session that previously blocked this component has concluded: its questions were answered +as `D-13` through `D-21` in +[windows-topology-sys/DESIGN-NOTES.md](../windows-topology-sys/DESIGN-NOTES.md), and the central +query -- "how close are these two processors?" -- is answered by the ordered relation set that `MMT` +M2 and M4 build. What remains here is this component's own work, not a wait on someone else's. The name is settled; the crate does not exist yet. It is deliberately absent from `release-please-config.json`, the publish workflow's tag patterns, and the workspace manifest until diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 9373363b..14d8ace2 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -15,28 +15,39 @@ Completed milestones are archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIS ## What this is for -The current model describes a machine as a list of domains, and answers questions about it with one -global projection (`outermost_partitioning_cache`) that three consumers have independently -re-derived, two of them differently. It cannot say whether a fact was observed or merely absent, it -cannot answer anything about a *pair* of processors, and it collapses a seven-kind, any-depth -locality graph onto a single cache boundary. +This crate publishes a **refined view of what the platform publishes** +([D-21](DESIGN-NOTES.md#d-21)). The current model does that badly: it describes a machine as a list +of domains and answers questions with one global projection (`outermost_partitioning_cache`) that +three consumers have independently re-derived, two of them differently. It cannot say whether a fact +was observed or merely absent, it cannot answer anything about a *pair* of processors, and it +collapses a seven-kind, any-depth locality graph onto a single cache boundary. The reshape has one governing idea, settled with the engineer: **model the observed connectivity.** Presence and observation are facts to represent, not shapes to infer from. +**The scope test is "is this a refinement of what Windows reports?"** -- never "does the planner need +it?". [D-20](DESIGN-NOTES.md#d-20) draws the lower bound (the crate does not go below the Win32 +topology APIs); D-21 draws the upper one. A planner requirement with no platform correspondence is +the **adapter's** problem and must not be filed here as a gap. + ## Where this stands | Milestone | State | What it is waiting on | |---|---|---| -| M1 settle what is still open | 4 of 5 done | MMT-1.3, which is joint with the planner's `EP-1.4` | -| M2 the granularity model | parked | M1 | -| M3 observation and provenance | parked | M1 (1 of 4 answered early, by D-19) | +| M1 settle what is still open | **5 of 5 done** | nothing -- complete | +| M2 the granularity model | **ready** | nothing; M1 is closed and D-21 makes it self-justified | +| M3 observation and provenance | **ready** | nothing (1 of 4 answered early, by D-19) | | M4 the queries | parked | M2, M3 | -| M5 the defects this subsumes | parked | M4 | +| M5 the defects this subsumes | parked | M4, **except M5+.5 (done)** | + +**M1 was decision work, not implementation**, and it is complete. Each item was a question the +session left open, and each would have changed the shape of everything below it. -**M1 is decision work, not implementation.** Each item is a question the session left open, and each -would change the shape of everything below it. They are cheap to answer and expensive to answer -wrongly after code exists. +**M2 onward is implementation, and it is in scope for PR #56.** Per +[D-21](DESIGN-NOTES.md#d-21) the reshape is self-justified as the refined view rather than waiting on +a consumer, so nothing here is gated on the planner. Taking it into the current PR means +`windows-topology-sys` 0.2.0 ships the shape once, instead of publishing a surface already known to +be wrong and breaking again later. ## M1: settle what is still open @@ -203,19 +214,25 @@ wrongly after code exists. (**no** -- [D-15](DESIGN-NOTES.md#d-15) rejected reduce-on-insert, and preferring is that by another name). What remains is listed above. -- [ ] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that +- [x] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that the model answers without further measurement. Degrade to a documented weaker policy, refuse, or answer with an explicit "chosen without knowing X" marker. - **Narrowed by [D-19](DESIGN-NOTES.md#d-19), which is worth noting because it removes two thirds of - the item.** A contested subject was going to need its own answer beside "not observed"; it does - not. The unified view simply does not cover it, and *not covered* is [D-13](DESIGN-NOTES.md#d-13)'s - not-observed. So this is **one** decision about one degradation path -- a fact the consumer needed - and did not get -- rather than a separate answer per reason the fact is missing. - > **-> CROSS-COMPONENT PREREQUISITE:** this is the same decision as `EP-1.4` in - > [topology-planner](../topology-planner/CHECKLIST.md), seen from the model's side - > rather than the consumer's. They were filed independently before anyone noticed. **Take them - > together** -- answering either alone risks a planner that degrades in a way the model does not - > support, or a model offering a fallback no consumer wants. + **Narrowed by [D-19](DESIGN-NOTES.md#d-19), then resolved as not-this-crate's by + [D-21](DESIGN-NOTES.md#d-21).** D-19 removed two thirds of it: a contested subject is one the + unified view does not cover, which is D-13's not-observed, so there is one degradation path rather + than one per reason a fact is missing. + D-21 then places the remainder. This crate publishes a **refined view of what the platform + publishes**; what a consumer *does* with an unobserved fact is not a question about that view. The + model owes only that the absence be **representable and distinguishable** -- which is + [D-13](DESIGN-NOTES.md#d-13), implemented by **M2+.5**. The behavioural decision is the consumer's + and stays with `EP-1.4`. + **This item was gating M2, and through it M3, M4 and M5** -- a decision that was never the model's + to make was holding the whole reshape. Recorded as a planning defect rather than quietly fixed: it + landed in a "decisions that shape everything below" milestone because it *looked* foundational, and + foundational-looking is not the same as being about this component. + > **-> CROSS-COMPONENT HANDOFF:** the behavioural half is `EP-1.4` in + > [topology-planner](../topology-planner/CHECKLIST.md). It no longer has a counterpart here, so it + > is that component's decision alone rather than a joint one. - [x] **MMT-1.4** -- **Does `distances` survive at all?** The two-component architecture says the *synthesizer* measures, with the caller's permission, for its own scenario -- so a measured number @@ -250,7 +267,8 @@ wrongly after code exists. ## M2: the granularity model -Parked on M1. Shape recorded so it is not lost. +**Ready.** M1 is closed, and [D-21](DESIGN-NOTES.md#d-21) makes every item here a refinement of what +Windows reports rather than something a planner asked for. - [ ] **M2+.1** -- Model **observed sharing relations**, not a ladder of levels with optional rungs. A machine with no L3 has no L3 relation, which is an observation rather than a missing value. @@ -283,7 +301,7 @@ Parked on M1. Shape recorded so it is not lost. ## M3: observation and provenance -Parked on M1. +**Ready.** M1 is closed. - [ ] **M3+.1** -- Provenance is **per relation**, not per source. Per-relation subsumes per-source by repetition, and the reverse fails on the case that matters: two sources describing the *same* @@ -310,9 +328,21 @@ Parked on M1. ## M4: the queries -Parked on M2 and M3. Each is a requirement from -[topology-planner](../topology-planner/DESIGN-NOTES.md), stated there against a real -caller rather than invented here. +Parked on M2 and M3. + +**Each is a refinement of what Windows reports**, per [D-21](DESIGN-NOTES.md#d-21) -- not a planner +requirement, which is how they were first justified. The change matters because it changes what is +in scope: a query the platform's data supports belongs here whether or not any planner wants it, and +a planner requirement with no platform correspondence belongs to the adapter. + +Read on their own terms, most of these were never planner-shaped. `M4+.2` and `M4+.3` are ordinary +facts about processors and memory stated without sentinels; `M4+.4` fixes a rule the **probes** have +restated three times in two crates. Only `M4+.1`'s pairwise helper is consumer-flavoured, and the +ordered collection it derives from is what stops that restatement recurring. + +They remain cross-referenced to [topology-planner](../topology-planner/DESIGN-NOTES.md) as +**evidence** the shape is right rather than as its justification -- stating those requirements found +the `Processor::capacity` sentinel collision that reviewing the model alone had not. - [ ] **M4+.1** -- **The ordered relations are the query surface; pairwise proximity is a method on them.** The requirement arrived from [EP-D-2](../topology-planner/DESIGN-NOTES.md#ep-d-2) as a diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 59c49eb2..bea6794b 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -40,6 +40,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-18 | **An observation is `(subject, claim, source)`, where a subject is either a relation identity or a processor attribute -- which closes the last gap in [D-15](#d-15).** Membership identity reaches partition disagreements but not per-processor scalars like efficiency class; generalising the *subject* rather than inventing a second mechanism covers both with one rule. Two smaller answers ride along: a topology records **that** collection concluded incoherently **and which subjects disagreed** -- the latter because a consumer forced to re-derive the comparison is the SH-16.9 failure repeating -- and the retry bound is a small documented constant whose exhaustion is a *conclusion*, not a failure. | | D-19 | **When the sources align -- which is the usual case -- a *unified* view is presented, in addition to the individual per-source ones.** A design that made every answer carry a coherence state was the sentinel mistake in another form: it let a case that essentially never happens shape every caller on every machine, and it made a *local* defect global, presenting a machine with one contested core as entirely uncertain. [D-15](#d-15) had already concluded the opposite and was simply not applied. Under `(kind, membership)` identity the unification is **free** -- agreeing sources have observed one relation, so there is no merge step -- and a contradiction contests only those processors at that kind. A contested subject needs no new vocabulary: it is a relation the unified view does not cover, which is [D-13](#d-13)'s *not observed*, so a consumer implements one degradation path rather than three. Requires that relations carry **attributes** as well as memberships. | | D-20 | **This crate does not go below the Win32 topology APIs, so if they do not report a fact, the crate does not have it -- and `distances` is therefore deleted rather than filled.** The engineer's ruling, and it is a **scope boundary** rather than a judgement about the field: ACPI carries SLIT, no Win32 API surfaces it, and reading firmware directly would be going below the boundary. Two supporting findings, neither of which is the reason: `distances` could never carry `Measured` provenance **by construction** (its only inputs are hand-construction, which is `Synthetic`, and deserialization, which per [D-12](#d-12) can only downgrade), and it has **zero read sites** -- `windows-platform-probes`' `render_node_distances` reads the probe's own measured `Observation`, not this field. What is lost is named rather than skated past: [D-10](#d-10)'s platform-neutral description can no longer carry Linux SLIT data. That capability was real, and it is given up because the two-component split routes distance through the synthesizer's *measurement*, which a fed-in description cannot substitute for. | +| D-21 | **This crate publishes a *refined view of what the platform publishes* -- it is not shaped by the planner.** The model was originally expected to couple tightly to the solver, which is why its reshape was planned against the planner's requirements; the **adapter** between the platform data model and the planner relieves that tension, and the engineer's clarification makes the refinement the crate's whole job. The scope test is therefore "is this a refinement of what Windows reports?", never "does the planner need it?" -- and a planner requirement with **no platform correspondence is the adapter's problem**, not a gap here. Two consequences: the reshape (M2-M5) is **self-justified** and no longer waits on a planner, and `MMT-1.3` stops gating it, because what a consumer *does* with an unobserved fact is not a question about a refined view of platform data. The model owes only that the absence be representable and distinguishable, which is [D-13](#d-13) and `M2+.5`. `EP-D-1`..`EP-D-3` survive as **evidence** the shape is right rather than as its justification -- a shape that answers a real caller's questions is better validated than one invented in the abstract. | ## D-12: provenance, and why the default points at distrust @@ -563,6 +564,70 @@ It does mean such a description no longer **round-trips** -- the value is droppe on write. That is a silent drop, which this crate has objected to elsewhere, so it is documented at the site rather than left to be discovered. +## D-21: a refined view of what the platform publishes + +*The engineer's clarification, 2026-09-03. It re-justifies the whole `MMT-*` reshape and unblocks +it.* + +### What this crate is for + +**A refined view of what the platform publishes.** That is the whole job. Windows reports processor +and memory structure through two APIs, in overlapping and differently-labelled shapes; this crate +turns that into one coherent, honestly-qualified statement of what was observed. + +[D-20](#d-20) already drew the *lower* bound -- the crate does not go below the Win32 topology APIs. +D-21 draws the *upper* one: it does not go above them either. It refines; it does not serve a +particular consumer. + +### What changed, and why it was worth saying + +The reshape was planned against the planner's requirements, because the model was expected to couple +tightly to the solver. Under that expectation the coupling was not a mistake -- if the planner reads +this crate directly, then the planner's questions *are* the specification. + +The **adapter** relieves it. With a translation layer between the platform data model and the +planner, the planner's questions are the adapter's problem, and this crate goes back to answering +only "what does the platform say, refined". + +### The scope test, and what it moves + +The test becomes **"is this a refinement of what Windows reports?"** -- never "does the planner need +it?". So: + +- A planner requirement with **no platform correspondence** belongs to the adapter. It is not a gap + here, and it must not be filed as one. +- A refinement Windows's data supports is in scope **whether or not any planner wants it**, which is + the PRIME DIRECTIVE applied to the model's own contents. + +### Two things this settles + +**The reshape is self-justified.** M2 through M5 stop being "what the planner needs" and become "the +refined view, stated properly". Read that way, every milestone survives on its own evidence: the +relation model and inclusion order come from the two sources' own disagreement about labels +([D-15](#d-15)); absence-honesty from [D-13](#d-13)'s audit; the named projection from the +partitioning rule being restated three times in two crates, **among the probes**, with no planner +involved. + +**`MMT-1.3` stops gating it.** What a consumer *does* with an unobserved fact -- degrade, refuse, or +mark -- is not a question about a refined view of platform data. This crate owes only that the +absence be **representable and distinguishable**, which is D-13 and `M2+.5`. The behavioural half is +the consumer's and lives with `EP-1.4`. + +That item had gated M2, which gated M3, M4 and M5 in turn -- so a decision that was never the model's +to make was holding the entire reshape. Worth naming as a planning defect rather than quietly +fixing: an item lands in a "decisions that shape everything below" milestone by looking foundational, +and foundational-looking is not the same as being about *this* component. + +### What the planner's requirements are still good for + +`EP-D-1` through `EP-D-3` remain valuable as **evidence**, and are cited that way rather than deleted. +A shape that demonstrably answers a real caller's questions is better validated than one invented in +the abstract, and stating them found defects nothing else had: the `Processor::capacity` sentinel +collision (`SH-16.12`) came out of checking the shard-set query against the model, not out of +reviewing the model on its own. + +They are no longer the *justification*, and nothing in this crate waits on them. + ## What was deliberately excluded (D-9) Recorded because what a design declines is as important as what it adopts, and because each of these was From a96ef784c548ea1abee2ddac235a012c158b62c8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:29:27 -0400 Subject: [PATCH 262/361] docs(topology): re-plan M2 on execution -- two items already describe the code Checking M2's six items against the code they reshape found two that assert a gap the crate does not have, and three that are one deliverable. M2+.1 and M2+.6 are already satisfied. `Domain` is a relation over a ProcessorSet; DomainKind is open with seven kinds and has carried per-kind attributes since D-4 -- Memory { memory_bytes }, Core { efficiency_class, simultaneous_multithreading }, Cache { level, .. }, Other { attributes }. `cache_levels()` is already documented as derived from what the topology contains rather than from a fixed ceiling, with a regression test guarding the sweeping-1..=4 hazard M2+.1 names. M2+.6's premise was simply wrong: I wrote "has nowhere to live unless a relation holds a payload" from the abstract (kind, membership) framing while recording D-19, without checking the type, which had solved it two decisions earlier. Both items are kept rather than deleted -- once is a slip, twice in one milestone is a method problem worth leaving visible: check the item against the code before planning work from it. M2+.2, M2+.3 and M2+.4 are acknowledged as coupled and will land in one commit citing all three. An inclusion-derived order cannot be defined without deciding its top and its behaviour when two elements do not nest; splitting the commits would disguise the coupling rather than remove it. Also recorded: the concrete target for M2+.2 is that cache_levels() sorts by firmware `level` and outermost_partitioning_cache() walks it with .rev(), so the crate's only ordering today IS firmware numbering -- the thing the item forbids. And the D-15 work of separating relation identity from the Domain::id label is named as M3's, not silently absorbed here. Completed items: M2+.1, M2+.6 Completed item: M2+.1: Model observed sharing relations, not a ladder of levels with optional rungs Completed item: M2+.6: Relations carry attributes, not only memberships Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 46 +++++++++++++++++++++--- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 14d8ace2..1d7cd96a 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -270,13 +270,28 @@ be wrong and breaking again later. **Ready.** M1 is closed, and [D-21](DESIGN-NOTES.md#d-21) makes every item here a refinement of what Windows reports rather than something a planner asked for. -- [ ] **M2+.1** -- Model **observed sharing relations**, not a ladder of levels with optional rungs. +**Re-planned 2026-09-03, on execution.** Checking these six against the code they reshape found two +that already describe the status quo and three that are one deliverable. Recorded rather than +quietly worked around, per the re-plan rule. + +- [x] **M2+.1** -- Model **observed sharing relations**, not a ladder of levels with optional rungs. A machine with no L3 has no L3 relation, which is an observation rather than a missing value. + **Already satisfied; this item described the existing design.** `Domain` *is* a relation over a + `ProcessorSet`; `DomainKind` is open with seven kinds (D-4); `Cache { level: u8 }` has no fixed + rungs; and `cache_levels()` is documented as "derived from what the topology actually contains + rather than from a fixed ceiling", with a regression test guarding the exact hazard this item + names. A machine with no L3 simply has no `Cache { level: 3 }` domain today. + Not absorbed silently: separating *relation identity* `(kind, membership)` from the **label** + `Domain::id`, which [D-15](DESIGN-NOTES.md#d-15) requires, is real remaining work -- but it is + observation work and belongs to **M3**, not here. - [ ] **M2+.2** -- Derive the order from **observed set inclusion**, never from firmware level numbers. Inclusion is checkable; numbering is asserted, and this crate has been bitten by asserted structure before -- the ARM64 host with no L3, and the guard test against a consumer sweeping `1..=4`. + **The concrete target:** `cache_levels()` sorts by firmware `level`, and + `outermost_partitioning_cache()` walks it with `.rev()` -- so today's only ordering *is* firmware + numbering, which is what this item forbids. - [ ] **M2+.3** -- Give the order an explicit **top** ("the machine"), so a pairwise query is total. Two processors always share one address space, one scheduler and one memory system; without a top, @@ -286,18 +301,39 @@ Windows reports rather than something a planner asked for. granularities may not nest, and the honest answer to "tightest shared" is then a set of minimal elements -- almost always one, but not by construction. + > **M2+.2, M2+.3 and M2+.4 are one deliverable and land in one commit citing all three.** They are + > not independently implementable: an inclusion-derived order cannot be defined without deciding + > what its top is and what happens when two elements do not nest, and a type that answered only one + > of the three would not compile into anything coherent. This is the acknowledged-coupling case, + > named rather than disguised by splitting the commits. + > + > **Shape:** the order is over *relations*, compared by processor-set inclusion, with a synthetic + > `Machine` top that is **not** inserted into `domains` -- putting it there would claim the platform + > observed it. The operation the order exists to support is "the minimal relations containing this + > set of processors", which returns a `Vec` precisely because M2+.4 says minimality need not be + > unique. The *pairwise* query built on it, with membership and the upper-bound flag, is `M4+.1`. + - [ ] **M2+.5** -- Make absence first-class per [D-13](DESIGN-NOTES.md#d-13): **not observed**, **observed and absent**, and **a negative result** are three different facts that an `Option` spells identically. Per [D-19](DESIGN-NOTES.md#d-19) this also carries the contested case -- a subject the sources genuinely disagreed on is one the unified view does not cover, which is *not observed*, so no fourth state is added. + **Deliverable: the vocabulary type**, which `M5+.2` and `M5+.4` then consume -- M5+.4 already says + "M2+.5 gives it the vocabulary to accept one". Independent of M2+.2/.3/.4, so it lands separately. -- [ ] **M2+.6** -- **Relations carry attributes, not only memberships.** Required by +- [x] **M2+.6** -- **Relations carry attributes, not only memberships.** Required by [D-19](DESIGN-NOTES.md#d-19): once the relation set *is* the unified model, `DomainKind::Memory { memory_bytes }` and `Core { efficiency_class, simultaneous_multithreading }` - have nowhere to live unless a relation holds a payload alongside its processor set. Nothing else in - M2 provides this, and it was noticed only when the unified view was written down -- the - membership-only framing had quietly assumed relations were bare sets. + have nowhere to live unless a relation holds a payload alongside its processor set. + **Already satisfied, and the premise was wrong.** `DomainKind` has carried per-kind attributes + since D-4: `Memory { memory_bytes }`, `Core { simultaneous_multithreading, efficiency_class }`, + `Cache { level, associativity, line_size, size_bytes, cache_type }`, and `Other { name, attributes + }` for a kind this crate cannot interpret. Nothing had "nowhere to live". + The item was written from the abstract `(kind, membership)` framing while recording D-19, without + checking it against the type -- which had solved this two decisions earlier. Kept rather than + deleted because it is the second time in this milestone that an item asserted a gap the code did + not have, and once is a slip while twice is a method problem: **check the item against the code + before planning work from it.** ## M3: observation and provenance From b3f8de4e5f40fb28d36859a7437aa07290be1243 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:36:33 -0400 Subject: [PATCH 263/361] feat(topology): order granularities by observed inclusion, with a top The granularity order M2+.2, M2+.3 and M2+.4 call for. They land together because they are one type: an order derived from inclusion cannot be defined without deciding what its top is and what it does when two elements do not nest. The coupling is acknowledged rather than disguised by splitting. Inclusion, not the level number (M2+.2). Today's only ordering IS firmware numbering -- cache_levels() sorts by `level` and outermost_partitioning_cache() walks it with .rev() -- and a level number is asserted by firmware where a membership is observed. Asserted structure is what has bitten this crate before: the ARM64 host with no L3, and the consumer sweeping 1..=4. Inclusion is also strictly more general, ordering a cache against a core or a NUMA node, which share no numbering with it and could not otherwise be compared at all. A top (M2+.3), as a FALLBACK rather than a competitor. Granularity::Machine is returned exactly when no reported relation covers the query, so it never appears beside an observed relation, and it is deliberately not inserted into `domains` -- that would claim the platform observed it. Treating it as an ordinary element was rejected on its consequence: on a machine whose group domain spans every processor, every answer would carry a redundant second element, which is systematic noise, not M2+.4's "almost always one". Minimality is a set (M2+.4), by strict subset, so ties survive. The measured case is L1 arriving as a data cache and an instruction cache over identical processors: two relations, distinct in kind and attributes, and picking one would be arbitrary. Totality holds over processors the topology knows. A query naming an unknown processor answers empty, not Machine -- claiming the machine contains a processor it has never heard of would be an invention. SABOTAGE-VERIFIED, AND IT FOUND A REAL GAP. Removing the strictness from minimality failed 10 of 20 tests. Breaking the new ProcessorSet::is_subset failed only ONE, incidentally: the primitive the whole order rests on had no direct tests, and every granularity test used group 0 alone, so the multi-group path -- exactly where "not covered" differs from "absent" -- was untested. Seven is_subset tests and a cross-group order test took that from 1 detection to 4, two naming the defect directly. Completed items: M2+.2, M2+.3, M2+.4 Completed item: M2+.2: Derive the order from observed set inclusion, never from firmware level numbers Completed item: M2+.3: Give the order an explicit top ("the machine"), so a pairwise query is total Completed item: M2+.4: Represent incomparable granularities Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 23 +- .../windows-topology-sys/src/granularity.rs | 173 ++++++++ .../src/granularity/tests.rs | 371 ++++++++++++++++++ crates/windows-topology-sys/src/lib.rs | 4 + .../windows-topology-sys/src/processor_set.rs | 17 + .../src/processor_set/tests.rs | 98 +++++ 6 files changed, 683 insertions(+), 3 deletions(-) create mode 100644 crates/windows-topology-sys/src/granularity.rs create mode 100644 crates/windows-topology-sys/src/granularity/tests.rs diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 1d7cd96a..e62eafd1 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -285,7 +285,7 @@ quietly worked around, per the re-plan rule. `Domain::id`, which [D-15](DESIGN-NOTES.md#d-15) requires, is real remaining work -- but it is observation work and belongs to **M3**, not here. -- [ ] **M2+.2** -- Derive the order from **observed set inclusion**, never from firmware level +- [x] **M2+.2** -- Derive the order from **observed set inclusion**, never from firmware level numbers. Inclusion is checkable; numbering is asserted, and this crate has been bitten by asserted structure before -- the ARM64 host with no L3, and the guard test against a consumer sweeping `1..=4`. @@ -293,11 +293,11 @@ quietly worked around, per the re-plan rule. `outermost_partitioning_cache()` walks it with `.rev()` -- so today's only ordering *is* firmware numbering, which is what this item forbids. -- [ ] **M2+.3** -- Give the order an explicit **top** ("the machine"), so a pairwise query is total. +- [x] **M2+.3** -- Give the order an explicit **top** ("the machine"), so a pairwise query is total. Two processors always share one address space, one scheduler and one memory system; without a top, every caller writes the same empty-case branch for a cross-node pair. -- [ ] **M2+.4** -- Represent **incomparable** granularities. An inclusion order is partial, so two +- [x] **M2+.4** -- Represent **incomparable** granularities. An inclusion order is partial, so two granularities may not nest, and the honest answer to "tightest shared" is then a set of minimal elements -- almost always one, but not by construction. @@ -312,6 +312,23 @@ quietly worked around, per the re-plan rule. > observed it. The operation the order exists to support is "the minimal relations containing this > set of processors", which returns a `Vec` precisely because M2+.4 says minimality need not be > unique. The *pairwise* query built on it, with membership and the upper-bound flag, is `M4+.1`. + > + > **Done.** `src/granularity.rs` -- `Granularity::{Relation, Machine}`, + > `MachineMemoryTopology::{machine_processors, minimal_shared, is_finer_than}`, on a new + > `ProcessorSet::is_subset`. 21 tests. + > **The top is a fallback, not a competitor**: `Machine` is returned exactly when no reported + > relation covers the query, so it never appears beside an observed relation. The alternative -- + > treating it as an ordinary element -- was rejected on measurement of its consequence: on a machine + > whose group domain spans every processor, every answer would carry a redundant second element, + > which is systematic noise rather than M2+.4's "almost always one". + > **Totality is over processors the topology knows.** A query naming an unknown processor answers + > *empty*, not `Machine`, because claiming the machine contains a processor it has never heard of + > would be an invention. + > **Sabotage-verified, and it found a real gap.** Removing the strictness from minimality failed 10 + > of 20 tests. Breaking `is_subset` failed only **one**, incidentally -- the new primitive the whole + > order rests on had no direct tests, and every granularity test used group 0 alone, so the + > multi-group path was untested. Seven `is_subset` tests and a cross-group order test took that from + > 1 detection to 4, two of which name the defect directly. - [ ] **M2+.5** -- Make absence first-class per [D-13](DESIGN-NOTES.md#d-13): **not observed**, **observed and absent**, and **a negative result** are three different facts that an `Option` diff --git a/crates/windows-topology-sys/src/granularity.rs b/crates/windows-topology-sys/src/granularity.rs new file mode 100644 index 00000000..0b2064c5 --- /dev/null +++ b/crates/windows-topology-sys/src/granularity.rs @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Mike Grier +//! The granularity order: what a set of processors shares, ordered by +//! observed set inclusion. +//! +//! This is the model `M2+.2`, `M2+.3` and `M2+.4` call for, and the three are +//! one type because they cannot be separated: an order derived from inclusion +//! has to say what its top is and what it does when two elements do not nest. +//! +//! # Why inclusion, and not the level number +//! +//! [`DomainKind::Cache`](crate::DomainKind::Cache) carries a firmware `level`, +//! and ordering by it is the obvious thing to do. This crate does not, because +//! a level number is **asserted** by firmware while a membership is +//! **observed**, and asserted structure is what has bitten this crate before: +//! the ARM64 host that reports no L3 at all, and the consumer that swept a +//! hard-coded `1..=4`. Inclusion is checkable against the very sets Windows +//! reported, so it cannot disagree with them. +//! +//! It is also strictly more general. Inclusion orders a cache against a *core* +//! and against a *NUMA node*, which share no numbering with it and could not +//! otherwise be compared at all. + +use crate::domain::Domain; +use crate::processor_set::ProcessorSet; +use crate::topology::MachineMemoryTopology; + +/// One element of the granularity order: something a set of processors can +/// be observed to share. +/// +/// Ordered by inclusion of the processors covered, never by any level number +/// (see the module documentation). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Granularity<'a> { + /// One relation the platform reported. + Relation(&'a Domain), + /// **The machine** -- the order's top, and the reason a query over it is + /// total. + /// + /// Two processors in one machine always share *something*: one address + /// space, one scheduler, one memory system. Without an explicit top, a + /// query about a pair that no reported relation covers would answer + /// "nothing", and every caller would write the same empty-case branch for + /// a cross-node pair. + /// + /// It is deliberately **not** a [`Domain`] in + /// [`MachineMemoryTopology::domains`]: putting it there would claim the + /// platform observed a relation it never reported. + Machine, +} + +impl<'a> Granularity<'a> { + /// The relation this names, or `None` for [`Granularity::Machine`]. + #[must_use] + pub fn relation(self) -> Option<&'a Domain> { + match self { + Self::Relation(domain) => Some(domain), + Self::Machine => None, + } + } + + /// Whether this is the order's top. + #[must_use] + pub fn is_machine(self) -> bool { + matches!(self, Self::Machine) + } + + /// The processors this granularity covers. + /// + /// Takes the topology because [`Granularity::Machine`] has no membership + /// of its own -- it is every processor the topology knows, which only the + /// topology can supply. + #[must_use] + pub fn processors(self, topology: &MachineMemoryTopology) -> ProcessorSet { + match self { + Self::Relation(domain) => domain.processors.clone(), + Self::Machine => topology.machine_processors(), + } + } +} + +impl MachineMemoryTopology { + /// Every processor this topology knows, online or not. + /// + /// The membership of [`Granularity::Machine`]. Offline processors are + /// included on purpose: a slot that exists is still part of the machine, + /// and excluding it would make a query naming it answer "nothing shared" + /// rather than "the machine", which is the empty-case branch the top + /// exists to remove. + #[must_use] + pub fn machine_processors(&self) -> ProcessorSet { + let mut set = ProcessorSet::empty(); + for processor in &self.processors { + set.insert(processor.id.group, processor.id.number); + } + set + } + + /// The **minimal** granularities that cover every processor in `of` -- + /// the tightest things they all share. + /// + /// # The answer is a set, not one element + /// + /// Inclusion is a *partial* order, so two granularities can be + /// incomparable: neither contains the other, and both are therefore + /// minimal. It is almost always one element, but not by construction, and + /// a caller that takes the first must say that is what it is doing. + /// + /// Equal memberships are the ordinary case of this. Measured on the + /// development host, L1 arrives as a data cache *and* an instruction cache + /// over the very same processors -- two relations, distinct in kind and + /// attributes, tied in the order. Both are returned, because picking one + /// would be arbitrary. + /// + /// # When the answer is the machine + /// + /// [`Granularity::Machine`] is returned exactly when **no** reported + /// relation covers `of`. It is the fallback that makes this query total + /// rather than an element that competes with observed relations, so it + /// never appears alongside one. + /// + /// # When the answer is empty + /// + /// Totality holds over processors this topology knows. A set naming a + /// processor it does not know is answered with an empty result rather than + /// with the machine, because claiming the machine contains a processor it + /// has never heard of would be an invention. + #[must_use] + pub fn minimal_shared(&self, of: &ProcessorSet) -> Vec> { + if !of.is_subset(&self.machine_processors()) { + return Vec::new(); + } + + let covering: Vec<&Domain> = self + .domains + .iter() + .filter(|domain| of.is_subset(&domain.processors)) + .collect(); + + if covering.is_empty() { + return vec![Granularity::Machine]; + } + + covering + .iter() + .filter(|candidate| { + // Minimal means nothing else covering `of` sits strictly + // inside it. Strict is what keeps a tie -- two relations over + // identical sets do not exclude each other, so both survive. + !covering.iter().any(|other| { + other.processors.is_subset(&candidate.processors) + && other.processors != candidate.processors + }) + }) + .map(|domain| Granularity::Relation(domain)) + .collect() + } + + /// Whether `finer` sits strictly inside `coarser` in the granularity + /// order. + /// + /// The order's comparison, exposed so a caller can position an answer + /// against another without re-deriving what "tighter" means -- the + /// re-derivation this model exists to stop. + #[must_use] + pub fn is_finer_than(&self, finer: Granularity<'_>, coarser: Granularity<'_>) -> bool { + let finer = finer.processors(self); + let coarser = coarser.processors(self); + finer.is_subset(&coarser) && finer != coarser + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-topology-sys/src/granularity/tests.rs b/crates/windows-topology-sys/src/granularity/tests.rs new file mode 100644 index 00000000..32b08055 --- /dev/null +++ b/crates/windows-topology-sys/src/granularity/tests.rs @@ -0,0 +1,371 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for the granularity order. + +use super::Granularity; +use crate::domain::{Domain, DomainKind, Processor, ProcessorId}; +use crate::processor_set::ProcessorSet; +use crate::provenance::Provenance; +use crate::relation::CacheKind; +use crate::topology::MachineMemoryTopology; + +/// `count` processors in group 0, all online. +fn processors(count: u8) -> Vec { + (0..count) + .map(|number| Processor { + id: ProcessorId { group: 0, number }, + online: true, + capacity: 0, + }) + .collect() +} + +fn set(numbers: &[u8]) -> ProcessorSet { + let mut s = ProcessorSet::empty(); + for &n in numbers { + s.insert(0, n); + } + s +} + +fn cache(level: u8, id: u32, numbers: &[u8], cache_type: CacheKind) -> Domain { + Domain { + kind: DomainKind::Cache { + level, + associativity: 8, + line_size: 64, + size_bytes: 32 * 1024, + cache_type, + }, + id, + processors: set(numbers), + } +} + +fn core(id: u32, numbers: &[u8]) -> Domain { + Domain { + kind: DomainKind::Core { + simultaneous_multithreading: numbers.len() > 1, + efficiency_class: 0, + }, + id, + processors: set(numbers), + } +} + +fn memory(id: u32, numbers: &[u8]) -> Domain { + Domain { + kind: DomainKind::Memory { memory_bytes: None }, + id, + processors: set(numbers), + } +} + +fn topology(processor_count: u8, domains: Vec) -> MachineMemoryTopology { + MachineMemoryTopology { + processors: processors(processor_count), + domains, + cpu_sets: None, + provenance: Provenance::Synthetic, + } +} + +// --- the machine, and totality (M2+.3) --- + +#[test] +fn machine_processors_names_every_processor_the_topology_knows() { + let t = topology(4, Vec::new()); + assert_eq!(t.machine_processors(), set(&[0, 1, 2, 3])); +} + +#[test] +fn machine_processors_includes_offline_slots() { + // A slot that exists is part of the machine. Excluding it would make a + // query naming it answer "nothing shared" rather than "the machine". + let mut t = topology(4, Vec::new()); + t.processors[3].online = false; + assert_eq!(t.machine_processors(), set(&[0, 1, 2, 3])); +} + +#[test] +fn a_pair_no_relation_covers_answers_the_machine_rather_than_nothing() { + // Two NUMA nodes, nothing spanning them: exactly the cross-node case the + // top exists for. + let t = topology(4, vec![memory(0, &[0, 1]), memory(1, &[2, 3])]); + assert_eq!(t.minimal_shared(&set(&[0, 3])), vec![Granularity::Machine]); +} + +#[test] +fn the_machine_never_appears_beside_an_observed_relation() { + // It is the fallback that makes the query total, not an element competing + // with what the platform reported. + let t = topology(4, vec![memory(0, &[0, 1, 2, 3])]); + let answer = t.minimal_shared(&set(&[0, 3])); + assert_eq!(answer.len(), 1); + assert!(!answer[0].is_machine()); +} + +#[test] +fn a_topology_with_no_domains_at_all_still_answers() { + let t = topology(2, Vec::new()); + assert_eq!(t.minimal_shared(&set(&[0, 1])), vec![Granularity::Machine]); +} + +// --- inclusion, not level number (M2+.2) --- + +#[test] +fn the_tightest_covering_relation_wins_regardless_of_level_number() { + let t = topology( + 4, + vec![ + cache(3, 0, &[0, 1, 2, 3], CacheKind::Unified), + cache(2, 0, &[0, 1], CacheKind::Unified), + cache(2, 1, &[2, 3], CacheKind::Unified), + ], + ); + let answer = t.minimal_shared(&set(&[0, 1])); + assert_eq!(answer.len(), 1); + let domain = answer[0].relation().expect("a relation, not the machine"); + assert_eq!(domain.processors, set(&[0, 1])); +} + +#[test] +fn a_lower_level_number_does_not_win_when_it_covers_more() { + // The inversion firmware numbering would get wrong: an L1 that (on this + // synthetic machine) spans everything while an L2 splits it. Inclusion + // answers with the L2 because it is smaller; a level-number sort would + // have answered with the L1. + let t = topology( + 4, + vec![ + cache(1, 0, &[0, 1, 2, 3], CacheKind::Unified), + cache(2, 0, &[0, 1], CacheKind::Unified), + ], + ); + let answer = t.minimal_shared(&set(&[0, 1])); + assert_eq!(answer.len(), 1); + let domain = answer[0].relation().expect("a relation"); + assert!( + matches!(domain.kind, DomainKind::Cache { level: 2, .. }), + "inclusion must pick the smaller set, not the lower level number" + ); +} + +#[test] +fn kinds_that_share_no_numbering_are_still_ordered() { + // A core against a memory domain: no level number relates them, and + // inclusion does. + let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + let answer = t.minimal_shared(&set(&[0, 1])); + assert_eq!(answer.len(), 1); + assert!(matches!( + answer[0].relation().expect("a relation").kind, + DomainKind::Core { .. } + )); +} + +// --- incomparability and ties (M2+.4) --- + +#[test] +fn two_relations_over_the_same_processors_both_survive() { + // Measured shape: L1 arrives as a data cache and an instruction cache over + // the very same processors. Picking one would be arbitrary. + let t = topology( + 4, + vec![ + cache(1, 0, &[0, 1], CacheKind::Data), + cache(1, 1, &[0, 1], CacheKind::Instruction), + cache(3, 0, &[0, 1, 2, 3], CacheKind::Unified), + ], + ); + let answer = t.minimal_shared(&set(&[0, 1])); + assert_eq!(answer.len(), 2, "a tie is reported, not broken: {answer:?}"); + assert!( + answer + .iter() + .all(|g| g.relation().expect("a relation").processors == set(&[0, 1])) + ); +} + +#[test] +fn genuinely_incomparable_relations_both_survive() { + // Neither contains the other, and both contain the query. This is the case + // that makes the answer a set by construction rather than by accident. + let t = topology( + 4, + vec![ + memory(0, &[0, 1, 2]), + cache(2, 0, &[0, 1, 3], CacheKind::Unified), + ], + ); + let answer = t.minimal_shared(&set(&[0, 1])); + assert_eq!(answer.len(), 2, "{answer:?}"); +} + +#[test] +fn a_relation_strictly_inside_another_excludes_it() { + let t = topology( + 4, + vec![ + memory(0, &[0, 1, 2, 3]), + core(0, &[0, 1]), + cache(2, 0, &[0, 1, 2], CacheKind::Unified), + ], + ); + let answer = t.minimal_shared(&set(&[0, 1])); + assert_eq!(answer.len(), 1); + assert_eq!( + answer[0].relation().expect("a relation").processors, + set(&[0, 1]) + ); +} + +// --- edges --- + +#[test] +fn a_processor_the_topology_does_not_know_answers_empty_not_the_machine() { + // Claiming the machine contains a processor it has never heard of would be + // an invention. Totality holds over what the topology knows. + let t = topology(2, vec![memory(0, &[0, 1])]); + assert!(t.minimal_shared(&set(&[0, 7])).is_empty()); +} + +#[test] +fn the_order_holds_across_processor_groups() { + // Every other test here uses group 0 alone, which cannot exercise the + // multi-group path in `ProcessorSet::is_subset` -- and that path is where + // "this group is not covered" differs from "this group is absent". + let mut t = topology(2, Vec::new()); + t.processors.push(Processor { + id: ProcessorId { + group: 1, + number: 0, + }, + online: true, + capacity: 0, + }); + + let mut spanning = set(&[0, 1]); + spanning.insert(1, 0); + t.domains.push(Domain { + kind: DomainKind::Memory { memory_bytes: None }, + id: 0, + processors: spanning, + }); + t.domains.push(core(0, &[0, 1])); + + // Within group 0 the core is tighter than the spanning domain. + let within = t.minimal_shared(&set(&[0, 1])); + assert_eq!(within.len(), 1); + assert!(matches!( + within[0].relation().expect("a relation").kind, + DomainKind::Core { .. } + )); + + // A pair straddling the group boundary is covered only by the spanning + // domain -- the core must not qualify. + let mut across = set(&[0]); + across.insert(1, 0); + let answer = t.minimal_shared(&across); + assert_eq!(answer.len(), 1); + assert!(matches!( + answer[0].relation().expect("a relation").kind, + DomainKind::Memory { .. } + )); +} + +#[test] +fn a_single_processor_answers_its_tightest_relation() { + let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + let answer = t.minimal_shared(&set(&[0])); + assert_eq!(answer.len(), 1); + assert_eq!( + answer[0].relation().expect("a relation").processors, + set(&[0, 1]) + ); +} + +#[test] +fn the_empty_set_is_covered_by_everything_so_the_smallest_wins() { + // Not a case a caller has reason to ask, but it must not panic or invent. + let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + let answer = t.minimal_shared(&ProcessorSet::empty()); + assert_eq!(answer.len(), 1); + assert_eq!( + answer[0].relation().expect("a relation").processors, + set(&[0, 1]) + ); +} + +#[test] +fn a_memory_only_domain_never_covers_anything_but_does_not_break_the_order() { + // D-5's CXL-shaped node has no processors, so it covers no non-empty + // query. It must not become a spurious minimum. + let t = topology(2, vec![memory(1, &[]), core(0, &[0, 1])]); + let answer = t.minimal_shared(&set(&[0, 1])); + assert_eq!(answer.len(), 1); + assert!(matches!( + answer[0].relation().expect("a relation").kind, + DomainKind::Core { .. } + )); +} + +// --- the comparison itself --- + +#[test] +fn is_finer_than_is_strict() { + let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + let core_g = Granularity::Relation(&t.domains[0]); + let memory_g = Granularity::Relation(&t.domains[1]); + + assert!(t.is_finer_than(core_g, memory_g)); + assert!(!t.is_finer_than(memory_g, core_g)); + assert!( + !t.is_finer_than(core_g, core_g), + "strict: nothing is finer than itself" + ); +} + +#[test] +fn every_relation_is_finer_than_the_machine_unless_it_spans_it() { + let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + assert!(t.is_finer_than(Granularity::Relation(&t.domains[0]), Granularity::Machine)); + assert!( + !t.is_finer_than(Granularity::Relation(&t.domains[1]), Granularity::Machine), + "a relation covering every processor ties with the machine rather than being finer" + ); +} + +#[test] +fn incomparable_granularities_are_finer_in_neither_direction() { + let t = topology( + 4, + vec![ + memory(0, &[0, 1, 2]), + cache(2, 0, &[0, 1, 3], CacheKind::Unified), + ], + ); + let left = Granularity::Relation(&t.domains[0]); + let right = Granularity::Relation(&t.domains[1]); + assert!(!t.is_finer_than(left, right)); + assert!(!t.is_finer_than(right, left)); +} + +#[test] +fn the_machine_covers_every_processor_as_a_granularity() { + let t = topology(3, vec![core(0, &[0, 1])]); + assert_eq!(Granularity::Machine.processors(&t), set(&[0, 1, 2])); + assert_eq!( + Granularity::Relation(&t.domains[0]).processors(&t), + set(&[0, 1]) + ); +} + +#[test] +fn relation_and_is_machine_agree() { + let t = topology(2, vec![core(0, &[0, 1])]); + let relation = Granularity::Relation(&t.domains[0]); + assert!(!relation.is_machine()); + assert!(relation.relation().is_some()); + assert!(Granularity::Machine.is_machine()); + assert!(Granularity::Machine.relation().is_none()); +} diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index a1544bfe..40c340d2 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -65,6 +65,8 @@ mod cpu_set; #[cfg(windows)] mod domain; #[cfg(windows)] +mod granularity; +#[cfg(windows)] mod processor_set; /// Where a topology's content came from. mod provenance; @@ -79,6 +81,8 @@ mod walk; pub use cpu_set::CpuSet; #[cfg(windows)] pub use domain::{AttributeValue, Domain, DomainKind, Processor, ProcessorId}; +#[cfg(windows)] +pub use granularity::Granularity; pub use processor_set::ProcessorSet; pub use provenance::Provenance; #[cfg(windows)] diff --git a/crates/windows-topology-sys/src/processor_set.rs b/crates/windows-topology-sys/src/processor_set.rs index 91fe164b..0b20e8ed 100644 --- a/crates/windows-topology-sys/src/processor_set.rs +++ b/crates/windows-topology-sys/src/processor_set.rs @@ -134,6 +134,23 @@ impl ProcessorSet { .is_none_or(|&other_mask| mask & other_mask == 0) }) } + + /// Whether every processor in `self` is also in `other`. + /// + /// This is the comparison the granularity order is built on (M2+.2): + /// inclusion is *checkable* against the memberships the platform actually + /// reported, where a firmware level number is only asserted. The empty + /// set is a subset of everything, which follows from the definition and + /// is not a special case. + #[must_use] + pub fn is_subset(&self, other: &Self) -> bool { + self.groups.iter().all(|(group, &mask)| { + other + .groups + .get(group) + .is_some_and(|&other_mask| mask & !other_mask == 0) + }) + } } impl FromIterator<(u16, u8)> for ProcessorSet { diff --git a/crates/windows-topology-sys/src/processor_set/tests.rs b/crates/windows-topology-sys/src/processor_set/tests.rs index cc5c4a6f..d9511695 100644 --- a/crates/windows-topology-sys/src/processor_set/tests.rs +++ b/crates/windows-topology-sys/src/processor_set/tests.rs @@ -112,6 +112,104 @@ fn is_disjoint_across_groups_with_no_overlap_in_group_ids() { assert!(a.is_disjoint(&b)); } +#[test] +fn is_subset_is_true_only_when_every_processor_is_covered() { + let pair = ProcessorSet::from_group_mask(0, 0b011); + let triple = ProcessorSet::from_group_mask(0, 0b111); + + assert!(pair.is_subset(&triple)); + assert!(!triple.is_subset(&pair)); +} + +#[test] +fn is_subset_is_reflexive_but_that_is_not_strictness() { + // The order's comparison is built from this plus an inequality check, so + // this method deliberately answers `true` for equal sets. + let set = ProcessorSet::from_group_mask(0, 0b101); + assert!(set.is_subset(&set.clone())); +} + +#[test] +fn is_subset_is_false_when_a_group_is_missing_entirely() { + // The case that separates "every group I name is covered" from "every + // group I name is *present*": group 1 does not appear in `other` at all, + // so the answer must be false rather than vacuously true. + let spans_two_groups = { + let mut set = ProcessorSet::from_group_mask(0, 0b1); + set.insert(1, 0); + set + }; + let one_group = ProcessorSet::from_group_mask(0, 0b1); + + assert!(!spans_two_groups.is_subset(&one_group)); + assert!(one_group.is_subset(&spans_two_groups)); +} + +#[test] +fn is_subset_holds_across_several_groups() { + let smaller = { + let mut set = ProcessorSet::empty(); + set.insert(0, 1); + set.insert(3, 2); + set + }; + let larger = { + let mut set = ProcessorSet::empty(); + set.insert(0, 1); + set.insert(0, 5); + set.insert(3, 2); + set.insert(3, 7); + set + }; + + assert!(smaller.is_subset(&larger)); + assert!(!larger.is_subset(&smaller)); +} + +#[test] +fn is_subset_is_false_when_one_group_of_several_is_not_covered() { + // Covered in group 0, not covered in group 1. A per-group check that + // stopped at the first match would wrongly answer true. + let left = { + let mut set = ProcessorSet::empty(); + set.insert(0, 1); + set.insert(1, 4); + set + }; + let right = { + let mut set = ProcessorSet::empty(); + set.insert(0, 1); + set.insert(1, 5); + set + }; + + assert!(!left.is_subset(&right)); +} + +#[test] +fn the_empty_set_is_a_subset_of_everything_including_itself() { + let empty = ProcessorSet::empty(); + let populated = ProcessorSet::from_group_mask(0, 0b1); + + assert!(empty.is_subset(&populated)); + assert!(empty.is_subset(&ProcessorSet::empty())); + assert!(!populated.is_subset(&empty)); +} + +#[test] +fn is_subset_and_is_disjoint_agree_where_they_must() { + // Two non-empty sets cannot be both, and a set that is a subset of a + // non-empty set is never disjoint from it. Checked because the two + // methods are the order's only set predicates and a sign error in either + // is invisible from the other's tests. + let small = ProcessorSet::from_group_mask(0, 0b001); + let big = ProcessorSet::from_group_mask(0, 0b111); + let apart = ProcessorSet::from_group_mask(0, 0b110); + + assert!(small.is_subset(&big) && !small.is_disjoint(&big)); + assert!(!small.is_subset(&apart) && small.is_disjoint(&apart)); +} + #[test] fn from_iter_builds_the_same_set_as_repeated_insert() { let via_insert = { From 867b47db0c7c112283f75c1a01e8a3120a7bfe0e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:40:30 -0400 Subject: [PATCH 264/361] feat(topology): add Observed, absence with its reason attached (M2+.5) The vocabulary D-13 asks for. An Option spells three different facts identically -- not observed, observed and absent, and a negative result -- and both confusions are silent and both invent something: reading "we did not look" as "there is none" invents a fact about the machine, and reading "there is none" as "we did not look" sends a caller off to re-derive something already settled. Two variants, not three, and the omission is deliberate. A negative result is NOT an absence -- it is a computed answer whose value happens to be "no" -- so giving it a variant would re-create the very conflation this type removes. It stays an ordinary value, or an Option documented as meaning exactly that. The type covers only the two that are about whether we know. `was_observed()` is the accessor that carries the distinction: true for both Known and Absent, because both are answers. That is the question a caller asks before deciding whether re-deriving could help -- it cannot, if the platform already said there is none. `known()` is documented as discarding the reason, so reaching for it to get back to a familiar shape is visibly a choice rather than an accident. Default is NotObserved, on D-12's reasoning: forgetting to set a field must not assert something about the machine. Per D-19 the contested case needs no fourth state -- a subject the two Win32 sources genuinely disagreed about is one the unified view does not cover, which is NotObserved. D-16's retry has already removed the transient cases by the time anything is represented. Sabotage-verified: making was_observed() treat Absent as a gap -- the exact conflation the type exists to prevent -- is caught by the test named for that claim. A serde round-trip test asserts the two absences never share a wire representation, since a format that collapsed them would undo the type at the boundary where hand-written descriptions enter. Not yet applied to any field: M5+.2 (memory_bytes from a description) and M5+.4 (the probe refusing a partially-covering cache level) are the sites. M2 is now complete, 6 of 6. Completed item: M2+.5: Make absence first-class per D-13 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 17 ++- crates/windows-topology-sys/src/lib.rs | 3 + crates/windows-topology-sys/src/observed.rs | 106 ++++++++++++++++++ .../src/observed/tests.rs | 88 +++++++++++++++ 4 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 crates/windows-topology-sys/src/observed.rs create mode 100644 crates/windows-topology-sys/src/observed/tests.rs diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index e62eafd1..7d5e82ca 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -35,9 +35,9 @@ the **adapter's** problem and must not be filed here as a gap. | Milestone | State | What it is waiting on | |---|---|---| | M1 settle what is still open | **5 of 5 done** | nothing -- complete | -| M2 the granularity model | **ready** | nothing; M1 is closed and D-21 makes it self-justified | +| M2 the granularity model | **6 of 6 done** | nothing -- complete | | M3 observation and provenance | **ready** | nothing (1 of 4 answered early, by D-19) | -| M4 the queries | parked | M2, M3 | +| M4 the queries | **ready** | M2 is done; M3 is decision work that does not block it | | M5 the defects this subsumes | parked | M4, **except M5+.5 (done)** | **M1 was decision work, not implementation**, and it is complete. Each item was a question the @@ -330,13 +330,24 @@ quietly worked around, per the re-plan rule. > multi-group path was untested. Seven `is_subset` tests and a cross-group order test took that from > 1 detection to 4, two of which name the defect directly. -- [ ] **M2+.5** -- Make absence first-class per [D-13](DESIGN-NOTES.md#d-13): **not observed**, +- [x] **M2+.5** -- Make absence first-class per [D-13](DESIGN-NOTES.md#d-13): **not observed**, **observed and absent**, and **a negative result** are three different facts that an `Option` spells identically. Per [D-19](DESIGN-NOTES.md#d-19) this also carries the contested case -- a subject the sources genuinely disagreed on is one the unified view does not cover, which is *not observed*, so no fourth state is added. **Deliverable: the vocabulary type**, which `M5+.2` and `M5+.4` then consume -- M5+.4 already says "M2+.5 gives it the vocabulary to accept one". Independent of M2+.2/.3/.4, so it lands separately. + **Done.** `src/observed.rs` -- `Observed` with `Known`, `Absent`, `NotObserved`, plus `known()`, + `was_observed()`, `map()`, and a `Default` of `NotObserved` (D-12's reasoning: forgetting a field + must not assert something about the machine). 9 tests. + **Two variants, not three, and the omission is deliberate.** The *negative result* is not an + absence -- it is a computed answer whose value happens to be "no" -- so giving it a variant would + re-create the conflation the type removes. It stays an ordinary value, or an `Option` documented as + meaning exactly that. + **Sabotage-verified:** making `was_observed()` treat `Absent` as a gap -- the precise conflation + this type exists to prevent -- is caught by the test named for that claim. + Not yet *applied* to any field: `M5+.2` (`memory_bytes` from a description) and `M5+.4` (the probe + refusing a partially-covering cache level) are the sites, and both are M5 items. - [x] **M2+.6** -- **Relations carry attributes, not only memberships.** Required by [D-19](DESIGN-NOTES.md#d-19): once the relation set *is* the unified model, diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 40c340d2..92a0717b 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -66,6 +66,8 @@ mod cpu_set; mod domain; #[cfg(windows)] mod granularity; +/// Absence with its reason attached. +mod observed; #[cfg(windows)] mod processor_set; /// Where a topology's content came from. @@ -83,6 +85,7 @@ pub use cpu_set::CpuSet; pub use domain::{AttributeValue, Domain, DomainKind, Processor, ProcessorId}; #[cfg(windows)] pub use granularity::Granularity; +pub use observed::Observed; pub use processor_set::ProcessorSet; pub use provenance::Provenance; #[cfg(windows)] diff --git a/crates/windows-topology-sys/src/observed.rs b/crates/windows-topology-sys/src/observed.rs new file mode 100644 index 00000000..2c1b7f12 --- /dev/null +++ b/crates/windows-topology-sys/src/observed.rs @@ -0,0 +1,106 @@ +// Copyright (c) 2026 Mike Grier +//! Absence with its reason attached. +//! +//! [`Observed`] is the vocabulary [D-13](../DESIGN-NOTES.md) asks for: an +//! `Option` spells three different facts identically, and a consumer that +//! cannot tell them apart will eventually read one as the other. + +/// A value that may be absent, saying **which** absence it means. +/// +/// # Why not `Option` +/// +/// `Option` has one `None` and this crate has three distinct facts to put +/// in it (D-13): +/// +/// - **not observed** -- nothing asked, so the value may well exist; +/// - **observed and absent** -- something asked, and the answer was "none"; +/// - **a negative result** -- not an absence at all, but a computed answer +/// whose value happens to be "no". +/// +/// The third is deliberately **not** a variant here. It is not an absence, so +/// giving it one would re-create the conflation this type exists to remove: a +/// computed "no" is an ordinary value and belongs in `T`, or in an `Option` +/// documented as meaning exactly that. This type covers the two that are +/// genuinely about *whether we know*. +/// +/// # Why this matters more than it looks +/// +/// Both confusions are silent and both invent something. Reading "we did not +/// look" as "there is none" invents a fact about the machine; reading "there +/// is none" as "we did not look" sends a caller off to re-derive something +/// already settled. Neither fails a test that only checks the happy path. +/// +/// # The contested case +/// +/// Per [D-19](../DESIGN-NOTES.md), a subject the two Win32 sources genuinely +/// disagreed about is one the unified view does not cover -- which is +/// [`Observed::NotObserved`], not a fourth state. The retry in +/// [D-16](../DESIGN-NOTES.md) has already removed the transient cases by the +/// time anything is represented, so what reaches this type is a settled +/// question, and "we cannot say" is the honest answer to it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Observed { + /// The platform was asked and reported this value. + Known(T), + /// The platform was asked and reported that there is none. + /// + /// A positive statement about the machine, not a gap in what we did. + Absent, + /// Nothing asked, or there is no way to ask. + /// + /// Says nothing about whether the value exists. This is the variant a + /// hand-written description leaves behind when it omits a field, and the + /// one a contested subject collapses to (D-19). + NotObserved, +} + +impl Observed { + /// The value, if the platform reported one. + /// + /// **Discards the reason for an absence**, which is the whole point of + /// this type -- so reach for it only where the caller genuinely does not + /// care why the value is missing, and not merely to get back to a + /// familiar shape. + pub fn known(self) -> Option { + match self { + Self::Known(value) => Some(value), + Self::Absent | Self::NotObserved => None, + } + } + + /// Whether the platform was asked at all. + /// + /// True for both [`Observed::Known`] and [`Observed::Absent`], because + /// both are answers. This is the question a caller asks before deciding + /// whether re-deriving a value could possibly help: it cannot, if the + /// platform already said there is none. + pub fn was_observed(&self) -> bool { + !matches!(self, Self::NotObserved) + } + + /// Apply `f` to a known value, preserving the reason for an absence. + pub fn map U>(self, f: F) -> Observed { + match self { + Self::Known(value) => Observed::Known(f(value)), + Self::Absent => Observed::Absent, + Self::NotObserved => Observed::NotObserved, + } + } +} + +impl Default for Observed { + /// [`Observed::NotObserved`], because the safe default is the one that + /// claims nothing. + /// + /// Same reasoning as [`Provenance`](crate::Provenance)'s default pointing + /// at distrust (D-12): forgetting to set a field must not assert + /// something about the machine, so the default is the variant that says + /// only "nobody looked". + fn default() -> Self { + Self::NotObserved + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-topology-sys/src/observed/tests.rs b/crates/windows-topology-sys/src/observed/tests.rs new file mode 100644 index 00000000..69683ce8 --- /dev/null +++ b/crates/windows-topology-sys/src/observed/tests.rs @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`Observed`]. + +use super::Observed; + +#[test] +fn the_two_absences_are_not_equal_to_each_other() { + // The whole point of the type. If these compared equal, it would spell + // three facts with two values and be no better than `Option`. + assert_ne!(Observed::::Absent, Observed::::NotObserved); +} + +#[test] +fn known_discards_the_reason_which_is_why_it_is_the_narrow_accessor() { + assert_eq!(Observed::Known(7_u32).known(), Some(7)); + assert_eq!(Observed::::Absent.known(), None); + assert_eq!(Observed::::NotObserved.known(), None); +} + +#[test] +fn was_observed_separates_an_answer_from_a_gap() { + // Both `Known` and `Absent` are answers; only `NotObserved` is a gap. A + // caller uses this to decide whether re-deriving could help -- it cannot, + // if the platform already said there is none. + assert!(Observed::Known(0_u32).was_observed()); + assert!(Observed::::Absent.was_observed()); + assert!(!Observed::::NotObserved.was_observed()); +} + +#[test] +fn a_known_zero_is_an_answer_not_an_absence() { + // The sentinel collision D-11 and D-13 exist to prevent: `0` is a real + // value and must not read as "missing". + let zero = Observed::Known(0_u32); + assert!(zero.was_observed()); + assert_eq!(zero.known(), Some(0)); + assert_ne!(zero, Observed::Absent); +} + +#[test] +fn map_preserves_which_absence_it_was() { + assert_eq!(Observed::Known(2_u32).map(|v| v * 2), Observed::Known(4)); + assert_eq!( + Observed::::Absent.map(|v| v * 2), + Observed::::Absent + ); + assert_eq!( + Observed::::NotObserved.map(|v| v * 2), + Observed::::NotObserved + ); +} + +#[test] +fn the_default_claims_nothing() { + // Same reasoning as Provenance defaulting to Synthetic (D-12): forgetting + // to set a field must not assert something about the machine. + assert_eq!(Observed::::default(), Observed::NotObserved); + assert!(!Observed::::default().was_observed()); +} + +#[test] +fn it_holds_values_that_are_not_copy() { + let held: Observed = Observed::Known("l3".to_string()); + assert_eq!(held.clone().known().as_deref(), Some("l3")); + assert_eq!(held.map(|s| s.len()), Observed::Known(2)); +} + +#[cfg(feature = "serde")] +#[test] +fn the_two_absences_survive_a_round_trip_distinctly() { + // A wire format that collapsed them would undo the type at the boundary, + // which is exactly where a hand-written description enters this crate. + for value in [ + Observed::Known(3_u32), + Observed::Absent, + Observed::NotObserved, + ] { + let json = serde_json::to_string(&value).expect("serialize"); + let back: Observed = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, value, "round trip changed {json}"); + } + + assert_ne!( + serde_json::to_string(&Observed::::Absent).expect("serialize"), + serde_json::to_string(&Observed::::NotObserved).expect("serialize"), + "the two absences must not share a wire representation" + ); +} From ede72b18a7db1525520ff24fb1f9812f23bb95e3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:52:10 -0400 Subject: [PATCH 265/361] docs(topology): re-plan M3 -- M3+.3's premise is wrong, and M3+.1 was under-specified Third item in this reshape to assert a gap the crate does not have. The M2 re-plan named the pattern; this is it recurring, so the correction is recorded rather than absorbed. M3+.3 planned to supersede the whole-object Provenance, arguing an object-level scalar "can only be the minimum ... or the maximum, which is dishonest". Provenance is not an aggregate: it records HOW THE OBJECT WAS OBTAINED -- discover() stamps Measured, deserialization is capped at Restored, hand construction defaults to Synthetic. That is a fact about the construction act and no per-relation value can express it. Recorded as D-22. The mixed "ninety-nine measured and one synthetic" case cannot arise from collection at all; it needs someone to hand-insert a relation into a discovered topology -- which is exactly what per-relation provenance makes VISIBLE, rather than a reason to delete the object-level fact. The two are orthogonal and both kept. It also has a consumer that wants precisely it: windows-placement-probe's Record::is_trustworthy gates on is_measured(), and the record schema carries the value at its top level deliberately so a collector need not reach into the fingerprint. Deleting it would have been fix-the-layer damage, not a fix. M3+.1 said "per relation, not per source" without saying what that requires. The case it names does not exist in the code: `domains` is built from GetLogicalProcessorInformationEx alone and `cpu_sets` sits beside it as a parallel list, so no relation is described by two sources today. Satisfying the item means unifying both sources into one relation set keyed by (kind, membership) per D-15 -- the heart of D-19's unified view, which is not implemented yet. That absorbs the Domain::id work rather than leaving it separate: D-15 requires the label to move to the observation, and unification forces it, since the sources agree on the core partition while labelling it [0,2,4,...,14] against [0,1,...,7]. The M2 re-plan had said this "belongs to M3" without filing it anywhere, so it was owned by no item at all. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 37 +++++++++++++++++++-- crates/windows-topology-sys/DESIGN-NOTES.md | 1 + 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 7d5e82ca..45377657 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -367,19 +367,52 @@ quietly worked around, per the re-plan rule. **Ready.** M1 is closed. +**Re-planned 2026-09-03, before implementing.** Checking these against the code found `M3+.3`'s +premise wrong in the same way `M2+.6`'s was, and found work this milestone had been assigned but +never given an item. Recorded rather than absorbed silently -- this is the third item in the reshape +to assert a gap the crate does not have, and the pattern is what the M2 re-plan named: **check the +item against the code before planning work from it.** + - [ ] **M3+.1** -- Provenance is **per relation**, not per source. Per-relation subsumes per-source by repetition, and the reverse fails on the case that matters: two sources describing the *same* relation. + **What that means concretely, which the item did not say.** "The case that matters" does not exist + in the code yet: `domains` is built from `GetLogicalProcessorInformationEx` alone, and `cpu_sets` + sits beside it as a parallel list, so no relation is currently described by two sources. Satisfying + this item therefore means **unifying the two sources into one relation set keyed by + `(kind, membership)`** per [D-15](DESIGN-NOTES.md#d-15), with each relation recording which sources + observed it. That is the heart of [D-19](DESIGN-NOTES.md#d-19)'s unified view, and it does not + exist yet. + **It absorbs the `Domain::id` work rather than leaving it a separate item.** D-15 requires the + *label* to move from the relation to the observation, and unification forces it: the two sources + agree on the core partition while labelling it `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]`, so a + single unified relation cannot carry one `id`. The two are the same change. + *(The M2 re-plan said this work "belongs to M3" without filing it anywhere, so until now it was + owned by no item at all.)* - [ ] **M3+.2** -- Keep two properties of the old `Provenance` **because they re-derive**, not because they were there: the default is the untrusted value (a *stronger* argument per-relation, since there are more places to forget), and trust never upgrades (a file still cannot establish it describes the machine you are on). -- [ ] **M3+.3** -- Supersede the whole-object `Provenance` **without replacing it with another +- [ ] **M3+.3** -- ~~Supersede the whole-object `Provenance` **without replacing it with another whole-object scalar**. With trust per relation, an object-level scalar can only be the minimum -- ninety-nine measured relations and one synthetic reading `SYNTHETIC` -- or the maximum, which is - dishonest. Trust belongs to an *answer*. + dishonest. Trust belongs to an *answer*.~~ + **Rewritten. The premise is wrong, recorded as [D-22](DESIGN-NOTES.md#d-22).** `Provenance` is not + an aggregate of anything: it records **how the object was obtained** -- `discover()` stamps + `Measured`, deserialization is capped at `Restored`, hand construction defaults to `Synthetic`. That + is a fact about the construction *act*, and no per-relation value can express it. The mixed + "ninety-nine measured and one synthetic" case cannot arise from collection at all; it needs someone + to hand-insert a relation into a discovered topology, which is exactly what per-relation provenance + makes **visible** rather than a reason to delete the object-level fact. + It also has a real consumer that wants precisely it: `windows-placement-probe`'s + `Record::is_trustworthy` gates on `is_measured()` to decide whether a measurement counts, and its + record schema carries the value at the top level deliberately so a collector need not reach into + the fingerprint. + **Revised deliverable:** keep the type, and make its documentation say what it actually means -- + the construction act, orthogonal to per-relation provenance -- so the next reader does not repeat + this item's mistake. - [x] **M3+.4** -- Carry both observers without merging, per MMT-1.1's decision. `Topology::cpu_sets` already lands this way; this item is whether that stays a parallel list or becomes observations diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index bea6794b..3f9b38d6 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -41,6 +41,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-19 | **When the sources align -- which is the usual case -- a *unified* view is presented, in addition to the individual per-source ones.** A design that made every answer carry a coherence state was the sentinel mistake in another form: it let a case that essentially never happens shape every caller on every machine, and it made a *local* defect global, presenting a machine with one contested core as entirely uncertain. [D-15](#d-15) had already concluded the opposite and was simply not applied. Under `(kind, membership)` identity the unification is **free** -- agreeing sources have observed one relation, so there is no merge step -- and a contradiction contests only those processors at that kind. A contested subject needs no new vocabulary: it is a relation the unified view does not cover, which is [D-13](#d-13)'s *not observed*, so a consumer implements one degradation path rather than three. Requires that relations carry **attributes** as well as memberships. | | D-20 | **This crate does not go below the Win32 topology APIs, so if they do not report a fact, the crate does not have it -- and `distances` is therefore deleted rather than filled.** The engineer's ruling, and it is a **scope boundary** rather than a judgement about the field: ACPI carries SLIT, no Win32 API surfaces it, and reading firmware directly would be going below the boundary. Two supporting findings, neither of which is the reason: `distances` could never carry `Measured` provenance **by construction** (its only inputs are hand-construction, which is `Synthetic`, and deserialization, which per [D-12](#d-12) can only downgrade), and it has **zero read sites** -- `windows-platform-probes`' `render_node_distances` reads the probe's own measured `Observation`, not this field. What is lost is named rather than skated past: [D-10](#d-10)'s platform-neutral description can no longer carry Linux SLIT data. That capability was real, and it is given up because the two-component split routes distance through the synthesizer's *measurement*, which a fed-in description cannot substitute for. | | D-21 | **This crate publishes a *refined view of what the platform publishes* -- it is not shaped by the planner.** The model was originally expected to couple tightly to the solver, which is why its reshape was planned against the planner's requirements; the **adapter** between the platform data model and the planner relieves that tension, and the engineer's clarification makes the refinement the crate's whole job. The scope test is therefore "is this a refinement of what Windows reports?", never "does the planner need it?" -- and a planner requirement with **no platform correspondence is the adapter's problem**, not a gap here. Two consequences: the reshape (M2-M5) is **self-justified** and no longer waits on a planner, and `MMT-1.3` stops gating it, because what a consumer *does* with an unobserved fact is not a question about a refined view of platform data. The model owes only that the absence be representable and distinguishable, which is [D-13](#d-13) and `M2+.5`. `EP-D-1`..`EP-D-3` survive as **evidence** the shape is right rather than as its justification -- a shape that answers a real caller's questions is better validated than one invented in the abstract. | +| D-22 | **The whole-object [`Provenance`] survives per-relation provenance, because it is not an aggregate of it.** `M3+.3` planned to supersede it, arguing that an object-level scalar "can only be the minimum ... or the maximum, which is dishonest". That premise is wrong: `Provenance` records **how the object was obtained** -- `discover()` stamps `Measured`, deserialization is capped at `Restored`, hand construction defaults to `Synthetic` -- which is a fact about the *construction act*, not a roll-up of anything. No per-relation value can express it, and `windows-placement-probe` depends on exactly it: `Record::is_trustworthy` gates on `is_measured()` to decide whether a measurement counts. The two are **orthogonal and both kept**: the object says how the collection happened, a relation says which source reported it. They also compose usefully -- a `Measured` topology with a hand-inserted `Synthetic` relation is precisely the mixed case `M3+.3` was groping at, and per-relation provenance is what makes it visible rather than a reason to delete the object-level fact. | ## D-12: provenance, and why the default points at distrust From 098764cb2ee15af2e7ce480b8ddf4d546bb21f9c Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:53:19 -0400 Subject: [PATCH 266/361] docs(topology): say what Provenance actually records (M3+.3) The revised M3+.3 per D-22. The correct outcome here was NOT changing the code: Provenance is right as it stands, and what was wrong was the plan to delete it. Its documentation now opens with what it records -- the construction act, not a summary of what is in the object -- and states the orthogonality to per-relation provenance explicitly. The superseded argument is named in place ("can only be the minimum or the maximum of its parts' trust") so the next reader recognises it as already considered and rejected rather than re-proposing it. Also names the composition the argument missed: a Measured topology with a hand-inserted relation is a real hazard, since public fields allow it, and the answer is that the RELATION says where it came from while this value goes on correctly saying the collection was performed here. And names the consumer, so a future reader weighing removal sees the cost: windows-placement-probe gates its record on is_measured() and carries the value at the record's top level so a collector need not reach inside. Completed item: M3+.3: Supersede the whole-object Provenance (rewritten per D-22) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 5 +++- crates/windows-topology-sys/src/provenance.rs | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 45377657..bec01bca 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -395,7 +395,7 @@ item against the code before planning work from it.** since there are more places to forget), and trust never upgrades (a file still cannot establish it describes the machine you are on). -- [ ] **M3+.3** -- ~~Supersede the whole-object `Provenance` **without replacing it with another +- [x] **M3+.3** -- ~~Supersede the whole-object `Provenance` **without replacing it with another whole-object scalar**. With trust per relation, an object-level scalar can only be the minimum -- ninety-nine measured relations and one synthetic reading `SYNTHETIC` -- or the maximum, which is dishonest. Trust belongs to an *answer*.~~ @@ -413,6 +413,9 @@ item against the code before planning work from it.** **Revised deliverable:** keep the type, and make its documentation say what it actually means -- the construction act, orthogonal to per-relation provenance -- so the next reader does not repeat this item's mistake. + **Done.** `Provenance`'s documentation now opens with "what this records: the construction act", + states the orthogonality, and names the superseded argument so it is not re-proposed. No type or + behaviour change: the correct outcome here was *not* changing the code. - [x] **M3+.4** -- Carry both observers without merging, per MMT-1.1's decision. `Topology::cpu_sets` already lands this way; this item is whether that stays a parallel list or becomes observations diff --git a/crates/windows-topology-sys/src/provenance.rs b/crates/windows-topology-sys/src/provenance.rs index 1a454aa2..4c8d20e5 100644 --- a/crates/windows-topology-sys/src/provenance.rs +++ b/crates/windows-topology-sys/src/provenance.rs @@ -5,6 +5,27 @@ use std::fmt; /// Where a [`MachineMemoryTopology`](crate::MachineMemoryTopology)'s content came from. /// +/// # What this records: the construction act +/// +/// **How the object was obtained, not a summary of what is in it.** `discover` +/// stamps [`Self::Measured`], deserialization is capped at [`Self::Restored`], +/// and anything built by hand defaults to [`Self::Synthetic`]. The question it +/// answers is "was this collection performed, on this machine?", which is a +/// property of the act and not a roll-up of any per-part value. +/// +/// That distinction is worth stating because it is easy to get backwards, and +/// getting it backwards leads somewhere wrong. A plan to supersede this type +/// once argued that an object-level value "can only be the minimum or the +/// maximum" of its parts' trust, and therefore had to go. It is neither: it is +/// a different fact, orthogonal to where any individual relation came from, and +/// it survives per-relation provenance untouched. See D-22 in +/// `DESIGN-NOTES.md`. +/// +/// The two compose rather than compete. A `Measured` topology with a +/// hand-inserted relation is a real hazard -- public fields allow it -- and the +/// answer is that the *relation* says where it came from, while this value goes +/// on saying, correctly, that the collection was performed here. +/// /// # Why this exists /// /// This crate deliberately lets a topology be built three ways: read from the @@ -20,6 +41,10 @@ use std::fmt; /// A number produced against fabricated topology and quoted without a label is /// worse than no number, because nothing downstream can tell. /// +/// `windows-placement-probe` is that consumer today: its record gates on +/// [`Self::is_measured`] to decide whether a measurement counts, and carries +/// the value at the record's top level so a collector need not reach inside. +/// /// # Ordering is trust /// /// The variants are ordered `Synthetic < Restored < Measured`, so the derived From 896b821a3127b1377bb8e34e96c44eac918f79e0 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:56:15 -0400 Subject: [PATCH 267/361] docs(topology): split M3+.1 into three sub-steps on execution Starting the unification revealed Domain::id has 14 uses across three other crates -- windows-ioring-sys' ring_copy example, windows-placement-probe and windows-platform-probes -- plus a hand-written JSON shape. As one item this would mix an additive change, a behavioural one and a cross-crate breaking one in a single commit. M3+.1.1 introduces Source/Observation and gives Domain its observations, populated with the label it already puts in `id`. Additive; nothing downstream changes. M3+.1.2 folds CPU Sets into the relation set, which is where the unified view actually comes into being -- today discover() builds `domains` from the relationship walk alone and leaves `cpu_sets` beside it, so the two sources have never met. LastLevelCacheIndex is deliberately NOT folded: per D-14 it answers a different question from the derived cache partitioning, so under D-15 it is a different relation rather than a second observation of one. EfficiencyClass is a per-processor attribute, which is D-18's other subject kind. M3+.1.3 removes Domain::id and updates the downstream crates and wire shape. Breaking, and last so it is isolated. It is also correct rather than merely tidy: there is no canonical id once two sources label the same relation differently, and a relation observed only by CPU Sets has no walk label at all -- so keeping `id` beside the observations would be two statements of one fact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index bec01bca..ad573c53 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -389,6 +389,33 @@ item against the code before planning work from it.** single unified relation cannot carry one `id`. The two are the same change. *(The M2 re-plan said this work "belongs to M3" without filing it anywhere, so until now it was owned by no item at all.)* + **Split into three sub-steps on execution**, because `Domain::id` turns out to have **14 uses + across three other crates** -- `windows-ioring-sys`' `ring_copy` example, `windows-placement-probe`, + and `windows-platform-probes` -- plus a hand-written JSON shape. Doing this as one commit would mix + an additive change, a behavioural one, and a cross-crate breaking one. Each sub-step below compiles + and is testable on its own, and the breaking change is last and isolated. + + - [ ] **M3+.1.1** -- Introduce `Source` and `Observation`, and give `Domain` its observations, + populated by `from_relations` with the label it currently puts in `id`. **Additive**: `id` stays, + nothing downstream changes, and the unified view has somewhere to record what it unifies. + + - [ ] **M3+.1.2** -- Fold CPU Sets into the relation set. For a `Core` or `Memory` membership that + matches an existing relation, add an observation; otherwise add a relation observed only by CPU + Sets. **This is where the unified view comes into being** -- today `discover` builds `domains` + from the relationship walk alone and leaves `cpu_sets` beside it, so the two sources have never + met. + Deliberately *not* folded: `LastLevelCacheIndex`. Per [D-14](DESIGN-NOTES.md#d-14) it answers a + different question from the derived cache partitioning -- one LLC group where the derivation finds + eight L2 partitions -- so under [D-15](DESIGN-NOTES.md#d-15) it is a **different relation**, not a + second observation of the same one. `EfficiencyClass` is likewise a per-processor attribute rather + than a membership, and belongs to [D-18](DESIGN-NOTES.md#d-18)'s other subject kind. + + - [ ] **M3+.1.3** -- Remove `Domain::id`, now that observations carry the labels, and update the + three downstream crates and the wire shape. **Breaking**, and correct: there is no single + canonical id once two sources label the same relation differently -- measured as + `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]` -- and a relation observed only by CPU Sets has no + walk label at all. Keeping `id` beside the observations would be two statements of one fact, which + is the restatement drift this repository has a rule about. - [ ] **M3+.2** -- Keep two properties of the old `Provenance` **because they re-derive**, not because they were there: the default is the untrusted value (a *stronger* argument per-relation, From a11c4e28b93e01e34aea7a1174b6c65265f57598 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 16:06:54 -0400 Subject: [PATCH 268/361] feat(topology): record which source reported each relation (M3+.1.1) Source and Observation, and Domain gains `observations`. from_relations populates a genuine Source::RelationshipWalk observation carrying the label it already put in `id`, so the unified view has somewhere to record what it unifies before M3+.1.2 folds CPU Sets in. The label lives on the observation because the sources do not agree on naming: measured, both report the same eight-group core partition while labelling it [0,2,4,...,14] against [0,1,...,7]. Neither numbering is a claim about the machine, so a single id on the relation would have to pick one arbitrarily and discard the other. "NOTHING DOWNSTREAM CHANGES" WAS WRONG -- the fourth item in this reshape to assert something the code contradicts. Domain has public fields and is not non_exhaustive, so a new field breaks every struct literal: 59 across five crates, 17 outside this one. rustc enumerated each site rather than a regex guessing at them. Test literals take Vec::new(), which is honest for a hand-built relation nobody reported. DESERIALIZATION DROPS PLATFORM OBSERVATIONS, AND THAT IS THE POINT. The wire shape does not encode them, and no Description observation is synthesized. Carrying "the relationship walk observed this" out of a file would be exactly the forgery D-12 refuses, and synthesizing Description would restate what the object's Provenance::Restored already says -- which D-22 had just finished separating. Twelve round-trip tests failed on this and were right to; the question was real, not a test defect. The discovered-topology round-trip test now asserts both downgrades explicitly rather than being weakened to "the parts I still expect to match". Source deliberately carries no trust ordering, and a test says so: both Windows APIs are cheap reads and neither is more authoritative (D-15, D-17). Trust in the object is Provenance's separate question (D-22). Completed item: M3+.1.1: Introduce Source and Observation, and give Domain its observations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../examples/ring_copy/policy.rs | 1 + .../src/fingerprint/tests.rs | 16 ++++ crates/windows-topology-sys/CHECKLIST.md | 14 ++- crates/windows-topology-sys/src/domain.rs | 31 +++++++ .../windows-topology-sys/src/domain/tests.rs | 15 ++++ .../src/granularity/tests.rs | 4 + crates/windows-topology-sys/src/lib.rs | 4 + .../windows-topology-sys/src/observation.rs | 74 ++++++++++++++++ .../src/observation/tests.rs | 86 +++++++++++++++++++ crates/windows-topology-sys/src/topology.rs | 11 +++ .../src/topology/tests.rs | 74 ++++++++++++++-- 11 files changed, 320 insertions(+), 10 deletions(-) create mode 100644 crates/windows-topology-sys/src/observation.rs create mode 100644 crates/windows-topology-sys/src/observation/tests.rs diff --git a/crates/windows-ioring-sys/examples/ring_copy/policy.rs b/crates/windows-ioring-sys/examples/ring_copy/policy.rs index 4e3ff5e1..f327788b 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/policy.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/policy.rs @@ -100,6 +100,7 @@ impl Policy { kind, id: 0, processors, + observations: Vec::new(), }], degraded, ) diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index c31e547c..c23397b1 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -352,6 +352,7 @@ mod from_topology { }, id: index as u32, processors: set_of(&members), + observations: Vec::new(), }); push_members(&mut cache_members, core.cache_domain, &members); @@ -364,6 +365,7 @@ mod from_topology { kind: DomainKind::Group, id: 0, processors: set_of(&all), + observations: Vec::new(), }, ); @@ -378,6 +380,7 @@ mod from_topology { }, id, processors: set_of(&members), + observations: Vec::new(), }); } for (id, members) in node_members { @@ -385,6 +388,7 @@ mod from_topology { kind: DomainKind::Memory { memory_bytes: None }, id, processors: set_of(&members), + observations: Vec::new(), }); } @@ -624,6 +628,7 @@ mod multi_group_conversion { }, id: core_id, processors: ProcessorSet::from_group_mask(group, 1_usize << number), + observations: Vec::new(), }); core_id += 1; } @@ -633,6 +638,7 @@ mod multi_group_conversion { kind: DomainKind::Group, id: u32::from(group), processors: ProcessorSet::from_group_mask(group, mask), + observations: Vec::new(), }); // A cache domain per group, because a cache is never shared across // one, and a memory domain per group so this stays a two-node @@ -647,11 +653,13 @@ mod multi_group_conversion { }, id: 100 + u32::from(group), processors: ProcessorSet::from_group_mask(group, mask), + observations: Vec::new(), }); domains.push(Domain { kind: DomainKind::Memory { memory_bytes: None }, id: u32::from(group), processors: ProcessorSet::from_group_mask(group, mask), + observations: Vec::new(), }); } @@ -734,6 +742,7 @@ mod multi_group_conversion { kind: DomainKind::Group, id: 0, processors: ProcessorSet::from_group_mask(0, mask), + observations: Vec::new(), }], cpu_sets: None, ..Default::default() @@ -773,6 +782,7 @@ mod multi_group_conversion { kind: DomainKind::Group, id: 1, processors: ProcessorSet::from_group_mask(1, 0b1), + observations: Vec::new(), }); let places = places_from_topology(&topology).expect("no memory domain, so node 0 applies"); @@ -822,6 +832,7 @@ mod multi_group_conversion { kind: DomainKind::Memory { memory_bytes: None }, id, processors: ProcessorSet::from_group_mask(0, mask), + observations: Vec::new(), }); } @@ -846,6 +857,7 @@ mod multi_group_conversion { kind: DomainKind::Memory { memory_bytes: None }, id, processors: ProcessorSet::from_group_mask(0, mask), + observations: Vec::new(), }); } @@ -876,6 +888,7 @@ mod multi_group_conversion { }, id, processors: ProcessorSet::from_group_mask(0, mask), + observations: Vec::new(), }); topology } @@ -916,6 +929,7 @@ mod multi_group_conversion { kind: DomainKind::Group, id: 1, processors: ProcessorSet::from_group_mask(1, 0b1), + observations: Vec::new(), }); let places = @@ -962,6 +976,7 @@ mod multi_group_conversion { }, id, processors: ProcessorSet::from_group_mask(0, 1 << member), + observations: Vec::new(), }); } for (id, mask) in [(20_u32, 0b001_usize), (21, 0b010)] { @@ -975,6 +990,7 @@ mod multi_group_conversion { }, id, processors: ProcessorSet::from_group_mask(0, mask), + observations: Vec::new(), }); } diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index ad573c53..8d2af761 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -395,9 +395,21 @@ item against the code before planning work from it.** an additive change, a behavioural one, and a cross-crate breaking one. Each sub-step below compiles and is testable on its own, and the breaking change is last and isolated. - - [ ] **M3+.1.1** -- Introduce `Source` and `Observation`, and give `Domain` its observations, + - [x] **M3+.1.1** -- Introduce `Source` and `Observation`, and give `Domain` its observations, populated by `from_relations` with the label it currently puts in `id`. **Additive**: `id` stays, nothing downstream changes, and the unified view has somewhere to record what it unifies. + **"Nothing downstream changes" was wrong** -- the fourth item in this reshape to assert something + the code contradicts. `Domain` has public fields and is not `#[non_exhaustive]`, so a new field + breaks every struct literal: **59 of them across five crates**, 17 outside this one. rustc + enumerated each site; test literals take `Vec::new()` (a hand-built relation nobody reported, + which is honest) and the seven real builders in `from_relations` take a genuine + `Source::RelationshipWalk` observation carrying the label they already used. + **Deserialization drops platform observations, and that is the point.** The wire shape does not + encode them and no `Description` observation is synthesized. Carrying "the relationship walk + observed this" out of a file would be exactly the forgery [D-12](DESIGN-NOTES.md#d-12) refuses, + and synthesizing `Description` would restate what the object's `Provenance::Restored` already + says -- which [D-22](DESIGN-NOTES.md#d-22) had just finished separating. Twelve round-trip tests + failed on this and were right to: the question was real, not a test defect. - [ ] **M3+.1.2** -- Fold CPU Sets into the relation set. For a `Core` or `Memory` membership that matches an existing relation, add an observation; otherwise add a relation observed only by CPU diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index c5626a87..2f09bbe8 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -11,6 +11,7 @@ use std::collections::BTreeMap; use crate::CacheKind; +use crate::observation::Observation; use crate::processor_set::ProcessorSet; /// The identity of one logical processor: its group and its number within @@ -170,10 +171,24 @@ pub struct Domain { /// `kind`. Where Windows reports a natural number (a NUMA node number, a /// group number) that number is used; otherwise domains are numbered in /// the order they were discovered. + /// + /// **Superseded by [`Self::observations`] and removed in `M3+.1.3`.** A + /// relation two sources label differently has no single canonical id -- + /// measured as `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]` for the same + /// core partition -- so the label belongs to the observation that carries + /// it (D-15). Read the label off an observation instead. pub id: u32, /// The logical processors this domain covers. Empty for a memory-only /// domain (D-5). pub processors: ProcessorSet, + /// Which sources reported this relation, and what each called it. + /// + /// Empty for a relation nobody reported -- one built by hand, which is + /// honest rather than a gap: no platform API said anything about it. A + /// relation both Windows APIs describe carries **two** observations, which + /// is what makes agreement visible without either label being discarded + /// (D-15, D-19). + pub observations: Vec, } /// Manual `Serialize`/`Deserialize` for the open-kinded types. @@ -475,6 +490,21 @@ mod serde_impl { }; let id = as_u32(take::(&mut fields, "id")?)?; let processors = processors_from_value(take::(&mut fields, "processors")?)?; + // A described relation carries NO platform observation, and the + // wire shape does not encode them. + // + // This is the same downgrade `Provenance::downgraded_to` performs + // one level up (D-12): a file saying "the relationship walk + // observed this" cannot establish that it did, so the claim is not + // carried across the boundary. Serializing observations faithfully + // would carry exactly the claim D-12 refuses. + // + // Nor is a `Source::Description` observation synthesized here. That + // would restate what the object already says -- a deserialized + // topology's `Provenance` is capped at `Restored` -- and D-22 has + // just established these two are different questions that should + // not duplicate each other. + let observations = Vec::new(); let kind = match kind_name.as_str() { "group" => DomainKind::Group, @@ -511,6 +541,7 @@ mod serde_impl { kind, id, processors, + observations, }) } } diff --git a/crates/windows-topology-sys/src/domain/tests.rs b/crates/windows-topology-sys/src/domain/tests.rs index 1cbe7ea7..6f077fdf 100644 --- a/crates/windows-topology-sys/src/domain/tests.rs +++ b/crates/windows-topology-sys/src/domain/tests.rs @@ -29,6 +29,7 @@ fn a_memory_domain_may_have_no_processors() { }, id: 9, processors: ProcessorSet::empty(), + observations: Vec::new(), }; assert!(domain.processors.is_empty()); let DomainKind::Memory { memory_bytes } = domain.kind else { @@ -46,6 +47,7 @@ fn a_discovered_memory_domain_has_no_known_size() { kind: DomainKind::Memory { memory_bytes: None }, id: 0, processors: ProcessorSet::empty(), + observations: Vec::new(), }; let DomainKind::Memory { memory_bytes } = domain.kind else { panic!("expected Memory") @@ -64,6 +66,7 @@ fn an_unrecognised_domain_kind_carries_its_attributes() { }, id: 0, processors: ProcessorSet::empty(), + observations: Vec::new(), }; let DomainKind::Other { name, @@ -108,6 +111,7 @@ mod serde_tests { kind: DomainKind::Group, id: 0, processors: ProcessorSet::from_group_mask(0, 0b11), + observations: Vec::new(), }; assert_eq!(round_trip(&domain), domain); } @@ -121,6 +125,7 @@ mod serde_tests { }, id: 3, processors: ProcessorSet::from_group_mask(0, 0b1), + observations: Vec::new(), }; assert_eq!(round_trip(&domain), domain); } @@ -137,6 +142,7 @@ mod serde_tests { }, id: 0, processors: ProcessorSet::from_group_mask(0, 0b1111), + observations: Vec::new(), }; assert_eq!(round_trip(&domain), domain); } @@ -153,6 +159,7 @@ mod serde_tests { }, id: 0, processors: ProcessorSet::from_group_mask(0, 0b1), + observations: Vec::new(), }; let json = serde_json::to_string(&domain).expect("serialize"); assert!( @@ -177,6 +184,7 @@ mod serde_tests { }, id: 0, processors: ProcessorSet::from_group_mask(0, 0b1), + observations: Vec::new(), }; let json = serde_json::to_string(&domain).expect("serialize"); assert!( @@ -196,6 +204,7 @@ mod serde_tests { }, id: 9, processors: ProcessorSet::empty(), + observations: Vec::new(), }; let json = serde_json::to_string(&domain).expect("serialize"); assert!( @@ -211,6 +220,7 @@ mod serde_tests { kind: DomainKind::Memory { memory_bytes: None }, id: 0, processors: ProcessorSet::empty(), + observations: Vec::new(), }; let json = serde_json::to_string(&domain).expect("serialize"); assert!(!json.contains("memory_bytes"), "unexpected JSON: {json}"); @@ -229,6 +239,7 @@ mod serde_tests { }, id: 2, processors: ProcessorSet::empty(), + observations: Vec::new(), }; assert_eq!(round_trip(&domain), domain); } @@ -258,6 +269,7 @@ mod serde_tests { }, id: 3, processors: ProcessorSet::empty(), + observations: Vec::new(), }; let restored = round_trip(&domain); assert_eq!(restored, domain); @@ -286,6 +298,7 @@ mod serde_tests { }, id: 4, processors: ProcessorSet::empty(), + observations: Vec::new(), }; let restored = round_trip(&domain); let DomainKind::Memory { memory_bytes } = restored.kind else { @@ -306,6 +319,7 @@ mod serde_tests { }, id: 2, processors: ProcessorSet::empty(), + observations: Vec::new(), }; serde_json::to_string(&domain).expect_err(&format!( "an attribute named {reserved:?} must not silently overwrite the reserved field" @@ -552,6 +566,7 @@ mod serde_tests { }, id: 1, processors: ProcessorSet::from_group_mask(0, 0b1), + observations: Vec::new(), }; let restored = round_trip(&domain); diff --git a/crates/windows-topology-sys/src/granularity/tests.rs b/crates/windows-topology-sys/src/granularity/tests.rs index 32b08055..1f733c6a 100644 --- a/crates/windows-topology-sys/src/granularity/tests.rs +++ b/crates/windows-topology-sys/src/granularity/tests.rs @@ -38,6 +38,7 @@ fn cache(level: u8, id: u32, numbers: &[u8], cache_type: CacheKind) -> Domain { }, id, processors: set(numbers), + observations: Vec::new(), } } @@ -49,6 +50,7 @@ fn core(id: u32, numbers: &[u8]) -> Domain { }, id, processors: set(numbers), + observations: Vec::new(), } } @@ -57,6 +59,7 @@ fn memory(id: u32, numbers: &[u8]) -> Domain { kind: DomainKind::Memory { memory_bytes: None }, id, processors: set(numbers), + observations: Vec::new(), } } @@ -250,6 +253,7 @@ fn the_order_holds_across_processor_groups() { kind: DomainKind::Memory { memory_bytes: None }, id: 0, processors: spanning, + observations: Vec::new(), }); t.domains.push(core(0, &[0, 1])); diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 92a0717b..54169bbb 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -66,6 +66,8 @@ mod cpu_set; mod domain; #[cfg(windows)] mod granularity; +#[cfg(windows)] +mod observation; /// Absence with its reason attached. mod observed; #[cfg(windows)] @@ -85,6 +87,8 @@ pub use cpu_set::CpuSet; pub use domain::{AttributeValue, Domain, DomainKind, Processor, ProcessorId}; #[cfg(windows)] pub use granularity::Granularity; +#[cfg(windows)] +pub use observation::{Observation, Source}; pub use observed::Observed; pub use processor_set::ProcessorSet; pub use provenance::Provenance; diff --git a/crates/windows-topology-sys/src/observation.rs b/crates/windows-topology-sys/src/observation.rs new file mode 100644 index 00000000..d961e0b0 --- /dev/null +++ b/crates/windows-topology-sys/src/observation.rs @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Mike Grier +//! Who reported a relation, and what they called it. +//! +//! Windows describes processor structure through two APIs that overlap without +//! agreeing on names, so "which source said this" is a fact the model has to +//! carry rather than infer. See D-15 and D-18 in `DESIGN-NOTES.md`. + +/// Which platform API reported something. +/// +/// Not a trust ordering. Both sources are cheap reads of the running system +/// and neither is more authoritative than the other -- where they disagree, +/// [D-15](../DESIGN-NOTES.md) keeps both rather than picking a winner, and +/// [D-17](../DESIGN-NOTES.md) expects genuine disagreement in the field. Trust +/// in the *object* is [`Provenance`](crate::Provenance), which is a different +/// question (D-22): where this says who spoke, that says how the collection +/// happened. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[non_exhaustive] +pub enum Source { + /// `GetLogicalProcessorInformationEx` -- the relationship walk. + RelationshipWalk, + /// `GetSystemCpuSetInformation` -- the CPU-set enumeration. + CpuSets, + /// A description a caller merged in, which named no platform API. + /// + /// **Not** what deserialization produces: a restored relation carries no + /// observation at all, because the object's + /// [`Provenance`](crate::Provenance) already records that it came from a + /// file, and restating it here would duplicate a fact D-22 has just + /// separated. This variant is for the *mixed* case -- a caller adding + /// described relations to a topology that was discovered -- which is + /// exactly the case per-relation provenance exists to make visible. + Description, +} + +/// One source's report of a relation, carrying that source's own label for it. +/// +/// # Why the label lives here and not on the relation +/// +/// [D-15](../DESIGN-NOTES.md) identifies a relation by `(kind, membership)`, +/// because that is what the sources agree about. What they do **not** agree +/// about is naming: measured on the development host, the two report the same +/// eight-group core partition while labelling it `[0, 2, 4, ..., 14]` and +/// `[0, 1, ..., 7]`. Neither numbering is wrong and neither is a claim about +/// the machine, so a single `id` on the relation would have to pick one +/// arbitrarily and discard the other. +/// +/// Putting the label on the observation removes the choice: one relation, two +/// observations, each keeping what its source called it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct Observation { + /// Which API reported this relation. + pub source: Source, + /// What that source called it. + /// + /// A NUMA node number, a processor group number, a CPU-set `CoreIndex`, or + /// -- where the source numbers nothing -- the position it was reported in. + /// Meaningful only alongside [`Self::source`], never on its own. + pub label: u32, +} + +impl Observation { + /// An observation by `source`, labelled `label`. + #[must_use] + pub fn new(source: Source, label: u32) -> Self { + Self { source, label } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-topology-sys/src/observation/tests.rs b/crates/windows-topology-sys/src/observation/tests.rs new file mode 100644 index 00000000..3df426e2 --- /dev/null +++ b/crates/windows-topology-sys/src/observation/tests.rs @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`Observation`] and [`Source`]. + +use super::{Observation, Source}; + +#[test] +fn a_label_is_only_meaningful_beside_its_source() { + // The measured case: both sources name the same core partition, and the + // numbers differ. Two observations of one relation, neither wrong. + let walk = Observation::new(Source::RelationshipWalk, 0); + let cpu_sets = Observation::new(Source::CpuSets, 0); + + assert_eq!(walk.label, cpu_sets.label); + assert_ne!( + walk, cpu_sets, + "equal labels from different sources are different observations" + ); +} + +#[test] +fn observations_from_one_source_are_distinguished_by_label() { + assert_ne!( + Observation::new(Source::RelationshipWalk, 0), + Observation::new(Source::RelationshipWalk, 1) + ); +} + +#[test] +fn source_is_not_a_trust_ordering() { + // Deliberately checked: `Ord` exists so observations can be sorted into a + // stable order, and it must not be read as "CpuSets is better than the + // walk". Trust in the object is Provenance's job (D-22). + let mut sources = [ + Source::CpuSets, + Source::Description, + Source::RelationshipWalk, + ]; + sources.sort_unstable(); + assert_eq!( + sources, + [ + Source::RelationshipWalk, + Source::CpuSets, + Source::Description + ], + "sorting is declaration order, which is not a claim about authority" + ); +} + +#[test] +fn a_description_names_no_platform_api() { + // A hand-written or deserialized relation was reported by neither Win32 + // source, and saying so is different from claiming one of them. + let described = Observation::new(Source::Description, 7); + assert_eq!(described.source, Source::Description); + assert_ne!(described.source, Source::RelationshipWalk); + assert_ne!(described.source, Source::CpuSets); +} + +#[cfg(feature = "serde")] +#[test] +fn an_observation_round_trips_with_its_source_intact() { + for observation in [ + Observation::new(Source::RelationshipWalk, 0), + Observation::new(Source::CpuSets, 14), + Observation::new(Source::Description, u32::MAX), + ] { + let json = serde_json::to_string(&observation).expect("serialize"); + let back: Observation = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back, observation, "round trip changed {json}"); + } +} + +#[cfg(feature = "serde")] +#[test] +fn the_sources_have_distinct_wire_names() { + // A format that collapsed two sources would destroy the only thing a + // second observer is for. + let walk = serde_json::to_string(&Source::RelationshipWalk).expect("serialize"); + let cpu_sets = serde_json::to_string(&Source::CpuSets).expect("serialize"); + let description = serde_json::to_string(&Source::Description).expect("serialize"); + + assert_ne!(walk, cpu_sets); + assert_ne!(walk, description); + assert_ne!(cpu_sets, description); +} diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 494cf823..905171e7 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -5,6 +5,7 @@ use std::io; use crate::cpu_set::CpuSet; use crate::domain::{Domain, DomainKind, Processor, ProcessorId}; +use crate::observation::{Observation, Source}; use crate::provenance::Provenance; use crate::relation::{self, Relations}; @@ -95,6 +96,10 @@ impl MachineMemoryTopology { kind: DomainKind::Group, id: u32::from(group.group), processors: group.active_processors.clone(), + observations: vec![Observation::new( + Source::RelationshipWalk, + u32::from(group.group), + )], }); } for (index, package) in relations.packages.iter().enumerate() { @@ -102,6 +107,7 @@ impl MachineMemoryTopology { kind: DomainKind::Package, id: index as u32, processors: package.processors.clone(), + observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); } for (index, die) in relations.dies.iter().enumerate() { @@ -109,6 +115,7 @@ impl MachineMemoryTopology { kind: DomainKind::Die, id: index as u32, processors: die.processors.clone(), + observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); } for (index, module) in relations.modules.iter().enumerate() { @@ -116,6 +123,7 @@ impl MachineMemoryTopology { kind: DomainKind::Module, id: index as u32, processors: module.processors.clone(), + observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); } for (index, core) in relations.cores.iter().enumerate() { @@ -126,6 +134,7 @@ impl MachineMemoryTopology { }, id: index as u32, processors: core.processors.clone(), + observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); } for (index, cache) in relations.caches.iter().enumerate() { @@ -139,6 +148,7 @@ impl MachineMemoryTopology { }, id: index as u32, processors: cache.processors.clone(), + observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); } for node in &relations.numa_nodes { @@ -146,6 +156,7 @@ impl MachineMemoryTopology { kind: DomainKind::Memory { memory_bytes: None }, id: node.node_number, processors: node.processors.clone(), + observations: vec![Observation::new(Source::RelationshipWalk, node.node_number)], }); } diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index d0effd9b..ebf9ad4d 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -30,11 +30,13 @@ fn synthetic() -> MachineMemoryTopology { kind: DomainKind::Group, id: 0, processors: group0.clone(), + observations: Vec::new(), }, Domain { kind: DomainKind::Package, id: 0, processors: group0.clone(), + observations: Vec::new(), }, Domain { kind: DomainKind::Core { @@ -43,6 +45,7 @@ fn synthetic() -> MachineMemoryTopology { }, id: 0, processors: group0.clone(), + observations: Vec::new(), }, Domain { kind: DomainKind::Cache { @@ -54,6 +57,7 @@ fn synthetic() -> MachineMemoryTopology { }, id: 0, processors: group0.clone(), + observations: Vec::new(), }, Domain { kind: DomainKind::Cache { @@ -65,11 +69,13 @@ fn synthetic() -> MachineMemoryTopology { }, id: 1, processors: group0, + observations: Vec::new(), }, Domain { kind: DomainKind::Memory { memory_bytes: None }, id: 0, processors: ProcessorSet::empty(), + observations: Vec::new(), }, ], cpu_sets: None, @@ -178,29 +184,70 @@ mod serde_tests { // are on", and once written to a file it can no longer assert that. // Reloading yields `Restored`. // + // Two things are downgraded across the boundary, for one reason. The + // provenance drops to `Restored`, and every relation's platform + // observations are dropped -- because a file saying "the relationship + // walk observed this" cannot establish that it did, and carrying the + // claim would be exactly the forgery D-12 refuses. + // // The assertion is deliberately not weakened to "the parts I still - // expect to match". Everything except the provenance must survive - // verbatim, so this compares against the original with only that field - // adjusted -- a second corruption would still fail here. + // expect to match". Everything else must survive verbatim, so this + // compares against the original with only those two adjusted -- a + // second corruption would still fail here. let topology = MachineMemoryTopology::discover().expect("discover"); assert!( topology.provenance.is_measured(), "discover must claim the machine it read" ); + assert!( + topology + .domains + .iter() + .all(|domain| !domain.observations.is_empty()), + "a discovered relation must record which source reported it" + ); let json = serde_json::to_string(&topology).expect("serialize"); let back: MachineMemoryTopology = serde_json::from_str(&json).expect("deserialize"); assert_eq!(back.provenance, Provenance::Restored); - assert_eq!( - back, - MachineMemoryTopology { - provenance: Provenance::Restored, - ..topology - } + assert!( + back.domains + .iter() + .all(|domain| domain.observations.is_empty()), + "a restored relation must not claim a platform source observed it" ); + + let mut expected = topology; + expected.provenance = Provenance::Restored; + for domain in &mut expected.domains { + domain.observations.clear(); + } + assert_eq!(back, expected); } + #[test] + fn every_discovered_relation_names_the_relationship_walk_as_its_source() { + // M3+.1.1: the walk is the only source folded into `domains` today, so + // every relation must carry exactly its observation and no other. When + // M3+.1.2 folds CPU Sets in, the relations both sources describe gain a + // second observation -- and this test is what will show that happening + // rather than it arriving unnoticed. + let topology = MachineMemoryTopology::discover().expect("discover"); + + for domain in &topology.domains { + assert_eq!( + domain.observations.len(), + 1, + "one source has been folded in, so one observation: {domain:?}" + ); + assert_eq!(domain.observations[0].source, Source::RelationshipWalk); + assert_eq!( + domain.observations[0].label, domain.id, + "the observation must carry the label the walk used" + ); + } + } #[test] fn a_hand_written_synthetic_topology_parses() { let json = r#"{ @@ -463,6 +510,7 @@ fn heterogeneous_relations() -> (crate::relation::Relations, Vec) { }, id: 0, processors: cpu0, + observations: Vec::new(), }, Domain { kind: DomainKind::Core { @@ -471,6 +519,7 @@ fn heterogeneous_relations() -> (crate::relation::Relations, Vec) { }, id: 1, processors: cpu1, + observations: Vec::new(), }, ]; @@ -546,6 +595,7 @@ fn an_offline_processor_reports_no_capacity_even_when_a_core_claims_it() { }, id: 0, processors: both, + observations: Vec::new(), }]; let processors = MachineMemoryTopology::processors_from(&relations, &domains); @@ -584,6 +634,7 @@ fn split_l1_machine(cores: u32, last_level: u8) -> MachineMemoryTopology { }, id, processors: processors.clone(), + observations: Vec::new(), }); id += 1; } @@ -598,6 +649,7 @@ fn split_l1_machine(cores: u32, last_level: u8) -> MachineMemoryTopology { }, id, processors: ProcessorSet::from_group_mask(0, all), + observations: Vec::new(), }); MachineMemoryTopology { @@ -676,6 +728,7 @@ fn a_partitioning_cache_above_level_four_is_found() { }, id, processors: ProcessorSet::from_group_mask(0, mask), + observations: Vec::new(), }); } let (level, partitions) = topo.outermost_partitioning_cache().expect("L5 divides"); @@ -719,6 +772,7 @@ fn a_level_whose_domains_overlap_is_not_a_partition() { }, id, processors: ProcessorSet::from_group_mask(0, mask), + observations: Vec::new(), }); } @@ -750,6 +804,7 @@ fn a_level_whose_domains_are_disjoint_but_incomplete_still_partitions() { }, id, processors: ProcessorSet::from_group_mask(0, mask), + observations: Vec::new(), }); } @@ -789,6 +844,7 @@ fn a_domain_covering_nothing_is_not_a_partition() { }, id, processors, + observations: Vec::new(), }); } From 7caf3f41d6b097adff51af959e424ca95d1d3e7d Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 17:03:11 -0400 Subject: [PATCH 269/361] feat(topology): fold CPU Sets into the relation set (M3+.1.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified view D-19 describes now exists. Until this commit `discover` built `domains` from the relationship walk alone and left `cpu_sets` beside it, so the two sources had never met and "two sources describing the same relation" -- the case D-15 is built on -- could not arise in code. Measured on this host rather than asserted: 44 relations, 9 doubly observed, 0 CPU-sets-only. The eight cores carry walk#N beside cpuSets#2N, which is exactly D-15's [0,1,...,7] against [0,2,4,...,14] with BOTH labels now kept instead of one being discarded. `cpu_sets` is still kept verbatim -- D-19's unified model is presented in addition to the individual ones. LastLevelCacheIndex is deliberately not folded. Per D-14 it answers a different question from the derived cache partitioning -- one LLC group where the derivation finds eight L2 partitions -- so under D-15 it is a different relation, and folding it into Cache would assert an agreement neither source made. A test asserts no cache relation ever carries a CPU-sets observation. A FABRICATION CAUGHT BEFORE IT SHIPPED. The first version defaulted a CPU-sets-only core's efficiency_class to 0, which would have reinvented the Processor::capacity sentinel this whole reshape exists to remove, since 0 is a legitimate class. It now reads the value from the records themselves. SABOTAGE FOUND A SECOND HOST-SHAPED TEST GAP. Weakening the match from equal membership to containment passed all 169 tests -- because every membership on this machine is exactly equal, so the two rules coincide and the fold tests, which all ran discover(), could not tell them apart. Four synthetic fold tests built to disagree on purpose now catch it. Worth recording that the first sabotage attempt was also not caught and that was MY error, not a test gap: I injected `domain ⊆ incoming`, which never matches here. The semantically plausible mistake is the other direction. Completed item: M3+.1.2: Fold CPU Sets into the relation set Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 28 +- crates/windows-topology-sys/src/topology.rs | 123 +++++++- .../src/topology/tests.rs | 281 +++++++++++++++++- 3 files changed, 416 insertions(+), 16 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 8d2af761..8600c27b 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -411,7 +411,7 @@ item against the code before planning work from it.** says -- which [D-22](DESIGN-NOTES.md#d-22) had just finished separating. Twelve round-trip tests failed on this and were right to: the question was real, not a test defect. - - [ ] **M3+.1.2** -- Fold CPU Sets into the relation set. For a `Core` or `Memory` membership that + - [x] **M3+.1.2** -- Fold CPU Sets into the relation set. For a `Core` or `Memory` membership that matches an existing relation, add an observation; otherwise add a relation observed only by CPU Sets. **This is where the unified view comes into being** -- today `discover` builds `domains` from the relationship walk alone and leaves `cpu_sets` beside it, so the two sources have never @@ -421,6 +421,20 @@ item against the code before planning work from it.** eight L2 partitions -- so under [D-15](DESIGN-NOTES.md#d-15) it is a **different relation**, not a second observation of the same one. `EfficiencyClass` is likewise a per-processor attribute rather than a membership, and belongs to [D-18](DESIGN-NOTES.md#d-18)'s other subject kind. + **Done, and measured rather than asserted.** On this host the fold produces 44 relations, **9 + doubly observed** and 0 CPU-sets-only: the eight cores carry `walk#N` beside `cpuSets#2N`, which + is D-15's `[0,1,...,7]` against `[0,2,...,14]` with both labels now kept, plus the single NUMA + node. Caches carry walk observations only, per D-14. + `cpu_sets` is still kept verbatim beside the folded view -- D-19's unified model is presented *in + addition to* the individual ones, not instead of them. + **A fabrication caught before it shipped.** The first version defaulted a CPU-sets-only core's + `efficiency_class` to `0`, which would have reinvented the `Processor::capacity` sentinel this + reshape exists to remove, since `0` is a legitimate class. It now takes the value from the records + themselves. + **Sabotage found a second host-shaped test gap.** Weakening the match from equal membership to + containment passed all 169 tests, because every membership on this machine is *exactly* equal so + the two rules coincide. Four synthetic fold tests -- built to disagree on purpose -- now catch it, + and the sabotage had to be injected in the semantically plausible direction to be meaningful. - [ ] **M3+.1.3** -- Remove `Domain::id`, now that observations carry the labels, and update the three downstream crates and the wire shape. **Breaking**, and correct: there is no single @@ -429,6 +443,18 @@ item against the code before planning work from it.** walk label at all. Keeping `id` beside the observations would be two statements of one fact, which is the restatement drift this repository has a rule about. + - [ ] **M3+.1.4** -- **Record a per-processor attribute conflict**, which relation unification + cannot reach. `M3+.1.2` matches relations by `(kind, membership)`, so two sources describing one + core agree on *that* even if they disagree about its `efficiency_class` -- and the unified + relation keeps the walk's value while the CPU-sets value goes unrecorded. + This is [MMT-1.2](CHECKLIST.md)'s **attribute shape** and [D-18](DESIGN-NOTES.md#d-18)'s second + subject kind: an observation whose subject is `(processor, attribute)` rather than + `(kind, membership)`. Filed as its own item because the fold's doc comments cite it, and a rule + cited in code but scheduled nowhere is exactly the orphaning the "design notes are not a work + queue" rule exists to stop. + **Not observable on this host** -- every efficiency class reads `0` here -- so it is testable only + synthetically, per [D-17](DESIGN-NOTES.md#d-17). + - [ ] **M3+.2** -- Keep two properties of the old `Provenance` **because they re-derive**, not because they were there: the default is the untrusted value (a *stronger* argument per-relation, since there are more places to forget), and trust never upgrades (a file still cannot establish it diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 905171e7..8eebb5a5 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -1,11 +1,13 @@ // Copyright (c) 2026 Mike Grier //! Assembling a [`MachineMemoryTopology`] from discovered relations. +use std::collections::BTreeMap; use std::io; use crate::cpu_set::CpuSet; use crate::domain::{Domain, DomainKind, Processor, ProcessorId}; use crate::observation::{Observation, Source}; +use crate::processor_set::ProcessorSet; use crate::provenance::Provenance; use crate::relation::{self, Relations}; @@ -77,17 +79,132 @@ impl MachineMemoryTopology { pub fn discover() -> io::Result { let relations = relation::discover()?; let mut topology = Self::from_relations(relations); - // The second observation, kept beside the first rather than folded into - // it. Both are cheap reads of the running system, so both belong to + // Both are cheap reads of the running system, so both belong to // discovery -- neither is a measurement in the sense that would make it // expensive or optional. - topology.cpu_sets = Some(crate::cpu_set::enumerate()?); + let cpu_sets = crate::cpu_set::enumerate()?; + // Folded into the relation set, and *also* kept verbatim. Not a + // contradiction: D-19's unified view is presented **in addition to** + // the individual per-source ones, so a caller wanting what CPU Sets + // said, in its own shape, still has it. + topology.fold_in_cpu_sets(&cpu_sets); + topology.cpu_sets = Some(cpu_sets); // The one place in the crate that may claim this is the machine you are // on, because it is the one place that asked the operating system. topology.provenance = Provenance::Measured; Ok(topology) } + /// Record what the CPU-set enumeration says about relations, unifying with + /// what the relationship walk already reported. + /// + /// # What is folded, and what deliberately is not + /// + /// Only **core** and **NUMA node** membership. Those are the two facts both + /// Windows APIs describe, so they are the two where "two sources, one + /// relation" can arise at all. + /// + /// `LastLevelCacheIndex` is **not** folded. Per D-14 it answers a different + /// question from the derived cache partitioning -- measured on the + /// development host it reports one LLC group where the derivation finds + /// eight L2 partitions, and neither is wrong -- so under D-15 it is a + /// *different relation*, not a second observation of the same one. Folding + /// it into `Cache` would assert an agreement that was never claimed. + /// + /// `EfficiencyClass` is not folded as a *relation* either, because it is a + /// **per-processor attribute** rather than a membership -- D-18's other + /// subject kind, tracked as `M3+.1.4`. It is read only when CPU Sets + /// reports a core the walk did not, where it supplies that new relation's + /// attribute rather than a fabricated one. + /// + /// # How a relation is matched + /// + /// By `(kind, membership)` per D-15 -- but *kind* here means which kind, not + /// its attributes. Two sources reporting the same core over the same + /// processors have observed one relation even if they disagree about its + /// efficiency class; that disagreement is an attribute conflict, which is + /// the subject `M3+.1.4` covers rather than a reason to treat them as two + /// relations. + fn fold_in_cpu_sets(&mut self, cpu_sets: &[CpuSet]) { + let cores = Self::grouped_by(cpu_sets, |set| u32::from(set.core_index)); + let nodes = Self::grouped_by(cpu_sets, |set| u32::from(set.numa_node_index)); + + self.fold_memberships( + &cores, + |kind| matches!(kind, DomainKind::Core { .. }), + |members| DomainKind::Core { + // Derived from the membership rather than reported: CPU Sets + // has no SMT field, and a core with more than one logical + // processor is what the flag means. + simultaneous_multithreading: members.len() > 1, + // Taken from what CPU Sets actually reported for these + // processors, never fabricated. Defaulting this to `0` would + // reinvent the `Processor::capacity` sentinel the reshape + // exists to remove -- `0` is a legitimate class, so a stand-in + // would be indistinguishable from a real value. + efficiency_class: members + .iter() + .map(|set| set.efficiency_class) + .max() + .unwrap_or_default(), + }, + ); + self.fold_memberships( + &nodes, + |kind| matches!(kind, DomainKind::Memory { .. }), + |_| DomainKind::Memory { memory_bytes: None }, + ); + } + + /// Group the CPU-set records by whatever `key` names, keeping the records + /// themselves so a new relation's attributes come from what was reported. + fn grouped_by( + cpu_sets: &[CpuSet], + key: impl Fn(&CpuSet) -> u32, + ) -> BTreeMap> { + let mut grouped: BTreeMap> = BTreeMap::new(); + for set in cpu_sets { + grouped.entry(key(set)).or_default().push(set); + } + grouped + } + + /// Attach a CPU-sets observation to each matching relation, adding one + /// where no relation of that kind covers the same processors. + fn fold_memberships( + &mut self, + grouped: &BTreeMap>, + is_kind: impl Fn(&DomainKind) -> bool, + make_kind: impl Fn(&[&CpuSet]) -> DomainKind, + ) { + for (&label, members) in grouped { + let mut processors = ProcessorSet::empty(); + for set in members { + processors.insert(set.group, set.logical_processor_index); + } + + let observation = Observation::new(Source::CpuSets, label); + match self + .domains + .iter_mut() + .find(|domain| is_kind(&domain.kind) && domain.processors == processors) + { + // One relation, observed twice. The labels differ and both are + // kept, which is the whole of D-15. + Some(domain) => domain.observations.push(observation), + // A relation only CPU Sets reported. Recorded rather than + // dropped: the walk not describing it is a fact about the walk, + // not evidence the relation is not there. + None => self.domains.push(Domain { + kind: make_kind(members), + id: label, + processors, + observations: vec![observation], + }), + } + } + } + fn from_relations(relations: Relations) -> Self { let mut domains = Vec::new(); diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index ebf9ad4d..3682dc2e 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -227,27 +227,284 @@ mod serde_tests { } #[test] - fn every_discovered_relation_names_the_relationship_walk_as_its_source() { - // M3+.1.1: the walk is the only source folded into `domains` today, so - // every relation must carry exactly its observation and no other. When - // M3+.1.2 folds CPU Sets in, the relations both sources describe gain a - // second observation -- and this test is what will show that happening - // rather than it arriving unnoticed. + fn both_sources_are_folded_into_one_relation_where_they_agree() { + // M3+.1.2: the walk and CPU Sets both describe cores and NUMA nodes. On + // a machine where they agree -- measured to be the ordinary case + // (D-15) -- that is ONE relation carrying TWO observations, not two + // competing relations. + // + // This test replaced one asserting exactly one observation per + // relation, which is what caught this change arriving rather than + // letting it land unnoticed. let topology = MachineMemoryTopology::discover().expect("discover"); + let doubly_observed = topology + .domains + .iter() + .filter(|domain| domain.observations.len() > 1) + .count(); + assert!( + doubly_observed > 0, + "both Win32 sources report cores, so at least one relation must \ + carry two observations: {:?}", + topology + .domains + .iter() + .map(|d| (&d.kind, d.observations.len())) + .collect::>() + ); + for domain in &topology.domains { + assert!( + !domain.observations.is_empty(), + "every relation names who reported it: {domain:?}" + ); + let mut sources: Vec<_> = domain.observations.iter().map(|o| o.source).collect(); + sources.sort_unstable(); + sources.dedup(); assert_eq!( + sources.len(), domain.observations.len(), - 1, - "one source has been folded in, so one observation: {domain:?}" + "one observation per source, never two from the same one: {domain:?}" ); - assert_eq!(domain.observations[0].source, Source::RelationshipWalk); - assert_eq!( - domain.observations[0].label, domain.id, - "the observation must carry the label the walk used" + } + } + + #[test] + fn a_doubly_observed_relation_keeps_both_labels() { + // The measured disagreement D-15 is built on: the sources agree on the + // core partition and label it differently. Both labels survive, which + // they could not if the relation carried a single id. + let topology = MachineMemoryTopology::discover().expect("discover"); + + let both: Vec<_> = topology + .domains + .iter() + .filter(|d| d.observations.len() > 1) + .collect(); + assert!(!both.is_empty(), "nothing was unified"); + + for domain in both { + assert!( + domain + .observations + .iter() + .any(|o| o.source == Source::RelationshipWalk), + "{domain:?}" ); + assert!( + domain + .observations + .iter() + .any(|o| o.source == Source::CpuSets), + "{domain:?}" + ); + } + } + + #[test] + fn the_last_level_cache_grouping_is_not_folded_into_a_cache_relation() { + // D-14: CPU Sets' LastLevelCacheIndex answers a different question from + // the derived cache partitioning -- one group against eight L2 + // partitions on the development host -- so folding it into `Cache` + // would assert an agreement neither source made. + let topology = MachineMemoryTopology::discover().expect("discover"); + + for domain in topology.caches() { + assert!( + domain + .observations + .iter() + .all(|o| o.source == Source::RelationshipWalk), + "no cache relation may carry a CPU-sets observation: {domain:?}" + ); + } + } + /// A CPU-set record for one processor in group 0. + fn cpu_set(index: u8, core: u8, node: u8, efficiency_class: u8) -> crate::cpu_set::CpuSet { + crate::cpu_set::CpuSet { + id: u32::from(index), + group: 0, + logical_processor_index: index, + core_index: core, + last_level_cache_index: 0, + numa_node_index: node, + efficiency_class, + parked: false, + allocated: true, + allocated_to_target_process: true, + real_time: false, + scheduling_class: 0, + allocation_tag: 0, + } + } + + fn core_domain(id: u32, members: &[u8], efficiency_class: u8) -> Domain { + let mut processors = ProcessorSet::empty(); + for &m in members { + processors.insert(0, m); + } + Domain { + kind: DomainKind::Core { + simultaneous_multithreading: members.len() > 1, + efficiency_class, + }, + id, + processors, + observations: vec![Observation::new(Source::RelationshipWalk, id)], } } + + // --- the fold, against shapes this host does not have --- + // + // Every other fold test runs `discover()`, which sees one machine whose two + // sources agree exactly. That cannot distinguish matching on equal + // membership from matching on a subset, because here the two coincide -- a + // sabotage run proved it, passing all 169 tests. These build the disagreeing + // shapes deliberately. + + #[test] + fn folding_matches_on_equal_membership_not_on_containment() { + // The walk reports one four-processor core; CPU Sets reports two + // two-processor ones. Each CPU-sets membership is a strict SUBSET of the + // walk's, so a containment match would attach both observations to the + // walk's relation and record a false agreement. + let mut topology = MachineMemoryTopology { + processors: Vec::new(), + domains: vec![core_domain(0, &[0, 1, 2, 3], 0)], + cpu_sets: None, + provenance: Provenance::Synthetic, + }; + topology.fold_in_cpu_sets(&[ + cpu_set(0, 0, 0, 0), + cpu_set(1, 0, 0, 0), + cpu_set(2, 1, 0, 0), + cpu_set(3, 1, 0, 0), + ]); + + let walk_relation = topology + .domains + .iter() + .find(|d| d.processors.len() == 4) + .expect("the walk's relation survives"); + assert_eq!( + walk_relation.observations.len(), + 1, + "a relation nothing agreed with keeps its single observation" + ); + + let cpu_only: Vec<_> = topology + .domains + .iter() + .filter(|d| { + matches!(d.kind, DomainKind::Core { .. }) + && d.observations.iter().all(|o| o.source == Source::CpuSets) + }) + .collect(); + assert_eq!( + cpu_only.len(), + 2, + "each disagreeing CPU-sets membership becomes its own relation: {:?}", + topology.domains + ); + } + + #[test] + fn a_core_only_cpu_sets_reports_takes_its_efficiency_class_from_the_record() { + // Never fabricated. Defaulting to `0` would reinvent the + // `Processor::capacity` sentinel, because `0` is a legitimate class. + let mut topology = MachineMemoryTopology { + processors: Vec::new(), + domains: Vec::new(), + cpu_sets: None, + provenance: Provenance::Synthetic, + }; + topology.fold_in_cpu_sets(&[cpu_set(0, 0, 0, 2), cpu_set(1, 0, 0, 2)]); + + let core = topology + .domains + .iter() + .find(|d| matches!(d.kind, DomainKind::Core { .. })) + .expect("a core relation"); + assert!( + matches!( + core.kind, + DomainKind::Core { + efficiency_class: 2, + simultaneous_multithreading: true + } + ), + "{:?}", + core.kind + ); + assert_eq!( + core.observations, + vec![Observation::new(Source::CpuSets, 0)] + ); + } + + #[test] + fn folding_agreeing_sources_yields_one_relation_with_both_labels() { + let mut topology = MachineMemoryTopology { + processors: Vec::new(), + domains: vec![core_domain(7, &[0, 1], 0)], + cpu_sets: None, + provenance: Provenance::Synthetic, + }; + topology.fold_in_cpu_sets(&[cpu_set(0, 3, 0, 0), cpu_set(1, 3, 0, 0)]); + + let cores: Vec<_> = topology + .domains + .iter() + .filter(|d| matches!(d.kind, DomainKind::Core { .. })) + .collect(); + assert_eq!(cores.len(), 1, "agreement is one relation: {cores:?}"); + assert_eq!( + cores[0].observations, + vec![ + Observation::new(Source::RelationshipWalk, 7), + Observation::new(Source::CpuSets, 3), + ], + "both labels survive, which is the whole of D-15" + ); + } + + #[test] + fn folding_never_attaches_a_cpu_sets_observation_to_the_wrong_kind() { + // A memory domain covering the same processors as a core must not + // absorb the core's CPU-sets observation. + let mut memory = core_domain(0, &[0, 1], 0); + memory.kind = DomainKind::Memory { memory_bytes: None }; + let mut topology = MachineMemoryTopology { + processors: Vec::new(), + domains: vec![memory], + cpu_sets: None, + provenance: Provenance::Synthetic, + }; + topology.fold_in_cpu_sets(&[cpu_set(0, 5, 0, 0), cpu_set(1, 5, 0, 0)]); + + let memory_domain = topology + .domains + .iter() + .find(|d| matches!(d.kind, DomainKind::Memory { .. })) + .expect("the memory domain"); + assert!( + memory_domain + .observations + .iter() + .any(|o| o.source == Source::CpuSets), + "the NUMA membership does match this one, and should attach" + ); + let core = topology + .domains + .iter() + .find(|d| matches!(d.kind, DomainKind::Core { .. })) + .expect("the core arrives as its own relation"); + assert_eq!( + core.observations, + vec![Observation::new(Source::CpuSets, 5)] + ); + } #[test] fn a_hand_written_synthetic_topology_parses() { let json = r#"{ From 4e73fe13925158d5c52bf6a49d6c9ae217e25c16 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 17:04:51 -0400 Subject: [PATCH 270/361] test(topology): assert the two Provenance properties at the relation level (M3+.2) Both turned out to be satisfied already by M3+.1.1 rather than needing new mechanism -- which is the item's own claim vindicated: it said to keep them because they RE-DERIVE, not because they were there, and they did. The untrusted default is an empty observation list. A relation nobody reported says exactly that, instead of defaulting to a source and asserting something no API said. The argument is stronger per-relation than for the object, because there are far more places to forget. Never-upgrade is deserialization dropping platform observations: a description claiming the relationship walk observed something cannot establish that it did, so the claim does not cross the boundary. The test feeds a file that says "measured" and asserts both that the object is capped at Restored and that no relation claims a platform source. Completed item: M3+.2: Keep two properties of the old Provenance because they re-derive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 10 +++- .../src/topology/tests.rs | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 8600c27b..d43fdba9 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -455,10 +455,18 @@ item against the code before planning work from it.** **Not observable on this host** -- every efficiency class reads `0` here -- so it is testable only synthetically, per [D-17](DESIGN-NOTES.md#d-17). -- [ ] **M3+.2** -- Keep two properties of the old `Provenance` **because they re-derive**, not +- [x] **M3+.2** -- Keep two properties of the old `Provenance` **because they re-derive**, not because they were there: the default is the untrusted value (a *stronger* argument per-relation, since there are more places to forget), and trust never upgrades (a file still cannot establish it describes the machine you are on). + **Done, and both turned out to be already satisfied by `M3+.1.1` rather than needing new + mechanism** -- which is the item's own claim vindicated: they *re-derive*. + The untrusted default is an **empty** observation list. A relation nobody reported says exactly + that, rather than defaulting to a source and asserting something no API said; nothing fills it in + on a caller's behalf. + Never-upgrade is deserialization dropping platform observations, so a description claiming the + relationship walk observed something cannot establish that it did. Both now have named tests + asserting them at the relation level, beside the object-level ones D-12 already had. - [x] **M3+.3** -- ~~Supersede the whole-object `Provenance` **without replacing it with another whole-object scalar**. With trust per relation, an object-level scalar can only be the minimum -- diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 3682dc2e..47306e6c 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -505,6 +505,60 @@ mod serde_tests { vec![Observation::new(Source::CpuSets, 5)] ); } + // --- M3+.2: the two Provenance properties, re-derived at the relation level --- + + #[test] + fn a_relation_nobody_reported_claims_no_source() { + // "The default is the untrusted value", carried down a level. A + // hand-built relation has an empty observation list, which says nobody + // reported it -- rather than defaulting to a source and asserting + // something no API said. The argument is STRONGER here than for the + // object, because there are far more places to forget. + let domain = core_domain(0, &[0, 1], 0); + assert_eq!(domain.observations.len(), 1, "the helper states its source"); + + let silent = Domain { + observations: Vec::new(), + ..core_domain(0, &[0, 1], 0) + }; + assert!( + silent.observations.is_empty(), + "nothing fills this in on a caller's behalf" + ); + } + + #[cfg(feature = "serde")] + #[test] + fn relation_level_trust_never_upgrades_across_a_file() { + // "Trust never upgrades", carried down a level. A description asserting + // the relationship walk observed something cannot establish that it + // did, so the claim does not survive deserialization -- the same rule + // `Provenance::downgraded_to` applies to the object (D-12). + let json = r#"{ + "processors": [ + {"id": {"group": 0, "number": 0}, "online": true, "capacity": 0} + ], + "domains": [ + {"kind": "core", "id": 0, "processors": [{"group":0,"number":0}], + "simultaneous_multithreading": false, "efficiency_class": 0} + ], + "provenance": "measured" + }"#; + let topology: MachineMemoryTopology = serde_json::from_str(json).expect("parse"); + + assert_eq!( + topology.provenance, + Provenance::Restored, + "the object's claim is capped" + ); + assert!( + topology + .domains + .iter() + .all(|domain| domain.observations.is_empty()), + "and no relation claims a platform source either" + ); + } #[test] fn a_hand_written_synthetic_topology_parses() { let json = r#"{ From b9e0c3570361527a7dd2cfa4bd53769c802a4810 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 17:20:14 -0400 Subject: [PATCH 271/361] feat(topology)!: remove Domain::id in favour of per-observation labels (M3+.1.3) BREAKING CHANGE: the public field `Domain::id` is removed. Use `Domain::label_from(source)` -- which takes a source BECAUSE THERE IS NO ANSWER WITHOUT ONE. The two Windows APIs agree on the core partition while labelling it [0,2,4,...,14] against [0,1,...,7], so "the id" stopped being well defined the moment both were consulted (D-15), and a relation observed only by CPU Sets has no walk label at all. `Domain::observed_by(source)` is added alongside. 59 struct literals and 14 call sites across four crates, every one located by rustc rather than by a regex guessing at them. The wire "id" survives as an informational field: written when the relationship walk labelled the relation, and OPTIONAL ON READ AND DISCARDED, since a file cannot establish which source observed anything (D-12). Making it optional was forced rather than chosen -- serialization stops writing it for a relation no source labelled, so continuing to require it would have rejected descriptions this crate itself produces. A REAL BUG, CAUGHT BY THE TESTS AND NOT BY REVIEW. Replacing domain.id with a positional index is fine for windows-placement-probe's cache and core maps, which only need an equivalence class. It is NOT fine for numa_of: that value reaches VirtualAllocExNuma, so a position would allocate on the wrong node on any machine whose nodes are not numbered 0..n. It now takes the walk's label, which is the real node number. Worth recording how narrowly that was caught: the fixture's nodes are 0 and 1, so the NUMA assertion passed BY COINCIDENCE and only the cache assertion failed. The bug was one coincidence away from shipping, and no amount of reading the diff would have found it -- the change looked uniform. Cache and core ids now prefer the walk's label with a positional fallback, which preserves the removed field's exact prior semantics for a discovered topology while still working for a relation no source labelled. Completed item: M3+.1.3: Remove Domain::id and update the downstream crates and wire shape Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../examples/ring_copy/plan.rs | 28 +++--- .../examples/ring_copy/policy.rs | 1 - .../src/fingerprint.rs | 31 ++++-- .../src/fingerprint/tests.rs | 96 ++++++++++++------- .../windows-platform-probes/src/topology.rs | 12 ++- crates/windows-topology-sys/CHECKLIST.md | 17 +++- crates/windows-topology-sys/src/domain.rs | 77 +++++++++++---- .../windows-topology-sys/src/domain/tests.rs | 20 +--- .../src/granularity/tests.rs | 66 ++++++------- crates/windows-topology-sys/src/topology.rs | 8 -- .../src/topology/tests.rs | 39 +++----- 11 files changed, 236 insertions(+), 159 deletions(-) diff --git a/crates/windows-ioring-sys/examples/ring_copy/plan.rs b/crates/windows-ioring-sys/examples/ring_copy/plan.rs index 0ee1f0f6..9cad72f9 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/plan.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/plan.rs @@ -4,7 +4,7 @@ use std::io; -use windows_topology_sys::{Domain, DomainKind, MachineMemoryTopology, ProcessorSet}; +use windows_topology_sys::{Domain, DomainKind, MachineMemoryTopology, ProcessorSet, Source}; /// What one execution domain needs to run: a single-group affinity mask and, /// if known, the NUMA node its registered buffer should prefer. @@ -70,19 +70,25 @@ pub fn build_plan( } fn label_for(domain: &Domain) -> String { + // The relationship walk's label, which is what the NUMA node number and + // the group number are. A relation has no single "id" now that two sources + // may label it differently, so the source is named rather than assumed. + let id = domain + .label_from(Source::RelationshipWalk) + .map_or_else(|| "?".to_string(), |label| label.to_string()); match &domain.kind { - DomainKind::Cache { level, .. } => format!("L{level} cache #{}", domain.id), - DomainKind::Memory { .. } => format!("NUMA node {}", domain.id), - DomainKind::Package => format!("package #{}", domain.id), - DomainKind::Core { .. } => format!("core #{}", domain.id), - DomainKind::Group => format!("group #{}", domain.id), - DomainKind::Die => format!("die #{}", domain.id), - DomainKind::Module => format!("module #{}", domain.id), + DomainKind::Cache { level, .. } => format!("L{level} cache #{}", id), + DomainKind::Memory { .. } => format!("NUMA node {}", id), + DomainKind::Package => format!("package #{}", id), + DomainKind::Core { .. } => format!("core #{}", id), + DomainKind::Group => format!("group #{}", id), + DomainKind::Die => format!("die #{}", id), + DomainKind::Module => format!("module #{}", id), DomainKind::Other { name, .. } => name.clone(), // `DomainKind` is `#[non_exhaustive]`: a future variant this sample // does not yet know falls back to a generic label rather than // failing to build. - _ => format!("domain #{}", domain.id), + _ => format!("domain #{}", id), } } @@ -94,7 +100,7 @@ fn numa_node_for(topology: &MachineMemoryTopology, processors: &ProcessorSet) -> .iter() .find_map(|domain| match domain.kind { DomainKind::Memory { .. } if !domain.processors.is_disjoint(processors) => { - Some(domain.id) + domain.label_from(Source::RelationshipWalk) } _ => None, }) @@ -108,7 +114,7 @@ pub fn remote_numa_node(topology: &MachineMemoryTopology, local: Option) -> .domains .iter() .filter_map(|domain| match domain.kind { - DomainKind::Memory { .. } => Some(domain.id), + DomainKind::Memory { .. } => domain.label_from(Source::RelationshipWalk), _ => None, }) .find(|&id| Some(id) != local) diff --git a/crates/windows-ioring-sys/examples/ring_copy/policy.rs b/crates/windows-ioring-sys/examples/ring_copy/policy.rs index f327788b..1875b971 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/policy.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/policy.rs @@ -98,7 +98,6 @@ impl Policy { ( vec![Domain { kind, - id: 0, processors, observations: Vec::new(), }], diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index b16b5b89..8b2a303b 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -76,7 +76,7 @@ use std::fmt; -use windows_topology_sys::{DomainKind, MachineMemoryTopology, Provenance}; +use windows_topology_sys::{DomainKind, MachineMemoryTopology, Provenance, Source}; /// One logical processor's position in the machine. /// @@ -624,10 +624,18 @@ pub fn places_from_topology( let mut class_of = std::collections::BTreeMap::new(); let mut core_of = std::collections::BTreeMap::new(); let mut any_core_domain = false; - for core in topology.cores() { + // The relationship walk's label where there is one, which is exactly what + // the removed `Domain::id` carried; a position only as a fallback, for a + // relation no source labelled. This map needs processors in one relation to + // share a value, nothing more -- unlike `numa_of` below, whose value leaves + // the topology and reaches `VirtualAllocExNuma`. + for (index, core) in topology.cores().enumerate() { any_core_domain = true; + let core_id = core + .label_from(Source::RelationshipWalk) + .unwrap_or(index as u32); for id in core.processors.iter() { - core_of.insert(id, core.id); + core_of.insert(id, core_id); } let DomainKind::Core { efficiency_class, .. @@ -648,9 +656,12 @@ pub fn places_from_topology( let mut any_cache_partition = false; if let Some((_, partitions)) = topology.outermost_partitioning_cache() { any_cache_partition = true; - for domain in partitions { + for (index, domain) in partitions.iter().enumerate() { + let cache_id = domain + .label_from(Source::RelationshipWalk) + .unwrap_or(index as u32); for id in domain.processors.iter() { - cache_of.insert(id, domain.id); + cache_of.insert(id, cache_id); } } } @@ -659,8 +670,16 @@ pub fn places_from_topology( let mut any_memory_domain = false; for domain in topology.memory_domains() { any_memory_domain = true; + // The relationship walk's label, which is the real Windows NUMA node + // number -- NOT a position. This value reaches `VirtualAllocExNuma`, + // so a positional index would allocate on the wrong node on any machine + // whose nodes are not numbered `0..n`. Unlike `cache_of` and `core_of` + // above, this identifier has meaning outside the topology. + let Some(node) = domain.label_from(Source::RelationshipWalk) else { + continue; + }; for id in domain.processors.iter() { - numa_of.insert(id, domain.id); + numa_of.insert(id, node); } } diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index c23397b1..37bf2d37 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -350,9 +350,11 @@ mod from_topology { simultaneous_multithreading: core.threads > 1, efficiency_class: core.efficiency_class, }, - id: index as u32, processors: set_of(&members), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + index as u32, + )], }); push_members(&mut cache_members, core.cache_domain, &members); @@ -363,9 +365,11 @@ mod from_topology { 0, Domain { kind: DomainKind::Group, - id: 0, processors: set_of(&all), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 0, + )], }, ); @@ -378,17 +382,21 @@ mod from_topology { size_bytes: 512 * 1024, cache_type: windows_topology_sys::CacheKind::Unified, }, - id, processors: set_of(&members), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + id, + )], }); } for (id, members) in node_members { domains.push(Domain { kind: DomainKind::Memory { memory_bytes: None }, - id, processors: set_of(&members), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + id, + )], }); } @@ -626,9 +634,11 @@ mod multi_group_conversion { simultaneous_multithreading: false, efficiency_class: 0, }, - id: core_id, processors: ProcessorSet::from_group_mask(group, 1_usize << number), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + core_id, + )], }); core_id += 1; } @@ -636,9 +646,11 @@ mod multi_group_conversion { let mask = members.iter().fold(0_usize, |mask, n| mask | (1 << n)); domains.push(Domain { kind: DomainKind::Group, - id: u32::from(group), processors: ProcessorSet::from_group_mask(group, mask), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + u32::from(group), + )], }); // A cache domain per group, because a cache is never shared across // one, and a memory domain per group so this stays a two-node @@ -651,15 +663,19 @@ mod multi_group_conversion { size_bytes: 32 * 1024 * 1024, cache_type: windows_topology_sys::CacheKind::Unified, }, - id: 100 + u32::from(group), processors: ProcessorSet::from_group_mask(group, mask), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 100 + u32::from(group), + )], }); domains.push(Domain { kind: DomainKind::Memory { memory_bytes: None }, - id: u32::from(group), processors: ProcessorSet::from_group_mask(group, mask), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + u32::from(group), + )], }); } @@ -740,9 +756,11 @@ mod multi_group_conversion { .collect(), domains: vec![Domain { kind: DomainKind::Group, - id: 0, processors: ProcessorSet::from_group_mask(0, mask), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 0, + )], }], cpu_sets: None, ..Default::default() @@ -780,9 +798,11 @@ mod multi_group_conversion { }); topology.domains.push(Domain { kind: DomainKind::Group, - id: 1, processors: ProcessorSet::from_group_mask(1, 0b1), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 1, + )], }); let places = places_from_topology(&topology).expect("no memory domain, so node 0 applies"); @@ -830,9 +850,11 @@ mod multi_group_conversion { for (id, mask) in [(1_u32, 0b001_usize), (2, 0b010)] { topology.domains.push(Domain { kind: DomainKind::Memory { memory_bytes: None }, - id, processors: ProcessorSet::from_group_mask(0, mask), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + id, + )], }); } @@ -855,9 +877,11 @@ mod multi_group_conversion { for (id, mask) in [(1_u32, 0b01_usize), (2, 0b10)] { topology.domains.push(Domain { kind: DomainKind::Memory { memory_bytes: None }, - id, processors: ProcessorSet::from_group_mask(0, mask), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + id, + )], }); } @@ -886,9 +910,11 @@ mod multi_group_conversion { simultaneous_multithreading: members.len() > 1, efficiency_class: 0, }, - id, processors: ProcessorSet::from_group_mask(0, mask), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + id, + )], }); topology } @@ -927,9 +953,11 @@ mod multi_group_conversion { }); topology.domains.push(Domain { kind: DomainKind::Group, - id: 1, processors: ProcessorSet::from_group_mask(1, 0b1), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 1, + )], }); let places = @@ -974,9 +1002,11 @@ mod multi_group_conversion { simultaneous_multithreading: false, efficiency_class: 0, }, - id, processors: ProcessorSet::from_group_mask(0, 1 << member), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + id, + )], }); } for (id, mask) in [(20_u32, 0b001_usize), (21, 0b010)] { @@ -988,9 +1018,11 @@ mod multi_group_conversion { size_bytes: 1024 * 1024, cache_type: windows_topology_sys::CacheKind::Unified, }, - id, processors: ProcessorSet::from_group_mask(0, mask), - observations: Vec::new(), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + id, + )], }); } diff --git a/crates/windows-platform-probes/src/topology.rs b/crates/windows-platform-probes/src/topology.rs index ce880908..62c9c485 100644 --- a/crates/windows-platform-probes/src/topology.rs +++ b/crates/windows-platform-probes/src/topology.rs @@ -38,7 +38,7 @@ use windows_sys::Win32::System::Threading::{ GetNumaHighestNodeNumber, }; -use windows_topology_sys::{DomainKind, MachineMemoryTopology}; +use windows_topology_sys::{DomainKind, MachineMemoryTopology, Source}; /// One cache level, summarised across the machine. #[derive(Debug, Clone, PartialEq, Eq)] @@ -224,8 +224,14 @@ pub fn measure() -> io::Result { DomainKind::Package => packages += 1, DomainKind::Memory { .. } => { numa_domains += 1; - highest_numa_node = - Some(highest_numa_node.map_or(domain.id, |seen: u32| seen.max(domain.id))); + // The NUMA *node number*, which is specifically what the + // relationship walk reports. Not "the id": a relation may now + // carry a second label from CPU Sets that numbers nodes its own + // way (D-15), so the source is named rather than assumed. + if let Some(node) = domain.label_from(Source::RelationshipWalk) { + highest_numa_node = + Some(highest_numa_node.map_or(node, |seen: u32| seen.max(node))); + } if domain.processors.is_empty() { memoryless_numa_domains += 1; } diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index d43fdba9..e98d2bab 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -436,12 +436,27 @@ item against the code before planning work from it.** the two rules coincide. Four synthetic fold tests -- built to disagree on purpose -- now catch it, and the sabotage had to be injected in the semantically plausible direction to be meaningful. - - [ ] **M3+.1.3** -- Remove `Domain::id`, now that observations carry the labels, and update the + - [x] **M3+.1.3** -- Remove `Domain::id`, now that observations carry the labels, and update the three downstream crates and the wire shape. **Breaking**, and correct: there is no single canonical id once two sources label the same relation differently -- measured as `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]` -- and a relation observed only by CPU Sets has no walk label at all. Keeping `id` beside the observations would be two statements of one fact, which is the restatement drift this repository has a rule about. + **Done.** `Domain::label_from(source)` and `observed_by(source)` replace it; the accessor takes a + source *because there is no answer without one*. 59 literals and 14 call sites across four crates, + all located by rustc rather than by a regex guessing at them. + The wire `"id"` survives as an informational field: written when the walk labelled the relation, + and **optional on read and discarded**, since a file cannot establish which source observed + anything (D-12). Making it optional was forced by the change -- serialization stops writing it for + a relation no source labelled, so requiring it would have rejected descriptions this crate itself + produces. + **A real bug, caught by the tests and not by review.** Replacing `domain.id` with a positional + index in `windows-placement-probe` is fine for the cache and core maps, which only need an + equivalence class -- but `numa_of`'s value reaches **`VirtualAllocExNuma`**, so a position would + allocate on the wrong node on any machine whose nodes are not numbered `0..n`. It now takes the + walk's label, which is the real node number. The fixture's nodes happen to be `0` and `1`, so the + NUMA assertion passed by coincidence and only the cache assertion failed -- the bug was one + coincidence away from shipping. - [ ] **M3+.1.4** -- **Record a per-processor attribute conflict**, which relation unification cannot reach. `M3+.1.2` matches relations by `(kind, membership)`, so two sources describing one diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index 2f09bbe8..25c8914c 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; use crate::CacheKind; -use crate::observation::Observation; +use crate::observation::{Observation, Source}; use crate::processor_set::ProcessorSet; /// The identity of one logical processor: its group and its number within @@ -150,10 +150,15 @@ pub enum AttributeValue { /// already violate assumptions like that, and Linux's own levels do not form /// a strict hierarchy either. /// -/// With the `serde` feature, serializes as `{"kind": , "id": , -/// "processors": [...], ...fields specific to that kind}` -- an internally -/// tagged shape, implemented by hand rather than derived, because `kind` is -/// open (D-4) and an unrecognised one must still round-trip its attributes. +/// With the `serde` feature, serializes as `{"kind": , "processors": +/// [...], ...fields specific to that kind}` -- an internally tagged shape, +/// implemented by hand rather than derived, because `kind` is open (D-4) and +/// an unrecognised one must still round-trip its attributes. +/// +/// An `"id"` is written when the relationship walk labelled this relation, and +/// is **optional on read and discarded**. It carries no model meaning: a label +/// belongs to the observation that issued it (D-15), and a file cannot +/// establish which source observed anything (D-12). /// /// **This JSON shape is explicitly not covered by this crate's semver /// contract (D-8 in `DESIGN-NOTES.md`).** The Rust API above (`Domain`, @@ -167,17 +172,7 @@ pub enum AttributeValue { pub struct Domain { /// What this domain represents. pub kind: DomainKind, - /// An identifier for this domain, unique among domains of the same - /// `kind`. Where Windows reports a natural number (a NUMA node number, a - /// group number) that number is used; otherwise domains are numbered in - /// the order they were discovered. - /// - /// **Superseded by [`Self::observations`] and removed in `M3+.1.3`.** A - /// relation two sources label differently has no single canonical id -- - /// measured as `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]` for the same - /// core partition -- so the label belongs to the observation that carries - /// it (D-15). Read the label off an observation instead. - pub id: u32, + /// The logical processors this domain covers. Empty for a memory-only /// domain (D-5). pub processors: ProcessorSet, @@ -191,6 +186,38 @@ pub struct Domain { pub observations: Vec, } +impl Domain { + /// What `source` called this relation, if `source` reported it. + /// + /// The replacement for the removed `id` field, and it takes a source + /// because there is no single answer without one: the two Windows APIs + /// agree on the core partition while labelling it `[0, 2, 4, ..., 14]` and + /// `[0, 1, ..., 7]`, so "the id" was never well defined once both were + /// consulted (D-15). + /// + /// A caller wanting a *stable* handle for grouping should use the + /// relation's position in [`MachineMemoryTopology::domains`][domains] + /// instead: an observation label is meaningful only to the source that + /// issued it, and a relation may have none at all. + /// + /// [domains]: crate::MachineMemoryTopology::domains + #[must_use] + pub fn label_from(&self, source: Source) -> Option { + self.observations + .iter() + .find(|observation| observation.source == source) + .map(|observation| observation.label) + } + + /// Whether `source` reported this relation. + #[must_use] + pub fn observed_by(&self, source: Source) -> bool { + self.observations + .iter() + .any(|observation| observation.source == source) + } +} + /// Manual `Serialize`/`Deserialize` for the open-kinded types. /// /// `AttributeValue` and `Domain` cannot be `#[derive(Serialize, Deserialize)]`: @@ -429,7 +456,14 @@ mod serde_impl { DomainKind::Other { name, .. } => name.as_str(), }; map.serialize_entry("kind", kind_name)?; - map.serialize_entry("id", &self.id)?; + // The wire shape keeps an "id" because a description is written by + // hand and a human-meaningful number is worth having. It is the + // relationship walk's label where there is one -- not "the id", + // which stopped being well defined once two sources labelled the + // same relation differently (D-15). + if let Some(label) = self.label_from(crate::observation::Source::RelationshipWalk) { + map.serialize_entry("id", &label)?; + } map.serialize_entry("processors", &self.processors)?; match &self.kind { DomainKind::Core { @@ -488,7 +522,13 @@ mod serde_impl { AttributeValue::String(s) => s, _ => return Err(D::Error::custom("domain \"kind\" must be a string")), }; - let id = as_u32(take::(&mut fields, "id")?)?; + // Read and discarded, and OPTIONAL. The wire "id" is one source's + // label; a file cannot establish which source observed the relation + // (D-12), so it never becomes an observation. Since it carries no + // model meaning, requiring it would reject a description that is + // otherwise complete -- including one this crate itself writes for a + // relation no source labelled. + let _ = fields.remove("id"); let processors = processors_from_value(take::(&mut fields, "processors")?)?; // A described relation carries NO platform observation, and the // wire shape does not encode them. @@ -539,7 +579,6 @@ mod serde_impl { Ok(Domain { kind, - id, processors, observations, }) diff --git a/crates/windows-topology-sys/src/domain/tests.rs b/crates/windows-topology-sys/src/domain/tests.rs index 6f077fdf..d1a92a06 100644 --- a/crates/windows-topology-sys/src/domain/tests.rs +++ b/crates/windows-topology-sys/src/domain/tests.rs @@ -27,7 +27,6 @@ fn a_memory_domain_may_have_no_processors() { kind: DomainKind::Memory { memory_bytes: Some(64 * 1024 * 1024 * 1024), }, - id: 9, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -45,7 +44,6 @@ fn a_discovered_memory_domain_has_no_known_size() { // guessing `Some(0)`, which would be indistinguishable from "no memory". let domain = Domain { kind: DomainKind::Memory { memory_bytes: None }, - id: 0, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -64,7 +62,6 @@ fn an_unrecognised_domain_kind_carries_its_attributes() { name: "power".to_string(), attributes: attributes.clone(), }, - id: 0, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -109,7 +106,6 @@ mod serde_tests { fn a_group_domain_round_trips() { let domain = Domain { kind: DomainKind::Group, - id: 0, processors: ProcessorSet::from_group_mask(0, 0b11), observations: Vec::new(), }; @@ -123,7 +119,6 @@ mod serde_tests { simultaneous_multithreading: true, efficiency_class: 7, }, - id: 3, processors: ProcessorSet::from_group_mask(0, 0b1), observations: Vec::new(), }; @@ -140,7 +135,6 @@ mod serde_tests { size_bytes: 32 * 1024 * 1024, cache_type: CacheKind::Unified, }, - id: 0, processors: ProcessorSet::from_group_mask(0, 0b1111), observations: Vec::new(), }; @@ -157,7 +151,6 @@ mod serde_tests { size_bytes: 32 * 1024, cache_type: CacheKind::Other(99), }, - id: 0, processors: ProcessorSet::from_group_mask(0, 0b1), observations: Vec::new(), }; @@ -182,7 +175,6 @@ mod serde_tests { size_bytes: 32 * 1024, cache_type: CacheKind::Other(-7), }, - id: 0, processors: ProcessorSet::from_group_mask(0, 0b1), observations: Vec::new(), }; @@ -202,7 +194,6 @@ mod serde_tests { kind: DomainKind::Memory { memory_bytes: Some(64 * 1024 * 1024 * 1024), }, - id: 9, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -218,7 +209,6 @@ mod serde_tests { fn a_memory_domain_with_unknown_size_omits_memory_bytes_rather_than_writing_null() { let domain = Domain { kind: DomainKind::Memory { memory_bytes: None }, - id: 0, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -237,7 +227,6 @@ mod serde_tests { name: "power".to_string(), attributes, }, - id: 2, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -267,7 +256,6 @@ mod serde_tests { name: "precision".to_string(), attributes, }, - id: 3, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -296,7 +284,6 @@ mod serde_tests { kind: DomainKind::Memory { memory_bytes: Some(precise), }, - id: 4, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -317,7 +304,6 @@ mod serde_tests { name: "power".to_string(), attributes, }, - id: 2, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -338,7 +324,10 @@ mod serde_tests { "memory_bytes": 549755813888 }"#; let domain: Domain = serde_json::from_str(json).expect("parse"); - assert_eq!(domain.id, 5); + // The wire "id" is read as the relationship walk's label, since that is + // what serialization writes there. It is not evidence the walk observed + // this -- deserialization records no observation at all (D-12). + assert!(domain.observations.is_empty()); assert!(domain.processors.is_empty()); assert_eq!( domain.kind, @@ -564,7 +553,6 @@ mod serde_tests { simultaneous_multithreading: false, efficiency_class: 0, }, - id: 1, processors: ProcessorSet::from_group_mask(0, 0b1), observations: Vec::new(), }; diff --git a/crates/windows-topology-sys/src/granularity/tests.rs b/crates/windows-topology-sys/src/granularity/tests.rs index 1f733c6a..4a765cca 100644 --- a/crates/windows-topology-sys/src/granularity/tests.rs +++ b/crates/windows-topology-sys/src/granularity/tests.rs @@ -27,7 +27,7 @@ fn set(numbers: &[u8]) -> ProcessorSet { s } -fn cache(level: u8, id: u32, numbers: &[u8], cache_type: CacheKind) -> Domain { +fn cache(level: u8, numbers: &[u8], cache_type: CacheKind) -> Domain { Domain { kind: DomainKind::Cache { level, @@ -36,28 +36,25 @@ fn cache(level: u8, id: u32, numbers: &[u8], cache_type: CacheKind) -> Domain { size_bytes: 32 * 1024, cache_type, }, - id, processors: set(numbers), observations: Vec::new(), } } -fn core(id: u32, numbers: &[u8]) -> Domain { +fn core(numbers: &[u8]) -> Domain { Domain { kind: DomainKind::Core { simultaneous_multithreading: numbers.len() > 1, efficiency_class: 0, }, - id, processors: set(numbers), observations: Vec::new(), } } -fn memory(id: u32, numbers: &[u8]) -> Domain { +fn memory(numbers: &[u8]) -> Domain { Domain { kind: DomainKind::Memory { memory_bytes: None }, - id, processors: set(numbers), observations: Vec::new(), } @@ -93,7 +90,7 @@ fn machine_processors_includes_offline_slots() { fn a_pair_no_relation_covers_answers_the_machine_rather_than_nothing() { // Two NUMA nodes, nothing spanning them: exactly the cross-node case the // top exists for. - let t = topology(4, vec![memory(0, &[0, 1]), memory(1, &[2, 3])]); + let t = topology(4, vec![memory(&[0, 1]), memory(&[2, 3])]); assert_eq!(t.minimal_shared(&set(&[0, 3])), vec![Granularity::Machine]); } @@ -101,7 +98,7 @@ fn a_pair_no_relation_covers_answers_the_machine_rather_than_nothing() { fn the_machine_never_appears_beside_an_observed_relation() { // It is the fallback that makes the query total, not an element competing // with what the platform reported. - let t = topology(4, vec![memory(0, &[0, 1, 2, 3])]); + let t = topology(4, vec![memory(&[0, 1, 2, 3])]); let answer = t.minimal_shared(&set(&[0, 3])); assert_eq!(answer.len(), 1); assert!(!answer[0].is_machine()); @@ -120,9 +117,9 @@ fn the_tightest_covering_relation_wins_regardless_of_level_number() { let t = topology( 4, vec![ - cache(3, 0, &[0, 1, 2, 3], CacheKind::Unified), - cache(2, 0, &[0, 1], CacheKind::Unified), - cache(2, 1, &[2, 3], CacheKind::Unified), + cache(3, &[0, 1, 2, 3], CacheKind::Unified), + cache(2, &[0, 1], CacheKind::Unified), + cache(2, &[2, 3], CacheKind::Unified), ], ); let answer = t.minimal_shared(&set(&[0, 1])); @@ -140,8 +137,8 @@ fn a_lower_level_number_does_not_win_when_it_covers_more() { let t = topology( 4, vec![ - cache(1, 0, &[0, 1, 2, 3], CacheKind::Unified), - cache(2, 0, &[0, 1], CacheKind::Unified), + cache(1, &[0, 1, 2, 3], CacheKind::Unified), + cache(2, &[0, 1], CacheKind::Unified), ], ); let answer = t.minimal_shared(&set(&[0, 1])); @@ -157,7 +154,7 @@ fn a_lower_level_number_does_not_win_when_it_covers_more() { fn kinds_that_share_no_numbering_are_still_ordered() { // A core against a memory domain: no level number relates them, and // inclusion does. - let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + let t = topology(4, vec![core(&[0, 1]), memory(&[0, 1, 2, 3])]); let answer = t.minimal_shared(&set(&[0, 1])); assert_eq!(answer.len(), 1); assert!(matches!( @@ -175,9 +172,9 @@ fn two_relations_over_the_same_processors_both_survive() { let t = topology( 4, vec![ - cache(1, 0, &[0, 1], CacheKind::Data), - cache(1, 1, &[0, 1], CacheKind::Instruction), - cache(3, 0, &[0, 1, 2, 3], CacheKind::Unified), + cache(1, &[0, 1], CacheKind::Data), + cache(1, &[0, 1], CacheKind::Instruction), + cache(3, &[0, 1, 2, 3], CacheKind::Unified), ], ); let answer = t.minimal_shared(&set(&[0, 1])); @@ -195,10 +192,7 @@ fn genuinely_incomparable_relations_both_survive() { // that makes the answer a set by construction rather than by accident. let t = topology( 4, - vec![ - memory(0, &[0, 1, 2]), - cache(2, 0, &[0, 1, 3], CacheKind::Unified), - ], + vec![memory(&[0, 1, 2]), cache(2, &[0, 1, 3], CacheKind::Unified)], ); let answer = t.minimal_shared(&set(&[0, 1])); assert_eq!(answer.len(), 2, "{answer:?}"); @@ -209,9 +203,9 @@ fn a_relation_strictly_inside_another_excludes_it() { let t = topology( 4, vec![ - memory(0, &[0, 1, 2, 3]), - core(0, &[0, 1]), - cache(2, 0, &[0, 1, 2], CacheKind::Unified), + memory(&[0, 1, 2, 3]), + core(&[0, 1]), + cache(2, &[0, 1, 2], CacheKind::Unified), ], ); let answer = t.minimal_shared(&set(&[0, 1])); @@ -228,7 +222,7 @@ fn a_relation_strictly_inside_another_excludes_it() { fn a_processor_the_topology_does_not_know_answers_empty_not_the_machine() { // Claiming the machine contains a processor it has never heard of would be // an invention. Totality holds over what the topology knows. - let t = topology(2, vec![memory(0, &[0, 1])]); + let t = topology(2, vec![memory(&[0, 1])]); assert!(t.minimal_shared(&set(&[0, 7])).is_empty()); } @@ -251,11 +245,10 @@ fn the_order_holds_across_processor_groups() { spanning.insert(1, 0); t.domains.push(Domain { kind: DomainKind::Memory { memory_bytes: None }, - id: 0, processors: spanning, observations: Vec::new(), }); - t.domains.push(core(0, &[0, 1])); + t.domains.push(core(&[0, 1])); // Within group 0 the core is tighter than the spanning domain. let within = t.minimal_shared(&set(&[0, 1])); @@ -279,7 +272,7 @@ fn the_order_holds_across_processor_groups() { #[test] fn a_single_processor_answers_its_tightest_relation() { - let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + let t = topology(4, vec![core(&[0, 1]), memory(&[0, 1, 2, 3])]); let answer = t.minimal_shared(&set(&[0])); assert_eq!(answer.len(), 1); assert_eq!( @@ -291,7 +284,7 @@ fn a_single_processor_answers_its_tightest_relation() { #[test] fn the_empty_set_is_covered_by_everything_so_the_smallest_wins() { // Not a case a caller has reason to ask, but it must not panic or invent. - let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + let t = topology(4, vec![core(&[0, 1]), memory(&[0, 1, 2, 3])]); let answer = t.minimal_shared(&ProcessorSet::empty()); assert_eq!(answer.len(), 1); assert_eq!( @@ -304,7 +297,7 @@ fn the_empty_set_is_covered_by_everything_so_the_smallest_wins() { fn a_memory_only_domain_never_covers_anything_but_does_not_break_the_order() { // D-5's CXL-shaped node has no processors, so it covers no non-empty // query. It must not become a spurious minimum. - let t = topology(2, vec![memory(1, &[]), core(0, &[0, 1])]); + let t = topology(2, vec![memory(&[]), core(&[0, 1])]); let answer = t.minimal_shared(&set(&[0, 1])); assert_eq!(answer.len(), 1); assert!(matches!( @@ -317,7 +310,7 @@ fn a_memory_only_domain_never_covers_anything_but_does_not_break_the_order() { #[test] fn is_finer_than_is_strict() { - let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + let t = topology(4, vec![core(&[0, 1]), memory(&[0, 1, 2, 3])]); let core_g = Granularity::Relation(&t.domains[0]); let memory_g = Granularity::Relation(&t.domains[1]); @@ -331,7 +324,7 @@ fn is_finer_than_is_strict() { #[test] fn every_relation_is_finer_than_the_machine_unless_it_spans_it() { - let t = topology(4, vec![core(0, &[0, 1]), memory(0, &[0, 1, 2, 3])]); + let t = topology(4, vec![core(&[0, 1]), memory(&[0, 1, 2, 3])]); assert!(t.is_finer_than(Granularity::Relation(&t.domains[0]), Granularity::Machine)); assert!( !t.is_finer_than(Granularity::Relation(&t.domains[1]), Granularity::Machine), @@ -343,10 +336,7 @@ fn every_relation_is_finer_than_the_machine_unless_it_spans_it() { fn incomparable_granularities_are_finer_in_neither_direction() { let t = topology( 4, - vec![ - memory(0, &[0, 1, 2]), - cache(2, 0, &[0, 1, 3], CacheKind::Unified), - ], + vec![memory(&[0, 1, 2]), cache(2, &[0, 1, 3], CacheKind::Unified)], ); let left = Granularity::Relation(&t.domains[0]); let right = Granularity::Relation(&t.domains[1]); @@ -356,7 +346,7 @@ fn incomparable_granularities_are_finer_in_neither_direction() { #[test] fn the_machine_covers_every_processor_as_a_granularity() { - let t = topology(3, vec![core(0, &[0, 1])]); + let t = topology(3, vec![core(&[0, 1])]); assert_eq!(Granularity::Machine.processors(&t), set(&[0, 1, 2])); assert_eq!( Granularity::Relation(&t.domains[0]).processors(&t), @@ -366,7 +356,7 @@ fn the_machine_covers_every_processor_as_a_granularity() { #[test] fn relation_and_is_machine_agree() { - let t = topology(2, vec![core(0, &[0, 1])]); + let t = topology(2, vec![core(&[0, 1])]); let relation = Granularity::Relation(&t.domains[0]); assert!(!relation.is_machine()); assert!(relation.relation().is_some()); diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 8eebb5a5..23286efe 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -197,7 +197,6 @@ impl MachineMemoryTopology { // not evidence the relation is not there. None => self.domains.push(Domain { kind: make_kind(members), - id: label, processors, observations: vec![observation], }), @@ -211,7 +210,6 @@ impl MachineMemoryTopology { for group in &relations.groups { domains.push(Domain { kind: DomainKind::Group, - id: u32::from(group.group), processors: group.active_processors.clone(), observations: vec![Observation::new( Source::RelationshipWalk, @@ -222,7 +220,6 @@ impl MachineMemoryTopology { for (index, package) in relations.packages.iter().enumerate() { domains.push(Domain { kind: DomainKind::Package, - id: index as u32, processors: package.processors.clone(), observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); @@ -230,7 +227,6 @@ impl MachineMemoryTopology { for (index, die) in relations.dies.iter().enumerate() { domains.push(Domain { kind: DomainKind::Die, - id: index as u32, processors: die.processors.clone(), observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); @@ -238,7 +234,6 @@ impl MachineMemoryTopology { for (index, module) in relations.modules.iter().enumerate() { domains.push(Domain { kind: DomainKind::Module, - id: index as u32, processors: module.processors.clone(), observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); @@ -249,7 +244,6 @@ impl MachineMemoryTopology { simultaneous_multithreading: core.simultaneous_multithreading, efficiency_class: core.efficiency_class, }, - id: index as u32, processors: core.processors.clone(), observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); @@ -263,7 +257,6 @@ impl MachineMemoryTopology { size_bytes: cache.cache_size, cache_type: cache.cache_type, }, - id: index as u32, processors: cache.processors.clone(), observations: vec![Observation::new(Source::RelationshipWalk, index as u32)], }); @@ -271,7 +264,6 @@ impl MachineMemoryTopology { for node in &relations.numa_nodes { domains.push(Domain { kind: DomainKind::Memory { memory_bytes: None }, - id: node.node_number, processors: node.processors.clone(), observations: vec![Observation::new(Source::RelationshipWalk, node.node_number)], }); diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 47306e6c..7fa5ccd2 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -28,13 +28,11 @@ fn synthetic() -> MachineMemoryTopology { domains: vec![ Domain { kind: DomainKind::Group, - id: 0, processors: group0.clone(), observations: Vec::new(), }, Domain { kind: DomainKind::Package, - id: 0, processors: group0.clone(), observations: Vec::new(), }, @@ -43,7 +41,6 @@ fn synthetic() -> MachineMemoryTopology { simultaneous_multithreading: true, efficiency_class: 10, }, - id: 0, processors: group0.clone(), observations: Vec::new(), }, @@ -55,7 +52,6 @@ fn synthetic() -> MachineMemoryTopology { size_bytes: 32 * 1024 * 1024, cache_type: CacheKind::Unified, }, - id: 0, processors: group0.clone(), observations: Vec::new(), }, @@ -67,13 +63,11 @@ fn synthetic() -> MachineMemoryTopology { size_bytes: 512 * 1024, cache_type: CacheKind::Unified, }, - id: 1, processors: group0, observations: Vec::new(), }, Domain { kind: DomainKind::Memory { memory_bytes: None }, - id: 0, processors: ProcessorSet::empty(), observations: Vec::new(), }, @@ -339,7 +333,7 @@ mod serde_tests { } } - fn core_domain(id: u32, members: &[u8], efficiency_class: u8) -> Domain { + fn core_domain(label: u32, members: &[u8], efficiency_class: u8) -> Domain { let mut processors = ProcessorSet::empty(); for &m in members { processors.insert(0, m); @@ -349,9 +343,8 @@ mod serde_tests { simultaneous_multithreading: members.len() > 1, efficiency_class, }, - id, processors, - observations: vec![Observation::new(Source::RelationshipWalk, id)], + observations: vec![Observation::new(Source::RelationshipWalk, label)], } } @@ -819,7 +812,6 @@ fn heterogeneous_relations() -> (crate::relation::Relations, Vec) { simultaneous_multithreading: false, efficiency_class: 0, }, - id: 0, processors: cpu0, observations: Vec::new(), }, @@ -828,7 +820,6 @@ fn heterogeneous_relations() -> (crate::relation::Relations, Vec) { simultaneous_multithreading: false, efficiency_class: 1, }, - id: 1, processors: cpu1, observations: Vec::new(), }, @@ -904,7 +895,6 @@ fn an_offline_processor_reports_no_capacity_even_when_a_core_claims_it() { simultaneous_multithreading: false, efficiency_class: 7, }, - id: 0, processors: both, observations: Vec::new(), }]; @@ -943,9 +933,11 @@ fn split_l1_machine(cores: u32, last_level: u8) -> MachineMemoryTopology { size_bytes: 32 * 1024, cache_type, }, - id, processors: processors.clone(), - observations: Vec::new(), + // The fixture stands in for a discovered machine, so its + // relations say who reported them and carry the walk's own + // numbering -- which is what the partitioning rule reads back. + observations: vec![Observation::new(Source::RelationshipWalk, id)], }); id += 1; } @@ -958,7 +950,6 @@ fn split_l1_machine(cores: u32, last_level: u8) -> MachineMemoryTopology { size_bytes: 32 * 1024 * 1024, cache_type: CacheKind::Unified, }, - id, processors: ProcessorSet::from_group_mask(0, all), observations: Vec::new(), }); @@ -1004,7 +995,11 @@ fn cache_partitions_keep_the_first_domain_for_each_processor_set() { let ids: Vec = topo .cache_partitions_at_level(1) .iter() - .map(|domain| domain.id) + .map(|domain| { + domain + .label_from(Source::RelationshipWalk) + .expect("the fixture records a walk observation") + }) .collect(); // 0 and 2 are the `data` domains; 1 and 3 are the `instruction` domains // covering the same processors, and are the ones dropped. @@ -1028,7 +1023,7 @@ fn a_partitioning_cache_above_level_four_is_found() { // Replace the shared last level with two L5 partitions, so the dividing // level is one a fixed `1..=4` ceiling cannot reach. topo.domains.pop(); - for (id, mask) in [(100u32, 0b01usize), (101, 0b10)] { + for (_id, mask) in [(100u32, 0b01usize), (101, 0b10)] { topo.domains.push(Domain { kind: DomainKind::Cache { level: 5, @@ -1037,7 +1032,6 @@ fn a_partitioning_cache_above_level_four_is_found() { size_bytes: 64 * 1024 * 1024, cache_type: CacheKind::Unified, }, - id, processors: ProcessorSet::from_group_mask(0, mask), observations: Vec::new(), }); @@ -1072,7 +1066,7 @@ fn a_level_whose_domains_overlap_is_not_a_partition() { // work on processor 1 twice and overwrites its domain assignment. let mut topo = split_l1_machine(1, 3); topo.domains.pop(); // the shared last level, which divides nothing - for (id, mask) in [(200u32, 0b011usize), (201, 0b110)] { + for (_id, mask) in [(200u32, 0b011usize), (201, 0b110)] { topo.domains.push(Domain { kind: DomainKind::Cache { level: 2, @@ -1081,7 +1075,6 @@ fn a_level_whose_domains_overlap_is_not_a_partition() { size_bytes: 1024 * 1024, cache_type: CacheKind::Unified, }, - id, processors: ProcessorSet::from_group_mask(0, mask), observations: Vec::new(), }); @@ -1104,7 +1097,7 @@ fn a_level_whose_domains_are_disjoint_but_incomplete_still_partitions() { // discarding the level over the gap would throw away a true boundary. let mut topo = split_l1_machine(1, 3); topo.domains.pop(); - for (id, mask) in [(300u32, 0b0001usize), (301, 0b0010)] { + for (_id, mask) in [(300u32, 0b0001usize), (301, 0b0010)] { topo.domains.push(Domain { kind: DomainKind::Cache { level: 2, @@ -1113,7 +1106,6 @@ fn a_level_whose_domains_are_disjoint_but_incomplete_still_partitions() { size_bytes: 1024 * 1024, cache_type: CacheKind::Unified, }, - id, processors: ProcessorSet::from_group_mask(0, mask), observations: Vec::new(), }); @@ -1141,7 +1133,7 @@ fn a_domain_covering_nothing_is_not_a_partition() { // input this method promises not to trust. let mut topo = split_l1_machine(1, 3); topo.domains.pop(); - for (id, processors) in [ + for (_id, processors) in [ (400u32, ProcessorSet::from_group_mask(0, 0b111)), (401, ProcessorSet::empty()), ] { @@ -1153,7 +1145,6 @@ fn a_domain_covering_nothing_is_not_a_partition() { size_bytes: 1024 * 1024, cache_type: CacheKind::Unified, }, - id, processors, observations: Vec::new(), }); From cbfd72ed4a44dd555613462c6f06c98bb8dc3f5a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 17:25:58 -0400 Subject: [PATCH 272/361] feat(topology): record per-processor attribute conflicts (M3+.1.4) D-18's second subject kind, and the gap relation unification cannot reach. M3+.1.2 matches relations by (kind, membership), so two sources describing one core agree about THAT even when they disagree about a number hanging off it -- and the disagreement had nowhere to live, because both observations name the same relation. ProcessorAttribute, AttributeObservation, and MachineMemoryTopology::{processor_attributes, attribute_conflicts}. The walk's claim is fanned out from each core to its processors rather than left for a consumer to re-derive; that reconstruction is what this model exists to stop. Reported, never resolved. attribute_conflicts names the contested subjects and both claims stay. Picking a winner would destroy the disagreement, and on a hybrid part the choice decides whether a processor is treated as a performance or an efficiency core -- a defect visible only in a percentile. Not observable here: every efficiency class on this host reads 0, so four of the five tests are synthetic, per D-17. The fifth asserts that THIS HOST HAS NO CONFLICT, which is a measurement of the machine rather than a stand-in for the conflicting case. Sabotage-verified: making agreement count as a conflict fails three of them. A third thing now downgrades across the file boundary, for the same reason as the other two: processor_attributes are not serialized, because a file saying "the relationship walk observed this" cannot establish that it did (D-12). The round-trip test asserts all three explicitly. M3 is complete, 4 of 4. Completed item: M3+.1.4: Record a per-processor attribute conflict Completed item: M3+.1: Provenance is per relation, not per source Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 18 ++- .../src/granularity/tests.rs | 1 + .../windows-topology-sys/src/observation.rs | 66 ++++++++ crates/windows-topology-sys/src/topology.rs | 108 ++++++++++++- .../src/topology/tests.rs | 153 +++++++++++++++++- 5 files changed, 330 insertions(+), 16 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index e98d2bab..dd42a6fa 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -36,8 +36,8 @@ the **adapter's** problem and must not be filed here as a gap. |---|---|---| | M1 settle what is still open | **5 of 5 done** | nothing -- complete | | M2 the granularity model | **6 of 6 done** | nothing -- complete | -| M3 observation and provenance | **ready** | nothing (1 of 4 answered early, by D-19) | -| M4 the queries | **ready** | M2 is done; M3 is decision work that does not block it | +| M3 observation and provenance | **4 of 4 done** | nothing -- complete | +| M4 the queries | **ready** | M2 and M3 are both complete | | M5 the defects this subsumes | parked | M4, **except M5+.5 (done)** | **M1 was decision work, not implementation**, and it is complete. Each item was a question the @@ -373,7 +373,7 @@ never given an item. Recorded rather than absorbed silently -- this is the third to assert a gap the crate does not have, and the pattern is what the M2 re-plan named: **check the item against the code before planning work from it.** -- [ ] **M3+.1** -- Provenance is **per relation**, not per source. Per-relation subsumes per-source +- [x] **M3+.1** -- Provenance is **per relation**, not per source. Per-relation subsumes per-source by repetition, and the reverse fails on the case that matters: two sources describing the *same* relation. **What that means concretely, which the item did not say.** "The case that matters" does not exist @@ -458,7 +458,7 @@ item against the code before planning work from it.** NUMA assertion passed by coincidence and only the cache assertion failed -- the bug was one coincidence away from shipping. - - [ ] **M3+.1.4** -- **Record a per-processor attribute conflict**, which relation unification + - [x] **M3+.1.4** -- **Record a per-processor attribute conflict**, which relation unification cannot reach. `M3+.1.2` matches relations by `(kind, membership)`, so two sources describing one core agree on *that* even if they disagree about its `efficiency_class` -- and the unified relation keeps the walk's value while the CPU-sets value goes unrecorded. @@ -469,6 +469,16 @@ item against the code before planning work from it.** queue" rule exists to stop. **Not observable on this host** -- every efficiency class reads `0` here -- so it is testable only synthetically, per [D-17](DESIGN-NOTES.md#d-17). + **Done.** `ProcessorAttribute`, `AttributeObservation`, and + `MachineMemoryTopology::{processor_attributes, attribute_conflicts}`. The walk's claim is fanned + out from each core to its processors rather than left for a consumer to re-derive -- that + reconstruction is what this model exists to stop. + **Reported, never resolved.** `attribute_conflicts` names the contested subjects and both claims + stay: picking a winner would destroy the disagreement, and on a hybrid part the choice decides + whether a processor is treated as a performance or an efficiency core. + Five tests, four of them synthetic, plus one that asserts *this host has no conflict* -- measured + rather than standing in for the conflicting case. Sabotage-verified: making agreement count as a + conflict fails three of them. - [x] **M3+.2** -- Keep two properties of the old `Provenance` **because they re-derive**, not because they were there: the default is the untrusted value (a *stronger* argument per-relation, diff --git a/crates/windows-topology-sys/src/granularity/tests.rs b/crates/windows-topology-sys/src/granularity/tests.rs index 4a765cca..29e68eb8 100644 --- a/crates/windows-topology-sys/src/granularity/tests.rs +++ b/crates/windows-topology-sys/src/granularity/tests.rs @@ -66,6 +66,7 @@ fn topology(processor_count: u8, domains: Vec) -> MachineMemoryTopology domains, cpu_sets: None, provenance: Provenance::Synthetic, + processor_attributes: Vec::new(), } } diff --git a/crates/windows-topology-sys/src/observation.rs b/crates/windows-topology-sys/src/observation.rs index d961e0b0..3b1a6bff 100644 --- a/crates/windows-topology-sys/src/observation.rs +++ b/crates/windows-topology-sys/src/observation.rs @@ -70,5 +70,71 @@ impl Observation { } } +/// A per-processor attribute that more than one source may describe. +/// +/// [D-18](../DESIGN-NOTES.md)'s **second subject kind**. Relation unification +/// matches on `(kind, membership)`, so two sources describing one core agree +/// about *that* even when they disagree about a number hanging off it -- and +/// that disagreement has nowhere to live in a relation, because both +/// observations name the same relation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] +#[non_exhaustive] +pub enum ProcessorAttribute { + /// The scheduler's efficiency class. + /// + /// Both Windows APIs report it: the relationship walk attaches it to a + /// core, and CPU Sets to each processor. On a hybrid part this is the value + /// that decides whether a processor is a performance or an efficiency core, + /// so a silent disagreement is a planning defect that shows up only in a + /// percentile. + EfficiencyClass, +} + +/// One source's claim about one processor's attribute. +/// +/// The `(subject, claim, source)` triple of [D-18](../DESIGN-NOTES.md), where +/// the subject is `(processor, attribute)` rather than a relation identity. +/// Held as a list and never reduced, for the same reason relations hold their +/// observations as a set: collapsing them would destroy the disagreement, which +/// is the only thing a second observer is for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct AttributeObservation { + /// The processor this is about. + pub processor: crate::domain::ProcessorId, + /// Which attribute. + pub attribute: ProcessorAttribute, + /// What this source said its value is. + pub value: u32, + /// Which source said it. + pub source: Source, +} + +impl AttributeObservation { + /// An observation of `attribute` on `processor`. + #[must_use] + pub fn new( + processor: crate::domain::ProcessorId, + attribute: ProcessorAttribute, + value: u32, + source: Source, + ) -> Self { + Self { + processor, + attribute, + value, + source, + } + } + + /// The subject this observation is about. + #[must_use] + pub fn subject(&self) -> (crate::domain::ProcessorId, ProcessorAttribute) { + (self.processor, self.attribute) + } +} + #[cfg(test)] mod tests; diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 23286efe..f434498a 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -6,7 +6,7 @@ use std::io; use crate::cpu_set::CpuSet; use crate::domain::{Domain, DomainKind, Processor, ProcessorId}; -use crate::observation::{Observation, Source}; +use crate::observation::{AttributeObservation, Observation, ProcessorAttribute, Source}; use crate::processor_set::ProcessorSet; use crate::provenance::Provenance; use crate::relation::{self, Relations}; @@ -28,8 +28,7 @@ pub struct MachineMemoryTopology { pub processors: Vec, /// Every domain. pub domains: Vec, - /// What `GetSystemCpuSetInformation` reported, as **its own observation**. - /// + /// What `GetSystemCpuSetInformation` reported, as **its own observation**. /// /// Windows describes processors through two APIs, and this is the second /// one. It is not a more convenient spelling of [`Self::domains`]: it /// carries facts the relationship walk has no equivalent for -- whether a @@ -52,6 +51,25 @@ pub struct MachineMemoryTopology { /// genuinely reported nothing. #[cfg_attr(feature = "serde", serde(default))] pub cpu_sets: Option>, + /// What each source said about each processor's attributes. + /// + /// [D-18](../DESIGN-NOTES.md)'s second subject kind, and the one relation + /// unification cannot reach: two sources describing the same core agree on + /// `(kind, membership)` even when they disagree about a number hanging off + /// it, so that disagreement has nowhere to live among the relation's own + /// observations. + /// + /// Never reduced, for the same reason relations keep both observations: + /// collapsing them would destroy the disagreement, which is the only thing + /// a second observer is for. Use + /// [`Self::attribute_conflicts`] to find the subjects the sources did not + /// agree on. + /// + /// Empty for a hand-built or deserialized topology -- nobody asked, which + /// is [`Observed::NotObserved`](crate::Observed::NotObserved)'s meaning + /// carried up to a collection. + #[cfg_attr(feature = "serde", serde(default, skip))] + pub processor_attributes: Vec, /// Where this content came from. /// /// **Defaults to [`Provenance::Synthetic`]**, so a topology built by hand @@ -82,6 +100,9 @@ impl MachineMemoryTopology { // Both are cheap reads of the running system, so both belong to // discovery -- neither is a measurement in the sense that would make it // expensive or optional. + // The walk's per-processor claims, recorded before the fold so both + // sources' claims about one processor sit side by side (D-18). + topology.record_walk_attributes(); let cpu_sets = crate::cpu_set::enumerate()?; // Folded into the relation set, and *also* kept verbatim. Not a // contradiction: D-19's unified view is presented **in addition to** @@ -96,8 +117,7 @@ impl MachineMemoryTopology { } /// Record what the CPU-set enumeration says about relations, unifying with - /// what the relationship walk already reported. - /// + /// what the relationship walk already reported. /// /// # What is folded, and what deliberately is not /// /// Only **core** and **NUMA node** membership. Those are the two facts both @@ -154,6 +174,83 @@ impl MachineMemoryTopology { |kind| matches!(kind, DomainKind::Memory { .. }), |_| DomainKind::Memory { memory_bytes: None }, ); + + // The attribute subject, which relation unification cannot reach: both + // sources report an efficiency class for the same processor, and they + // agree about the core while possibly disagreeing about this (D-18). + for set in cpu_sets { + self.processor_attributes.push(AttributeObservation::new( + ProcessorId { + group: set.group, + number: set.logical_processor_index, + }, + ProcessorAttribute::EfficiencyClass, + u32::from(set.efficiency_class), + Source::CpuSets, + )); + } + } + + /// Record the relationship walk's per-processor attribute claims. + /// + /// The walk attaches an efficiency class to a *core*, so its claim about a + /// processor is its core's value -- fanned out here rather than left for a + /// consumer to re-derive, which is the reconstruction this model exists to + /// stop. + fn record_walk_attributes(&mut self) { + let claims: Vec = self + .domains + .iter() + .filter(|domain| domain.observed_by(Source::RelationshipWalk)) + .filter_map(|domain| match domain.kind { + DomainKind::Core { + efficiency_class, .. + } => Some((efficiency_class, &domain.processors)), + _ => None, + }) + .flat_map(|(efficiency_class, processors)| { + processors.iter().map(move |(group, number)| { + AttributeObservation::new( + ProcessorId { group, number }, + ProcessorAttribute::EfficiencyClass, + u32::from(efficiency_class), + Source::RelationshipWalk, + ) + }) + }) + .collect(); + self.processor_attributes.extend(claims); + } + + /// The `(processor, attribute)` subjects the sources did not agree on. + /// + /// Empty is the ordinary answer. A non-empty one is the **attribute + /// conflict** [D-17](../DESIGN-NOTES.md) says to expect in the field and to + /// record rather than refuse over -- firmware tables are populated + /// incrementally, and the places to meet this are hardware nobody here has. + /// + /// Reported rather than resolved: picking a winner would destroy the + /// disagreement, and on a hybrid part the choice decides whether a + /// processor is treated as a performance or an efficiency core. + #[must_use] + pub fn attribute_conflicts(&self) -> Vec<(ProcessorId, ProcessorAttribute)> { + let mut claims: BTreeMap<(ProcessorId, ProcessorAttribute), Vec> = BTreeMap::new(); + for observation in &self.processor_attributes { + claims + .entry(observation.subject()) + .or_default() + .push(observation.value); + } + claims + .into_iter() + .filter(|(_, values)| { + let mut distinct = values.clone(); + distinct.sort_unstable(); + distinct.dedup(); + distinct.len() > 1 + }) + .map(|(subject, _)| subject) + .collect() } /// Group the CPU-set records by whatever `key` names, keeping the records @@ -274,6 +371,7 @@ impl MachineMemoryTopology { processors, domains, cpu_sets: None, + processor_attributes: Vec::new(), // Synthetic, not measured: this is a pure transform of whatever // relations it was handed, and cannot know where they came from. // `discover` stamps the claim because `discover` is what read the diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 7fa5ccd2..28a2f816 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -76,6 +76,7 @@ fn synthetic() -> MachineMemoryTopology { // Named rather than defaulted, so this fixture states what it is. The // helper is called `synthetic` and now says so in the value too. provenance: Provenance::Synthetic, + processor_attributes: Vec::new(), } } @@ -178,16 +179,17 @@ mod serde_tests { // are on", and once written to a file it can no longer assert that. // Reloading yields `Restored`. // - // Two things are downgraded across the boundary, for one reason. The - // provenance drops to `Restored`, and every relation's platform - // observations are dropped -- because a file saying "the relationship - // walk observed this" cannot establish that it did, and carrying the - // claim would be exactly the forgery D-12 refuses. + // Three things are downgraded across the boundary, for one reason. The + // provenance drops to `Restored`; every relation's platform + // observations are dropped; and so are the per-processor attribute + // claims -- because a file saying "the relationship walk observed this" + // cannot establish that it did, and carrying the claim would be exactly + // the forgery D-12 refuses. // // The assertion is deliberately not weakened to "the parts I still // expect to match". Everything else must survive verbatim, so this - // compares against the original with only those two adjusted -- a - // second corruption would still fail here. + // compares against the original with only those adjusted -- a second + // corruption would still fail here. let topology = MachineMemoryTopology::discover().expect("discover"); assert!( topology.provenance.is_measured(), @@ -200,6 +202,10 @@ mod serde_tests { .all(|domain| !domain.observations.is_empty()), "a discovered relation must record which source reported it" ); + assert!( + !topology.processor_attributes.is_empty(), + "a discovered topology records what each source said per processor" + ); let json = serde_json::to_string(&topology).expect("serialize"); let back: MachineMemoryTopology = serde_json::from_str(&json).expect("deserialize"); @@ -211,9 +217,14 @@ mod serde_tests { .all(|domain| domain.observations.is_empty()), "a restored relation must not claim a platform source observed it" ); + assert!( + back.processor_attributes.is_empty(), + "nor may a restored topology claim a source said anything per processor" + ); let mut expected = topology; expected.provenance = Provenance::Restored; + expected.processor_attributes.clear(); for domain in &mut expected.domains { domain.observations.clear(); } @@ -367,6 +378,7 @@ mod serde_tests { domains: vec![core_domain(0, &[0, 1, 2, 3], 0)], cpu_sets: None, provenance: Provenance::Synthetic, + processor_attributes: Vec::new(), }; topology.fold_in_cpu_sets(&[ cpu_set(0, 0, 0, 0), @@ -411,6 +423,7 @@ mod serde_tests { domains: Vec::new(), cpu_sets: None, provenance: Provenance::Synthetic, + processor_attributes: Vec::new(), }; topology.fold_in_cpu_sets(&[cpu_set(0, 0, 0, 2), cpu_set(1, 0, 0, 2)]); @@ -443,6 +456,7 @@ mod serde_tests { domains: vec![core_domain(7, &[0, 1], 0)], cpu_sets: None, provenance: Provenance::Synthetic, + processor_attributes: Vec::new(), }; topology.fold_in_cpu_sets(&[cpu_set(0, 3, 0, 0), cpu_set(1, 3, 0, 0)]); @@ -473,6 +487,7 @@ mod serde_tests { domains: vec![memory], cpu_sets: None, provenance: Provenance::Synthetic, + processor_attributes: Vec::new(), }; topology.fold_in_cpu_sets(&[cpu_set(0, 5, 0, 0), cpu_set(1, 5, 0, 0)]); @@ -552,6 +567,128 @@ mod serde_tests { "and no relation claims a platform source either" ); } + // --- M3+.1.4: the attribute subject (D-18) --- + + #[test] + fn both_sources_claim_an_efficiency_class_for_every_processor() { + let topology = MachineMemoryTopology::discover().expect("discover"); + + for processor in topology.processors.iter().filter(|p| p.online) { + let claims: Vec<_> = topology + .processor_attributes + .iter() + .filter(|a| { + a.processor == processor.id + && a.attribute == ProcessorAttribute::EfficiencyClass + }) + .collect(); + assert_eq!( + claims.len(), + 2, + "both Win32 sources report an efficiency class for {:?}: {claims:?}", + processor.id + ); + } + } + + #[test] + fn this_host_has_no_attribute_conflict() { + // Measured, not assumed. Every efficiency class reads 0 here, so this + // asserts what the machine actually is rather than standing in for the + // conflicting case -- which is why the next test builds one. + let topology = MachineMemoryTopology::discover().expect("discover"); + assert_eq!(topology.attribute_conflicts(), Vec::new()); + } + + #[test] + fn a_disagreement_about_one_processor_is_reported_not_resolved() { + // Testable only synthetically (D-17): the development host is not + // hybrid, and the field cases are hardware nobody here has plus + // prerelease firmware. + let mut topology = MachineMemoryTopology { + processors: Vec::new(), + domains: vec![core_domain(0, &[0, 1], 0)], + cpu_sets: None, + processor_attributes: Vec::new(), + provenance: Provenance::Synthetic, + }; + topology.record_walk_attributes(); + // CPU Sets disagrees about processor 0 and agrees about processor 1. + topology.fold_in_cpu_sets(&[cpu_set(0, 0, 0, 1), cpu_set(1, 0, 0, 0)]); + + assert_eq!( + topology.attribute_conflicts(), + vec![( + ProcessorId { + group: 0, + number: 0 + }, + ProcessorAttribute::EfficiencyClass + )], + "only the contested processor is named" + ); + + // Both claims survive. Picking a winner would destroy the disagreement, + // and on a hybrid part it decides whether this is a performance core. + let values: Vec = topology + .processor_attributes + .iter() + .filter(|a| { + a.processor + == ProcessorId { + group: 0, + number: 0, + } + }) + .map(|a| a.value) + .collect(); + assert_eq!(values.len(), 2); + assert!(values.contains(&0) && values.contains(&1), "{values:?}"); + } + + #[test] + fn a_conflict_is_local_to_the_subject_that_has_one() { + // D-19's blast radius, at the attribute level: one contested processor + // must not make the others look uncertain. + let mut topology = MachineMemoryTopology { + processors: Vec::new(), + domains: vec![core_domain(0, &[0, 1, 2, 3], 0)], + cpu_sets: None, + processor_attributes: Vec::new(), + provenance: Provenance::Synthetic, + }; + topology.record_walk_attributes(); + topology.fold_in_cpu_sets(&[ + cpu_set(0, 0, 0, 0), + cpu_set(1, 0, 0, 0), + cpu_set(2, 0, 0, 3), + cpu_set(3, 0, 0, 0), + ]); + + assert_eq!(topology.attribute_conflicts().len(), 1); + assert_eq!( + topology.attribute_conflicts()[0].0, + ProcessorId { + group: 0, + number: 2 + } + ); + } + + #[test] + fn a_hand_built_topology_records_no_attribute_claims() { + // Nobody asked, so nothing is claimed -- the same untrusted default the + // relation-level observations take. + let topology = MachineMemoryTopology { + processors: Vec::new(), + domains: vec![core_domain(0, &[0, 1], 0)], + cpu_sets: None, + processor_attributes: Vec::new(), + provenance: Provenance::Synthetic, + }; + assert!(topology.processor_attributes.is_empty()); + assert!(topology.attribute_conflicts().is_empty()); + } #[test] fn a_hand_written_synthetic_topology_parses() { let json = r#"{ @@ -959,6 +1096,7 @@ fn split_l1_machine(cores: u32, last_level: u8) -> MachineMemoryTopology { domains, cpu_sets: None, provenance: Provenance::Synthetic, + processor_attributes: Vec::new(), } } @@ -975,6 +1113,7 @@ fn cache_levels_are_empty_when_no_cache_is_reported() { domains: Vec::new(), cpu_sets: None, provenance: Provenance::Synthetic, + processor_attributes: Vec::new(), }; assert!(topo.cache_levels().is_empty()); } From a4257799685d5686c52bae6b9933422d3c59c681 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:32:47 -0700 Subject: [PATCH 273/361] feat(topology): pairwise proximity and an inclusion-ordered partitioning projection M4+.1 and M4+.4, which both build on M2's order. M4+.1: Proximity { shared, finer_unobserved } and MachineMemoryTopology::proximity(&[ProcessorId]). The body is minimal_shared plus a coverage check, deliberately -- the COLLECTION is the surface a caller reaching for structure should use, because rebuilding the grouping from repeated pairwise calls takes union-find over O(n^2) questions, which is the reconstruction SH-16.9 records three consumers doing, two differently. Generalised past a pair: nothing in the query is specific to two, and the same call sizes an MPSC fan-in over a whole block. The third of EP-D-2's requirements is the one that needed building. `finer_unobserved` says the answer is an UPPER BOUND: some kind the machine reports covers one of these processors in no instance, so the platform has said nothing about whether they share it. A caller told "the tightest shared thing is L3" when L2 was simply never reported would choose a slower channel than the hardware supports and never learn why -- and under this crate's own bar it cannot go and measure. The distinction that makes it honest is D-13's: absence of a KIND is a complete description of a machine without it, while absence of a processor FROM a kind that exists is a gap. Memory is excluded because a processor in no memory domain is M4+.3's unplaced case, with its own answer. Proximity::only() returns None on a tie rather than taking the first, so a caller that cannot handle M2+.4's multi-element answer must say so. M4+.4: outermost_partitioning_cache is now a projection over the order. Level GROUPS relations into candidate partitions -- reading what the source said -- and no longer ORDERS them, because "a higher number is coarser" is the asserted structure M2+.2 forbids and the ARM64 host disproves. The coarsest is the candidate no other refines, checkable against reported memberships. EVERY PRE-EXISTING TEST PASSED UNDER BOTH RULES, so this change would have been invisible. The discriminating case -- a machine whose lower level forms the coarser partition -- exists in no fixture built from real hardware. Reverting to the old .rev() now fails exactly one test, the one written for it. Sabotage-verified both: dropping the kind-exists guard fails three tests including the D-13 conflation case. Completed item: M4+.1: The ordered relations are the query surface; pairwise proximity is a method on them Completed item: M4+.4: Reduce outermost_partitioning_cache to a named projection over the order Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 36 ++++- .../windows-topology-sys/src/granularity.rs | 138 +++++++++++++++++- .../src/granularity/tests.rs | 116 +++++++++++++++ crates/windows-topology-sys/src/lib.rs | 4 +- crates/windows-topology-sys/src/topology.rs | 58 +++++++- .../src/topology/tests.rs | 125 ++++++++++++++++ 6 files changed, 467 insertions(+), 10 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index dd42a6fa..8e6b9812 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -542,7 +542,7 @@ They remain cross-referenced to [topology-planner](../topology-planner/DESIGN-NO **evidence** the shape is right rather than as its justification -- stating those requirements found the `Processor::capacity` sentinel collision that reviewing the model alone had not. -- [ ] **M4+.1** -- **The ordered relations are the query surface; pairwise proximity is a method on +- [x] **M4+.1** -- **The ordered relations are the query surface; pairwise proximity is a method on them.** The requirement arrived from [EP-D-2](../topology-planner/DESIGN-NOTES.md#ep-d-2) as a *pairwise* query returning the minimal shared granularities, **their membership**, and whether a finer granularity went **unobserved** so the answer can be an upper bound and say so. All three @@ -561,6 +561,21 @@ the `Processor::capacity` sentinel collision that reviewing the model alone had *Terminology:* it is a **poset with a top**, not a lattice -- M2+.4's incomparable granularities mean meets need not be unique, which is also why a pairwise function has to return a *set* and is an awkward face on an ordered collection. + **Done.** `Proximity { shared, finer_unobserved }` and + `MachineMemoryTopology::proximity(&[ProcessorId])`, whose body is `minimal_shared` plus the + coverage check -- so there is one implementation of the grouping, not one per caller. + Generalised past a pair, because nothing in the query is specific to two: the same call sizes an + MPSC fan-in over a whole block. + **The third requirement is the one that needed building.** `finer_unobserved` says the answer is an + **upper bound**: some kind the machine reports covers one of these processors in no instance, so the + platform has said nothing about whether they share it. The distinction that makes it honest is + [D-13](DESIGN-NOTES.md#d-13)'s -- absence of a *kind* describes a machine without it, while absence + of a processor *from* a kind that exists is a gap. `Memory` is excluded because a processor in no + memory domain is `M4+.3`'s unplaced case, which has its own answer. + `Proximity::only()` returns `None` on a tie rather than taking the first, so a caller that cannot + handle M2+.4's multi-element answer has to say so. + Sabotage-verified: dropping the kind-exists guard -- which would make a machine with no caches look + incomplete -- fails three tests, including the D-13 conflation case. - [ ] **M4+.2** -- The **shard-set** surface (EP-D-1): identity as `(group, number)`, online, core membership and SMT, efficiency class **without a sentinel**, and availability. @@ -569,9 +584,26 @@ the `Processor::capacity` sentinel collision that reviewing the model alone had distinguishable rather than defaulted -- an unknown cache domain costs an optimisation, an unknown memory domain has no honest fallback. -- [ ] **M4+.4** -- Reduce `outermost_partitioning_cache` to a **named projection** over the order -- +- [x] **M4+.4** -- Reduce `outermost_partitioning_cache` to a **named projection** over the order -- "the coarsest granularity with more than one group" -- so it is a query rather than a rule, and cannot be restated wrongly because there is nothing to restate. + **Note on what "over the order" can and cannot mean here.** M2's order is over *relations*, and a + partition is a *set* of relations, so the projection needs both: a **grouping** key to say which + relations form one candidate partition, and an **order** to say which candidate is coarsest. Level + is used for the first and must not be used for the second -- grouping by level reads what the + source said, whereas ordering by level asserts that a higher number is always coarser, which is the + structure `M2+.2` forbids and this host's ARM64 sibling disproves. + **Done.** The projection now collects every level that forms a partition, then picks the one no + other **refines** -- checkable against the memberships Windows reported, where "a higher number is + coarser" is asserted. + **Every pre-existing test passed under both rules**, so the change would have been invisible: the + discriminating case is a machine whose *lower* level forms the *coarser* partition, which no + fixture built from real hardware has. Reverting to the old `.rev()` over level numbers now fails + exactly one test, and it is the one written for that case. + +**Overlap noticed on reading, not deferred:** `M4+.2`'s "efficiency class **without a sentinel**" and +`M5+.1`'s `Processor::capacity` collision are the same defect from two sides. They land together, in +`M4+.2`, and `M5+.1` records that rather than repeating the work. ## M5: the defects this subsumes diff --git a/crates/windows-topology-sys/src/granularity.rs b/crates/windows-topology-sys/src/granularity.rs index 0b2064c5..1e586d69 100644 --- a/crates/windows-topology-sys/src/granularity.rs +++ b/crates/windows-topology-sys/src/granularity.rs @@ -20,7 +20,7 @@ //! and against a *NUMA node*, which share no numbering with it and could not //! otherwise be compared at all. -use crate::domain::Domain; +use crate::domain::{Domain, DomainKind, ProcessorId}; use crate::processor_set::ProcessorSet; use crate::topology::MachineMemoryTopology; @@ -155,6 +155,97 @@ impl MachineMemoryTopology { .collect() } + /// What a set of processors shares, and whether the answer is complete. + /// + /// The pairwise proximity query, generalised to any set because nothing in + /// it is specific to two: `proximity(&[a, b])` is the pair case, and the + /// same call sizes an MPSC fan-in over a whole block. + /// + /// # It is a helper over the order, not the primary surface + /// + /// The body is [`Self::minimal_shared`] plus the coverage check below. That + /// is deliberate: the *collection* is what a caller reaching for structure + /// should use, because rebuilding the grouping from repeated pairwise calls + /// takes union-find over `O(n^2)` questions -- and that reconstruction is + /// exactly what `SH-16.9` records three consumers doing, two of them + /// differently. Providing the helper here means there is one implementation + /// of the grouping rather than one per caller. + /// + /// # Errors + /// + /// None -- the query is total over processors this topology knows, and + /// answers [`Granularity::Machine`] rather than "nothing" for a pair no + /// relation covers. A processor it does not know yields an empty + /// [`Proximity::shared`], because claiming the machine contains a processor + /// it has never heard of would be an invention. + #[must_use] + pub fn proximity(&self, processors: &[ProcessorId]) -> Proximity<'_> { + let mut set = ProcessorSet::empty(); + for id in processors { + set.insert(id.group, id.number); + } + Proximity { + shared: self.minimal_shared(&set), + finer_unobserved: processors + .iter() + .any(|id| !self.kinds_covering(*id).is_empty()), + } + } + + /// The relation kinds this machine reports that `processor` appears in + /// **no** instance of. + /// + /// Evidence of a gap in what the platform said, not of a fact about the + /// machine. A kind is only counted when some *other* processor is covered + /// by it, so a machine that reports no caches at all does not make every + /// answer an upper bound -- that is a complete description of a machine + /// without caches, and treating it as incomplete would be the same + /// conflation [D-13](../DESIGN-NOTES.md) exists to remove. + fn kinds_covering(&self, processor: ProcessorId) -> Vec<&'static str> { + let mut missing = Vec::new(); + for kind in Self::REPORTED_KINDS { + let mut kind_exists = false; + let mut covers = false; + for domain in &self.domains { + if Self::kind_name(&domain.kind) != kind { + continue; + } + kind_exists = true; + if domain + .processors + .contains(processor.group, processor.number) + { + covers = true; + break; + } + } + if kind_exists && !covers { + missing.push(kind); + } + } + missing + } + + /// The kinds a coverage gap is meaningful for. + /// + /// Deliberately not every [`DomainKind`]: `Memory` is excluded because a + /// processor in no memory domain is `M4+.3`'s *unplaced* case, which has + /// its own answer and is not evidence that something finer went unreported. + const REPORTED_KINDS: [&'static str; 5] = ["cache", "core", "module", "die", "package"]; + + fn kind_name(kind: &DomainKind) -> &'static str { + match kind { + DomainKind::Group => "group", + DomainKind::Package => "package", + DomainKind::Die => "die", + DomainKind::Module => "module", + DomainKind::Core { .. } => "core", + DomainKind::Cache { .. } => "cache", + DomainKind::Memory { .. } => "memory", + DomainKind::Other { .. } => "other", + } + } + /// Whether `finer` sits strictly inside `coarser` in the granularity /// order. /// @@ -169,5 +260,50 @@ impl MachineMemoryTopology { } } +/// What a set of processors shares, and how far the answer can be trusted. +/// +/// The answer to [`MachineMemoryTopology::proximity`], and the reason it is a +/// struct rather than a bare list: a caller needs to know not only what was +/// found but whether something finer might exist and simply was not reported. +#[derive(Clone, Debug, PartialEq)] +pub struct Proximity<'a> { + /// The tightest granularities all the processors share. + /// + /// A **set**, because inclusion is a partial order and two granularities + /// can be incomparable -- almost always one element, but not by + /// construction (M2+.4). Exactly [`Granularity::Machine`] when no reported + /// relation covers them all, which is what makes the query total (M2+.3). + pub shared: Vec>, + /// Whether a **finer** granularity may exist that was never reported. + /// + /// When true, [`Self::shared`] is an **upper bound** rather than the + /// answer: the machine describes some kind of relation that one of these + /// processors appears in *no* instance of, so the platform has said nothing + /// about whether they share it. + /// + /// This is the third of [EP-D-2]'s requirements, and the one a naive design + /// drops. A caller told "the tightest shared thing is L3" when in truth L2 + /// was never reported for this processor would choose a slower channel than + /// the machine can support and never learn why -- and under this crate's own + /// bar it cannot go and measure to find out. + /// + /// [EP-D-2]: ../../topology-planner/DESIGN-NOTES.md + pub finer_unobserved: bool, +} + +impl<'a> Proximity<'a> { + /// The single shared granularity, when there is exactly one. + /// + /// `None` when the answer is a tie, so a caller that cannot handle one is + /// forced to say so rather than silently taking the first. + #[must_use] + pub fn only(&self) -> Option> { + match self.shared.as_slice() { + [single] => Some(*single), + _ => None, + } + } +} + #[cfg(test)] mod tests; diff --git a/crates/windows-topology-sys/src/granularity/tests.rs b/crates/windows-topology-sys/src/granularity/tests.rs index 29e68eb8..2d43f377 100644 --- a/crates/windows-topology-sys/src/granularity/tests.rs +++ b/crates/windows-topology-sys/src/granularity/tests.rs @@ -364,3 +364,119 @@ fn relation_and_is_machine_agree() { assert!(Granularity::Machine.is_machine()); assert!(Granularity::Machine.relation().is_none()); } + +// --- M4+.1: the pairwise helper and its upper-bound flag --- + +fn id(number: u8) -> ProcessorId { + ProcessorId { group: 0, number } +} + +#[test] +fn proximity_is_the_pairwise_face_of_minimal_shared() { + // The helper must not be a second implementation of the grouping -- that + // is the SH-16.9 defect one level up -- so it must agree with the + // collection exactly. + let t = topology(4, vec![core(&[0, 1]), memory(&[0, 1, 2, 3])]); + assert_eq!( + t.proximity(&[id(0), id(1)]).shared, + t.minimal_shared(&set(&[0, 1])) + ); + assert_eq!( + t.proximity(&[id(0), id(3)]).shared, + t.minimal_shared(&set(&[0, 3])) + ); +} + +#[test] +fn proximity_generalises_past_two_processors() { + // Nothing in the query is specific to a pair; the same call sizes a fan-in + // over a whole block. + let t = topology(4, vec![core(&[0, 1]), memory(&[0, 1, 2, 3])]); + let over_three = t.proximity(&[id(0), id(1), id(2)]); + assert_eq!(over_three.shared.len(), 1); + assert!(matches!( + over_three.shared[0].relation().expect("a relation").kind, + DomainKind::Memory { .. } + )); +} + +#[test] +fn a_pair_no_relation_covers_is_the_machine_and_the_query_stays_total() { + let t = topology(4, vec![memory(&[0, 1]), memory(&[2, 3])]); + assert_eq!( + t.proximity(&[id(0), id(3)]).shared, + vec![Granularity::Machine] + ); +} + +#[test] +fn a_complete_machine_reports_no_unobserved_finer_granularity() { + // Every processor is covered by every kind this machine reports, so the + // answer is the answer -- not an upper bound. + let t = topology( + 4, + vec![ + core(&[0, 1]), + core(&[2, 3]), + cache(2, &[0, 1], CacheKind::Unified), + cache(2, &[2, 3], CacheKind::Unified), + ], + ); + assert!(!t.proximity(&[id(0), id(1)]).finer_unobserved); +} + +#[test] +fn a_processor_in_no_relation_of_a_reported_kind_makes_the_answer_an_upper_bound() { + // Processor 3 is in no core, while cores exist. The platform has said + // nothing about whether 0 and 3 share one, so "the tightest shared thing is + // the machine" is at most true -- and a caller told otherwise would pick a + // slower channel than the hardware can support and never learn why. + let t = topology(4, vec![core(&[0, 1]), core(&[2])]); + let answer = t.proximity(&[id(0), id(3)]); + assert!( + answer.finer_unobserved, + "partial coverage at a reported kind is a gap, not a fact: {answer:?}" + ); +} + +#[test] +fn a_machine_that_reports_no_caches_is_complete_not_incomplete() { + // The conflation D-13 exists to remove, at the query level: absence of a + // KIND is a complete description of a machine without it, whereas absence + // of a processor FROM a kind that exists is a gap. Counting the first as a + // gap would make every answer on such a machine an upper bound. + let t = topology(2, vec![core(&[0, 1])]); + assert!(!t.proximity(&[id(0), id(1)]).finer_unobserved); +} + +#[test] +fn a_memory_gap_is_not_an_unobserved_finer_granularity() { + // A processor in no memory domain is M4+.3's *unplaced* case, which has its + // own answer. Treating it as evidence of unreported finer sharing would + // conflate two different absences. + let t = topology(4, vec![core(&[0, 1]), core(&[2, 3]), memory(&[0, 1])]); + assert!(!t.proximity(&[id(0), id(2)]).finer_unobserved); +} + +#[test] +fn only_reports_a_tie_rather_than_hiding_it() { + let t = topology( + 4, + vec![ + cache(1, &[0, 1], CacheKind::Data), + cache(1, &[0, 1], CacheKind::Instruction), + ], + ); + let answer = t.proximity(&[id(0), id(1)]); + assert_eq!(answer.shared.len(), 2); + assert_eq!(answer.only(), None, "a caller must not silently take one"); + + let single = topology(2, vec![core(&[0, 1])]); + assert!(single.proximity(&[id(0), id(1)]).only().is_some()); +} + +#[test] +fn an_unknown_processor_yields_an_empty_answer_not_the_machine() { + let t = topology(2, vec![core(&[0, 1])]); + assert!(t.proximity(&[id(0), id(7)]).shared.is_empty()); +} diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 54169bbb..66a9d545 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -86,9 +86,9 @@ pub use cpu_set::CpuSet; #[cfg(windows)] pub use domain::{AttributeValue, Domain, DomainKind, Processor, ProcessorId}; #[cfg(windows)] -pub use granularity::Granularity; +pub use granularity::{Granularity, Proximity}; #[cfg(windows)] -pub use observation::{Observation, Source}; +pub use observation::{AttributeObservation, Observation, ProcessorAttribute, Source}; pub use observed::Observed; pub use processor_set::ProcessorSet; pub use provenance::Provenance; diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index f434498a..fd5cd3ea 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -562,15 +562,63 @@ impl MachineMemoryTopology { /// the firmware said, not evidence that the domains which *were* reported /// overlap, and rejecting the level would discard a true boundary over it. /// + /// # "Outermost" is decided by inclusion, not by the level number + /// + /// Level groups the relations into candidate partitions -- that is reading + /// what the source said. It does **not** order them, because "a higher + /// number is coarser" is asserted structure of the kind `M2+.2` forbids, + /// and a machine that reports L2 over six processors and no L3 at all is + /// the counterexample this crate has actually measured. + /// + /// A candidate is coarser than another when **every block of the other is + /// contained in one of its blocks**, which is checkable against the very + /// memberships Windows reported. On ordinary hardware the two agree; where + /// they disagree, the firmware numbering is the one that is wrong. + /// /// Defined here, in the crate that owns the topology, so that every /// consumer asks the same question rather than restating the rule and /// drifting from it. pub fn outermost_partitioning_cache(&self) -> Option<(u8, Vec<&Domain>)> { - self.cache_levels().into_iter().rev().find_map(|level| { - let partitions = self.cache_partitions_at_level(level); - (partitions.len() > 1 && Self::are_pairwise_disjoint(&partitions)) - .then_some((level, partitions)) - }) + let candidates: Vec<(u8, Vec<&Domain>)> = self + .cache_levels() + .into_iter() + .filter_map(|level| { + let blocks = self.cache_partitions_at_level(level); + (blocks.len() > 1 && Self::are_pairwise_disjoint(&blocks)) + .then_some((level, blocks)) + }) + .collect(); + + // Coarsest by inclusion: the candidate no other candidate is coarser + // than. Ties -- two candidates refining each other, which means equal + // block memberships under different levels -- keep the first, matching + // `cache_partitions_at_level`'s own first-wins rule. + candidates + .iter() + .find(|candidate| { + !candidates + .iter() + .any(|other| Self::refines(&candidate.1, &other.1)) + }) + .map(|(level, blocks)| (*level, blocks.clone())) + } + + /// Whether every block of `finer` sits inside some block of `coarser`, and + /// the two are not the same partition. + /// + /// The refinement order over candidate partitions, derived from the same + /// membership inclusion the granularity order uses. + fn refines(finer: &[&Domain], coarser: &[&Domain]) -> bool { + let all_contained = finer.iter().all(|block| { + coarser + .iter() + .any(|outer| block.processors.is_subset(&outer.processors)) + }); + let same = finer.len() == coarser.len() + && finer + .iter() + .all(|block| coarser.iter().any(|o| o.processors == block.processors)); + all_contained && !same } /// Whether no two of these domains claim the same processor. diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 28a2f816..1c71c0cd 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -1128,6 +1128,131 @@ fn a_split_instruction_and_data_cache_is_one_partition_not_two() { assert_eq!(topo.cache_partitions_at_level(1).len(), 8); } +// --- M4+.4: "outermost" is inclusion, not the level number --- + +/// A cache relation over `numbers`, labelled by the relationship walk. +fn cache_at(level: u8, label: u32, numbers: &[u8]) -> Domain { + let mut processors = ProcessorSet::empty(); + for &n in numbers { + processors.insert(0, n); + } + Domain { + kind: DomainKind::Cache { + level, + associativity: 8, + line_size: 64, + size_bytes: 32 * 1024, + cache_type: CacheKind::Unified, + }, + processors, + observations: vec![Observation::new(Source::RelationshipWalk, label)], + } +} + +fn machine_of(count: u8, domains: Vec) -> MachineMemoryTopology { + MachineMemoryTopology { + processors: (0..count) + .map(|number| Processor { + id: ProcessorId { group: 0, number }, + online: true, + capacity: 0, + }) + .collect(), + domains, + cpu_sets: None, + processor_attributes: Vec::new(), + provenance: Provenance::Synthetic, + } +} + +#[test] +fn the_outermost_partition_is_the_coarsest_one_not_the_highest_level() { + // The discriminating case, which no fixture built from real hardware has: + // the LOWER level number forms the COARSER partition. Ordering by level + // would answer L2 (four blocks of two); ordering by inclusion answers L1 + // (two blocks of four), which is the outer boundary. + // + // Every pre-existing test passes under either rule, so without this one the + // change from `.rev()` over level numbers to a refinement order would be + // invisible. + let topo = machine_of( + 8, + vec![ + cache_at(1, 0, &[0, 1, 2, 3]), + cache_at(1, 1, &[4, 5, 6, 7]), + cache_at(2, 2, &[0, 1]), + cache_at(2, 3, &[2, 3]), + cache_at(2, 4, &[4, 5]), + cache_at(2, 5, &[6, 7]), + ], + ); + + let (level, blocks) = topo + .outermost_partitioning_cache() + .expect("both levels partition"); + assert_eq!( + level, 1, + "the coarser partition wins even though its level number is lower" + ); + assert_eq!(blocks.len(), 2); +} + +#[test] +fn the_usual_ordering_is_unchanged_where_the_two_rules_agree() { + // Ordinary hardware: the higher level is also the coarser one, so the + // answer must not move. + let topo = machine_of( + 8, + vec![ + cache_at(1, 0, &[0, 1]), + cache_at(1, 1, &[2, 3]), + cache_at(1, 2, &[4, 5]), + cache_at(1, 3, &[6, 7]), + cache_at(3, 4, &[0, 1, 2, 3]), + cache_at(3, 5, &[4, 5, 6, 7]), + ], + ); + + let (level, blocks) = topo.outermost_partitioning_cache().expect("both partition"); + assert_eq!(level, 3); + assert_eq!(blocks.len(), 2); +} + +#[test] +fn a_level_that_partitions_nothing_is_still_never_a_candidate() { + // A fully shared cache is one block, so it cannot be the boundary however + // coarse it is -- the rule this projection must not lose. + let topo = machine_of( + 4, + vec![ + cache_at(2, 0, &[0, 1]), + cache_at(2, 1, &[2, 3]), + cache_at(3, 2, &[0, 1, 2, 3]), + ], + ); + + let (level, blocks) = topo.outermost_partitioning_cache().expect("L2 divides"); + assert_eq!(level, 2, "the shared L3 partitions nothing"); + assert_eq!(blocks.len(), 2); +} + +#[test] +fn overlapping_blocks_still_disqualify_a_level() { + // Pairwise disjointness is not weakened by the reordering: a hand-built + // topology whose blocks overlap would make a caller double-count. + let topo = machine_of( + 4, + vec![ + cache_at(2, 0, &[0, 1]), + cache_at(2, 1, &[1, 2]), + cache_at(3, 2, &[0, 1]), + cache_at(3, 3, &[2, 3]), + ], + ); + + let (level, _) = topo.outermost_partitioning_cache().expect("L3 divides"); + assert_eq!(level, 3, "the overlapping L2 is refused"); +} #[test] fn cache_partitions_keep_the_first_domain_for_each_processor_set() { let topo = split_l1_machine(2, 3); From a7462350a8dcf75f4eccf6e67988b47d17b2c26a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:39:31 -0700 Subject: [PATCH 274/361] feat(topology): the shard-set and residency surfaces, without sentinels M4+.2 and M4+.3, which completes the query milestone bar one item the work itself uncovered. M4+.3: memory_domain_of(processor) -> Observed<&Domain> and unplaced_processors(). The unplaced case is NotObserved, never node zero -- the pool has to be allocated somewhere and guessing means quietly allocating remote memory for the life of the process. Observed::Absent is never returned: a memory domain covering no processors is a real shape (D-5), but a processor belonging to no node is a gap in what the firmware said. M4+.2: ProcessorFacts and shard_set(). Every optional field is an Observed, so "the platform said zero" and "nobody asked" are different values. That is M5+.1's collision fixed from the other side -- Processor::capacity spells offline, in-no-core and class-zero as the same 0, and the third is every processor on every non-hybrid machine, so the sentinel collides with the overwhelmingly common real value. TWO FINDINGS FROM MEASURING THE RESULT RATHER THAN TRUSTING THE TESTS. First, I wrote a usable() helper and removed it. Which of online, parked and allocation disqualifies a processor is a POLICY, and per D-21 this crate states facts -- baking the judgement in is exactly what outermost_partitioning_cache was criticised for. My own code, caught by the crate's own rule. Second, and it is why that mattered: allocated_to_this_process reads FALSE FOR EVERY PROCESSOR on this host, for a process plainly running on them. So usable() refused the entire machine. The CPU-set flag bit positions are transcribed from the SDK's bitfield order and have never been verified; parked reads false everywhere too, so neither flag has ever been observed true and nothing distinguishes "the bit is clear" from "wrong bit". Filed as M4+.5, with the field documenting itself as unconfirmed. Until it is verified, no behaviour may depend on these flags -- which the removal of usable() now guarantees. M5+.1 is closed as subsumed: the sentinel-free answer lives in ProcessorFacts, and Processor::capacity stays with its existing warning, since removing a published field is a second breaking change with no benefit once the honest answer exists beside it. Completed item: M4+.2: The shard-set surface (EP-D-1) Completed item: M4+.3: Residency (EP-D-3) Completed item: M5+.1: Processor::capacity's colliding sentinel (subsumed by M4+.2) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 42 +++- crates/windows-topology-sys/src/domain.rs | 69 +++++++ crates/windows-topology-sys/src/lib.rs | 2 +- crates/windows-topology-sys/src/topology.rs | 102 +++++++++- .../src/topology/tests.rs | 181 ++++++++++++++++++ 5 files changed, 389 insertions(+), 7 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 8e6b9812..0e1a72fb 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -37,7 +37,7 @@ the **adapter's** problem and must not be filed here as a gap. | M1 settle what is still open | **5 of 5 done** | nothing -- complete | | M2 the granularity model | **6 of 6 done** | nothing -- complete | | M3 observation and provenance | **4 of 4 done** | nothing -- complete | -| M4 the queries | **ready** | M2 and M3 are both complete | +| M4 the queries | **4 of 5 done** | `M4+.5`, which the work itself uncovered | | M5 the defects this subsumes | parked | M4, **except M5+.5 (done)** | **M1 was decision work, not implementation**, and it is complete. Each item was a question the @@ -577,12 +577,26 @@ the `Processor::capacity` sentinel collision that reviewing the model alone had Sabotage-verified: dropping the kind-exists guard -- which would make a machine with no caches look incomplete -- fails three tests, including the D-13 conflation case. -- [ ] **M4+.2** -- The **shard-set** surface (EP-D-1): identity as `(group, number)`, online, core +- [x] **M4+.2** -- The **shard-set** surface (EP-D-1): identity as `(group, number)`, online, core membership and SMT, efficiency class **without a sentinel**, and availability. - -- [ ] **M4+.3** -- **Residency** (EP-D-3): processor to memory domain, with the unplaced case + **Done.** `ProcessorFacts` and `MachineMemoryTopology::shard_set()`. Every optional field is an + `Observed`, so "the platform said zero" and "nobody asked" are different values -- which is the + `M5+.1` collision fixed from the other side, since `Processor::capacity` spells offline, in-no-core, + and class-zero as the same `0` and the third is every processor on every non-hybrid machine. + **It states availability and does not judge it.** A `usable()` helper was written and then removed: + which of online, parked and allocation disqualifies a processor is a **policy**, and per + [D-21](DESIGN-NOTES.md#d-21) this crate states facts -- baking the judgement in is exactly what + `outermost_partitioning_cache` was criticised for. It would also have been wrong, which is how the + next item was found. + +- [x] **M4+.3** -- **Residency** (EP-D-3): processor to memory domain, with the unplaced case distinguishable rather than defaulted -- an unknown cache domain costs an optimisation, an unknown memory domain has no honest fallback. + **Done.** `memory_domain_of(processor) -> Observed<&Domain>` and `unplaced_processors()`. The + unplaced case is `NotObserved`, never node zero: the pool has to be allocated somewhere, and + guessing means quietly allocating remote memory for the life of the process. `Observed::Absent` is + never returned -- a memory domain covering no processors is a real shape (D-5), but a processor + belonging to no node is a gap in what the firmware said, not a statement that it has no memory. - [x] **M4+.4** -- Reduce `outermost_partitioning_cache` to a **named projection** over the order -- "the coarsest granularity with more than one group" -- so it is a query rather than a rule, and @@ -605,15 +619,33 @@ the `Processor::capacity` sentinel collision that reviewing the model alone had `M5+.1`'s `Processor::capacity` collision are the same defect from two sides. They land together, in `M4+.2`, and `M5+.1` records that rather than repeating the work. +- [ ] **M4+.5** -- **Verify the CPU-set flag bit positions against the SDK**, which `M4+.2`'s + measurement gave evidence are wrong. `allocated_to_this_process` reads **false for every processor** + on the development host -- for a process plainly running on them, which is not a credible answer. + `parked` reads false everywhere too, so neither has ever been observed true and nothing distinguishes + "the bit is clear" from "we are reading the wrong bit". + The positions in `cpu_set::flags` are transcribed from the SDK's bitfield order and the module's own + doc calls changing them a breaking change -- but transcription is exactly the **asserted structure** + this reshape exists to distrust, and it has never been checked against a machine where the answer + would differ. + **Until it is verified, no behaviour may depend on these flags.** That is why `M4+.2` ships the + values as facts and no judgement over them; a `usable()` helper written against them refused every + processor on this machine. + ## M5: the defects this subsumes Parked on M4, **except M5+.5, which is independent and ready now**. Each of the others already exists as a defect that the reshape is what fixes, so they are listed here rather than fixed separately and then re-fixed. -- [ ] **M5+.1** -- `Processor::capacity` uses `0` as both a legitimate efficiency class and a "not +- [x] **M5+.1** -- `Processor::capacity` uses `0` as both a legitimate efficiency class and a "not known" sentinel, and the two collide on **every non-hybrid machine**. Worse than an ambiguous `Option`: a colliding sentinel cannot be distinguished even by a careful caller. + **Subsumed by `M4+.2`, which is where the sentinel-free answer lives**: `ProcessorFacts` reports + `efficiency_class: Observed`, so class zero and "no core names this processor" are different + values. `Processor::capacity` itself is left in place and its documentation already warns against + it -- removing a published field is a second breaking change with no additional benefit once the + honest answer exists beside it. - [ ] **M5+.2** -- `DomainKind::Memory::memory_bytes` is unambiguous from `discover` but ambiguous from a **description**, where "the field was omitted" and "this node's capacity is unknown" are the diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index 25c8914c..d1a6bfc1 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -12,6 +12,7 @@ use std::collections::BTreeMap; use crate::CacheKind; use crate::observation::{Observation, Source}; +use crate::observed::Observed; use crate::processor_set::ProcessorSet; /// The identity of one logical processor: its group and its number within @@ -586,5 +587,73 @@ mod serde_impl { } } +/// Everything this crate knows about one processor, with each absence named. +/// +/// Produced by [`MachineMemoryTopology::shard_set`]. Deliberately **not** a +/// second copy of [`Processor`]: that type is the platform's own record, while +/// this is the assembled answer to "may this processor host work, and where +/// does it allocate from" -- gathered from both Win32 sources plus the derived +/// relations. +/// +/// # No sentinels, anywhere +/// +/// Every optional field is an [`Observed`], so "the platform said zero" and +/// "nobody asked" are different values rather than the same one. The field +/// this exists to replace, [`Processor::capacity`], spells three facts as `0` +/// -- offline, in no core, and efficiency class zero -- and the third is every +/// processor on every non-hybrid machine. +#[derive(Clone, Debug, PartialEq)] +pub struct ProcessorFacts<'a> { + /// The processor's identity, always `(group, number)` and never flattened + /// (D-7): a Windows affinity is a `GROUP_AFFINITY`, so a bare index names a + /// different processor in every group. + pub id: ProcessorId, + /// Whether the slot is active. An offline slot exists and counts toward its + /// group's maximum, so planning work onto one is planning a thread that + /// cannot run. + pub online: bool, + /// The core relation this processor belongs to, if any names it. + /// + /// `None` is a firmware gap the crate tolerates by design, not a + /// contradiction -- see [`Self::efficiency_class`], which is + /// [`Observed::NotObserved`] in exactly that case rather than `0`. + pub core: Option<&'a Domain>, + /// Whether the owning core has more than one logical processor. + pub simultaneous_multithreading: Observed, + /// The scheduler's efficiency class for the owning core. + /// + /// [`Observed::NotObserved`] when no core names this processor -- which is + /// the distinction `Processor::capacity` cannot make. On a hybrid part + /// Windows orders class `0` as the *least* performant, so an unknown + /// processor reported as `0` is indistinguishable from an efficiency core: + /// a policy excluding efficiency cores silently drops a possible + /// performance core, and one tiering them mis-tiers it. Neither fails a + /// functional test. + pub efficiency_class: Observed, + /// Whether the scheduler is currently avoiding this processor. + /// + /// [`Observed::NotObserved`] when the CPU-set enumeration was not + /// consulted, which is any topology not produced by + /// [`MachineMemoryTopology::discover`]. Parked is **not** offline: the + /// processor is active and the scheduler is merely avoiding it. + pub parked: Observed, + /// Whether this processor is allocated to *this* process. + /// + /// A planner ignoring it places work on processors the process may not use, + /// which is a wrong plan rather than a slow one. + /// + /// **Reads `false` for every processor on the development host**, which is + /// not obviously right for a process that is plainly running on them. The + /// CPU-set flag *bit positions* are transcribed from the SDK's bitfield + /// order and have never been verified against a machine where they differ, + /// so this may be reporting the wrong bit. Tracked as `M4+.5`; treat the + /// value as unconfirmed until it is. + pub allocated_to_this_process: Observed, + /// The memory domain this processor allocates from, or + /// [`Observed::NotObserved`] for the **unplaced** case, which has no honest + /// fallback -- see [`MachineMemoryTopology::memory_domain_of`]. + pub memory_domain: Observed<&'a Domain>, +} + #[cfg(test)] mod tests; diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 66a9d545..321bae0a 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -84,7 +84,7 @@ mod walk; #[cfg(windows)] pub use cpu_set::CpuSet; #[cfg(windows)] -pub use domain::{AttributeValue, Domain, DomainKind, Processor, ProcessorId}; +pub use domain::{AttributeValue, Domain, DomainKind, Processor, ProcessorFacts, ProcessorId}; #[cfg(windows)] pub use granularity::{Granularity, Proximity}; #[cfg(windows)] diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index fd5cd3ea..1d3fe1c3 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -5,8 +5,9 @@ use std::collections::BTreeMap; use std::io; use crate::cpu_set::CpuSet; -use crate::domain::{Domain, DomainKind, Processor, ProcessorId}; +use crate::domain::{Domain, DomainKind, Processor, ProcessorFacts, ProcessorId}; use crate::observation::{AttributeObservation, Observation, ProcessorAttribute, Source}; +use crate::observed::Observed; use crate::processor_set::ProcessorSet; use crate::provenance::Provenance; use crate::relation::{self, Relations}; @@ -635,6 +636,105 @@ impl MachineMemoryTopology { }) } + /// Everything this crate knows about one processor, without sentinels. + /// + /// The shard-set surface `M4+.2` calls for: what a caller needs to decide + /// whether a processor may host work, gathered so the answer is asked for + /// once rather than assembled differently by each consumer. + /// + /// Every field says which absence it means (D-13), and **none uses a + /// sentinel**. That is the point rather than a nicety: + /// [`Processor::capacity`] spells "offline", "in no core", and "efficiency + /// class zero" as the same `0`, and the third is every processor on every + /// non-hybrid machine -- so the stand-in collides with the overwhelmingly + /// common real value and no careful caller can tell them apart. + #[must_use] + pub fn shard_set(&self) -> Vec> { + self.processors + .iter() + .map(|processor| { + let core = self.cores().find(|d| { + d.processors + .contains(processor.id.group, processor.id.number) + }); + let cpu_set = self.cpu_sets.as_ref().and_then(|sets| { + sets.iter().find(|s| { + s.group == processor.id.group + && s.logical_processor_index == processor.id.number + }) + }); + ProcessorFacts { + id: processor.id, + online: processor.online, + core, + simultaneous_multithreading: match core.map(|d| &d.kind) { + Some(DomainKind::Core { + simultaneous_multithreading, + .. + }) => Observed::Known(*simultaneous_multithreading), + _ => Observed::NotObserved, + }, + efficiency_class: match core.map(|d| &d.kind) { + Some(DomainKind::Core { + efficiency_class, .. + }) => Observed::Known(*efficiency_class), + _ => Observed::NotObserved, + }, + parked: cpu_set.map_or(Observed::NotObserved, |s| Observed::Known(s.parked)), + allocated_to_this_process: cpu_set.map_or(Observed::NotObserved, |s| { + Observed::Known(s.allocated_to_target_process) + }), + memory_domain: self.memory_domain_of(processor.id), + } + }) + .collect() + } + + /// Which memory domain `processor` allocates from. + /// + /// [`Observed::Known`] with the domain, or [`Observed::NotObserved`] when + /// no memory domain names it -- the **unplaced** case, which is deliberately + /// not collapsed into "node 0". + /// + /// # Why the unplaced case gets its own answer + /// + /// An unknown *cache* domain costs an optimisation. An unknown *memory* + /// domain has no honest fallback at all: the pool has to be allocated + /// somewhere, and guessing means quietly allocating remote memory for the + /// life of the process. `windows-placement-probe` already encodes this + /// asymmetry -- it refuses a missing NUMA node while tolerating a missing + /// cache domain -- and this method is where that distinction becomes the + /// model's rather than each consumer's. + /// + /// [`Observed::Absent`] is never returned: a memory domain covering no + /// processors is a real shape (D-5), but "this processor belongs to no + /// node" is a gap in what the firmware said, not a positive statement that + /// it has no memory. + #[must_use] + pub fn memory_domain_of(&self, processor: ProcessorId) -> Observed<&Domain> { + self.memory_domains() + .find(|domain| { + domain + .processors + .contains(processor.group, processor.number) + }) + .map_or(Observed::NotObserved, Observed::Known) + } + + /// Every processor no memory domain names. + /// + /// The set a caller must decide about before allocating anything, and + /// empty on a machine whose firmware covered every processor. Offered so + /// the question is asked once rather than rediscovered per allocation site. + #[must_use] + pub fn unplaced_processors(&self) -> Vec { + self.processors + .iter() + .filter(|processor| !self.memory_domain_of(processor.id).was_observed()) + .map(|processor| processor.id) + .collect() + } + /// Every memory domain, including one with no processors (D-5). pub fn memory_domains(&self) -> impl Iterator { self.domains diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 1c71c0cd..4508e1c6 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -1128,6 +1128,187 @@ fn a_split_instruction_and_data_cache_is_one_partition_not_two() { assert_eq!(topo.cache_partitions_at_level(1).len(), 8); } +// --- M4+.3: residency, with the unplaced case distinguishable --- + +#[test] +fn a_processor_in_a_memory_domain_reports_it() { + let mut node = core_domain_at(0, &[0, 1]); + node.kind = DomainKind::Memory { memory_bytes: None }; + let topo = machine_of(4, vec![node]); + + assert!( + topo.memory_domain_of(ProcessorId { + group: 0, + number: 0 + }) + .was_observed() + ); +} + +#[test] +fn an_unplaced_processor_is_not_observed_rather_than_node_zero() { + // The asymmetry `windows-placement-probe` already encodes: an unknown cache + // domain costs an optimisation, an unknown memory domain has no honest + // fallback, because the pool must be allocated somewhere and guessing means + // quietly allocating remote memory for the life of the process. + let mut node = core_domain_at(0, &[0, 1]); + node.kind = DomainKind::Memory { memory_bytes: None }; + let topo = machine_of(4, vec![node]); + + let unplaced = topo.memory_domain_of(ProcessorId { + group: 0, + number: 3, + }); + assert!(!unplaced.was_observed()); + assert_eq!(unplaced.known(), None); + assert_eq!( + topo.unplaced_processors(), + vec![ + ProcessorId { + group: 0, + number: 2 + }, + ProcessorId { + group: 0, + number: 3 + } + ] + ); +} + +#[test] +fn a_fully_covered_machine_has_no_unplaced_processors() { + let mut node = core_domain_at(0, &[0, 1, 2, 3]); + node.kind = DomainKind::Memory { memory_bytes: None }; + assert!(machine_of(4, vec![node]).unplaced_processors().is_empty()); +} + +// --- M4+.2: the shard-set surface, without sentinels --- + +#[test] +fn an_unknown_efficiency_class_is_not_observed_rather_than_zero() { + // The M5+.1 collision, from the other side. `Processor::capacity` spells + // "in no core" and "class zero" as the same 0, and class zero is every + // processor on every non-hybrid machine -- so the sentinel collides with + // the overwhelmingly common real value. + let topo = machine_of(2, Vec::new()); + let facts = topo.shard_set(); + + assert_eq!(facts.len(), 2); + assert_eq!(facts[0].efficiency_class, Observed::NotObserved); + assert!(facts[0].core.is_none()); + assert_eq!(facts[0].simultaneous_multithreading, Observed::NotObserved); +} + +#[test] +fn a_genuine_class_zero_is_known_not_missing() { + let topo = machine_of(2, vec![core_domain_at(0, &[0, 1])]); + let facts = topo.shard_set(); + + assert_eq!(facts[0].efficiency_class, Observed::Known(0)); + assert!( + facts[0].efficiency_class.was_observed(), + "class zero is an answer, not an absence" + ); + assert_eq!( + facts[0].simultaneous_multithreading, + Observed::Known(true), + "two processors in the core" + ); +} + +#[test] +fn availability_is_not_observed_when_cpu_sets_was_never_consulted() { + // A hand-built topology has no CPU-set records, so parked and allocation + // state are gaps in what we asked -- not claims that the processor is + // available or unavailable. + let topo = machine_of(2, vec![core_domain_at(0, &[0, 1])]); + let facts = topo.shard_set(); + + assert_eq!(facts[0].parked, Observed::NotObserved); + assert_eq!(facts[0].allocated_to_this_process, Observed::NotObserved); +} + +#[test] +fn the_shard_set_states_availability_and_does_not_judge_it() { + // There is deliberately no `usable()` helper. Which of online, parked and + // allocation disqualifies a processor is a POLICY, and per D-21 this crate + // states facts -- baking the judgement in here is exactly what + // `outermost_partitioning_cache` was criticised for. + // + // It would also have been wrong: `allocated_to_this_process` reads false + // for every processor on the development host, so a `usable()` that + // refused on it refused the whole machine. + let mut topo = machine_of(2, vec![core_domain_at(0, &[0, 1])]); + topo.processors[1].online = false; + let facts = topo.shard_set(); + + assert!(facts[0].online); + assert!(!facts[1].online); + + let mut parked = machine_of(1, Vec::new()); + parked.cpu_sets = Some(vec![cpu_set_parked(0)]); + assert_eq!( + parked.shard_set()[0].parked, + Observed::Known(true), + "parked is reported, and is not the same as offline" + ); + assert!( + parked.shard_set()[0].online, + "a parked processor is active; the scheduler is merely avoiding it" + ); +} + +#[test] +fn the_shard_set_reads_availability_from_the_cpu_sets_when_present() { + let mut topo = machine_of(1, Vec::new()); + topo.cpu_sets = Some(vec![cpu_set(0, 0, 0, 0)]); + let facts = topo.shard_set(); + + assert_eq!(facts[0].parked, Observed::Known(false)); + assert_eq!(facts[0].allocated_to_this_process, Observed::Known(true)); +} + +/// A core relation over `numbers`, labelled by the relationship walk. +fn core_domain_at(label: u32, numbers: &[u8]) -> Domain { + let mut processors = ProcessorSet::empty(); + for &n in numbers { + processors.insert(0, n); + } + Domain { + kind: DomainKind::Core { + simultaneous_multithreading: numbers.len() > 1, + efficiency_class: 0, + }, + processors, + observations: vec![Observation::new(Source::RelationshipWalk, label)], + } +} + +fn cpu_set(index: u8, core: u8, node: u8, efficiency_class: u8) -> crate::cpu_set::CpuSet { + crate::cpu_set::CpuSet { + id: u32::from(index), + group: 0, + logical_processor_index: index, + core_index: core, + last_level_cache_index: 0, + numa_node_index: node, + efficiency_class, + parked: false, + allocated: true, + allocated_to_target_process: true, + real_time: false, + scheduling_class: 0, + allocation_tag: 0, + } +} + +fn cpu_set_parked(index: u8) -> crate::cpu_set::CpuSet { + crate::cpu_set::CpuSet { + parked: true, + ..cpu_set(index, 0, 0, 0) + } +} // --- M4+.4: "outermost" is inclusion, not the level number --- /// A cache relation over `numbers`, labelled by the relationship walk. From 997eb185f22de415a33155e78d57acb760490a51 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 15:52:27 -0700 Subject: [PATCH 275/361] docs(topology): the CPU-set AllFlags byte is not populated on this build (D-23) M4+.5, and the answer is neither of the two the item expected. The bit positions are not wrong -- and cannot be shown right either. The whole AllFlags byte reads 0x00 for every processor, so no bit has ever been observed set and nothing distinguishes a correct transcription from a wrong one. They stand on the SDK's declared bitfield order alone, and only a machine that populates the byte could finish the job. The byte is NOT POPULATED on this build, which is the actual finding, and it was established by experiment rather than argued: SetProcessDefaultCpuSets succeeded, GetProcessDefaultCpuSets confirmed the allocation stuck ([0x100, 0x101]), and AllFlags still read zero -- under a null process handle, the GetCurrentProcess pseudo-handle, and a real OpenProcess handle alike. Windows 11 25H2 (10.0.26200.9168, AMD64). AND THE FIELD'S OWN DOCUMENTATION WAS WRONG, which the experiment exposed on the way. `allocated_to_target_process` does not mean "may we run here": it means the CPU set was explicitly allocated through SetProcessDefaultCpuSets, which an ordinary process never calls -- so `false` is the ordinary answer for a processor it is entirely free to run on. The module doc claimed the opposite, and that claim is what made the false readings look alarming in the first place. So the earlier suspicion was half right for the wrong reason: the values are indeed unusable, but because the kernel did not write them, not because we read the wrong bit. Removing usable() was correct either way -- no behaviour may depend on a byte nobody wrote. A regression test pins the measurement so a build that DOES populate the byte is noticed rather than silently changing what these fields mean. Its failure message says what to do: verify the bit positions, which becomes possible for the first time on such a machine. M4 is complete, 5 of 5. Completed item: M4+.5: Verify the CPU-set flag bit positions against the SDK Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 21 +++++++- crates/windows-topology-sys/DESIGN-NOTES.md | 1 + crates/windows-topology-sys/src/cpu_set.rs | 52 ++++++++++++++++--- .../windows-topology-sys/src/cpu_set/tests.rs | 29 +++++++++++ crates/windows-topology-sys/src/domain.rs | 14 ++--- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 0e1a72fb..25ee9361 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -37,7 +37,7 @@ the **adapter's** problem and must not be filed here as a gap. | M1 settle what is still open | **5 of 5 done** | nothing -- complete | | M2 the granularity model | **6 of 6 done** | nothing -- complete | | M3 observation and provenance | **4 of 4 done** | nothing -- complete | -| M4 the queries | **4 of 5 done** | `M4+.5`, which the work itself uncovered | +| M4 the queries | **5 of 5 done** | nothing -- complete | | M5 the defects this subsumes | parked | M4, **except M5+.5 (done)** | **M1 was decision work, not implementation**, and it is complete. Each item was a question the @@ -619,7 +619,7 @@ the `Processor::capacity` sentinel collision that reviewing the model alone had `M5+.1`'s `Processor::capacity` collision are the same defect from two sides. They land together, in `M4+.2`, and `M5+.1` records that rather than repeating the work. -- [ ] **M4+.5** -- **Verify the CPU-set flag bit positions against the SDK**, which `M4+.2`'s +- [x] **M4+.5** -- **Verify the CPU-set flag bit positions against the SDK**, which `M4+.2`'s measurement gave evidence are wrong. `allocated_to_this_process` reads **false for every processor** on the development host -- for a process plainly running on them, which is not a credible answer. `parked` reads false everywhere too, so neither has ever been observed true and nothing distinguishes @@ -631,6 +631,23 @@ the `Processor::capacity` sentinel collision that reviewing the model alone had **Until it is verified, no behaviour may depend on these flags.** That is why `M4+.2` ships the values as facts and no judgement over them; a `usable()` helper written against them refused every processor on this machine. + **Investigated, and the answer is neither of the two the item expected.** Recorded as + [D-23](DESIGN-NOTES.md#d-23). + The bit positions are **not** wrong -- and cannot be shown right either. The whole `AllFlags` byte + reads `0x00` for every processor, so no bit has ever been observed set and nothing distinguishes a + correct transcription from a wrong one. They stand on the SDK's declared order alone. + The byte is **not populated on this build**, which is the actual finding and was established by + experiment rather than argued: `SetProcessDefaultCpuSets` succeeded, `GetProcessDefaultCpuSets` + confirmed the allocation stuck (`[0x100, 0x101]`), and the byte still read zero -- under a null + handle, the pseudo-handle, and a real `OpenProcess` handle alike. Windows 11 25H2 + (10.0.26200.9168, AMD64). + **And the field's own documentation was wrong**, which the experiment exposed on the way: + `allocated_to_target_process` does not mean "may we run here". It means the CPU set was explicitly + allocated via `SetProcessDefaultCpuSets`, which an ordinary process never does -- so `false` is the + ordinary answer for a processor it is entirely free to run on. The old doc said the opposite. + A regression test pins the measurement, so a build that *does* populate the byte is noticed rather + than silently changing what these fields mean. That build is also the only thing that could finish + verifying the bit positions. ## M5: the defects this subsumes diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 3f9b38d6..21c7df0e 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -42,6 +42,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-20 | **This crate does not go below the Win32 topology APIs, so if they do not report a fact, the crate does not have it -- and `distances` is therefore deleted rather than filled.** The engineer's ruling, and it is a **scope boundary** rather than a judgement about the field: ACPI carries SLIT, no Win32 API surfaces it, and reading firmware directly would be going below the boundary. Two supporting findings, neither of which is the reason: `distances` could never carry `Measured` provenance **by construction** (its only inputs are hand-construction, which is `Synthetic`, and deserialization, which per [D-12](#d-12) can only downgrade), and it has **zero read sites** -- `windows-platform-probes`' `render_node_distances` reads the probe's own measured `Observation`, not this field. What is lost is named rather than skated past: [D-10](#d-10)'s platform-neutral description can no longer carry Linux SLIT data. That capability was real, and it is given up because the two-component split routes distance through the synthesizer's *measurement*, which a fed-in description cannot substitute for. | | D-21 | **This crate publishes a *refined view of what the platform publishes* -- it is not shaped by the planner.** The model was originally expected to couple tightly to the solver, which is why its reshape was planned against the planner's requirements; the **adapter** between the platform data model and the planner relieves that tension, and the engineer's clarification makes the refinement the crate's whole job. The scope test is therefore "is this a refinement of what Windows reports?", never "does the planner need it?" -- and a planner requirement with **no platform correspondence is the adapter's problem**, not a gap here. Two consequences: the reshape (M2-M5) is **self-justified** and no longer waits on a planner, and `MMT-1.3` stops gating it, because what a consumer *does* with an unobserved fact is not a question about a refined view of platform data. The model owes only that the absence be representable and distinguishable, which is [D-13](#d-13) and `M2+.5`. `EP-D-1`..`EP-D-3` survive as **evidence** the shape is right rather than as its justification -- a shape that answers a real caller's questions is better validated than one invented in the abstract. | | D-22 | **The whole-object [`Provenance`] survives per-relation provenance, because it is not an aggregate of it.** `M3+.3` planned to supersede it, arguing that an object-level scalar "can only be the minimum ... or the maximum, which is dishonest". That premise is wrong: `Provenance` records **how the object was obtained** -- `discover()` stamps `Measured`, deserialization is capped at `Restored`, hand construction defaults to `Synthetic` -- which is a fact about the *construction act*, not a roll-up of anything. No per-relation value can express it, and `windows-placement-probe` depends on exactly it: `Record::is_trustworthy` gates on `is_measured()` to decide whether a measurement counts. The two are **orthogonal and both kept**: the object says how the collection happened, a relation says which source reported it. They also compose usefully -- a `Measured` topology with a hand-inserted `Synthetic` relation is precisely the mixed case `M3+.3` was groping at, and per-relation provenance is what makes it visible rather than a reason to delete the object-level fact. | +| D-23 | **`SYSTEM_CPU_SET_INFORMATION`'s `AllFlags` byte is measured to be **constant zero** on Windows 11 25H2 (10.0.26200.9168, AMD64), even in the state it is documented to describe.** Established by experiment, not inference: `SetProcessDefaultCpuSets` was called successfully, `GetProcessDefaultCpuSets` confirmed the allocation stuck (`[0x100, 0x101]`), and `AllFlags` still read `0x00` for **every** processor -- under a `NULL` process handle, the `GetCurrentProcess()` pseudo-handle, and a real `OpenProcess` handle alike. So `parked`, `allocated`, `allocated_to_target_process` and `real_time` carry **no information** on this build, and a consumer reading `false` is reading a byte the kernel did not populate rather than a fact about the machine. Two consequences: the bit *positions* can be neither confirmed nor falsified from an all-zero byte, so they stand on the SDK's declared bitfield order alone; and **no behaviour may depend on these fields** -- which is why `M4+.2` ships them as values with no judgement over them, after a `usable()` helper written against them refused every processor on this machine. | ## D-12: provenance, and why the default points at distrust diff --git a/crates/windows-topology-sys/src/cpu_set.rs b/crates/windows-topology-sys/src/cpu_set.rs index e1b8fb25..0be0d9b3 100644 --- a/crates/windows-topology-sys/src/cpu_set.rs +++ b/crates/windows-topology-sys/src/cpu_set.rs @@ -69,14 +69,42 @@ pub struct CpuSet { /// both "class zero" and "not known". pub efficiency_class: u8, /// The processor is parked, so the scheduler is currently avoiding it. + /// + /// **Measured to carry no information on Windows 11 25H2** -- see + /// [`Self::allocated_to_target_process`] and D-23 in `DESIGN-NOTES.md`. pub parked: bool, - /// The processor is allocated. + /// The processor is allocated to some process through the CPU-set API. + /// + /// **Measured to carry no information on Windows 11 25H2** -- see + /// [`Self::allocated_to_target_process`]. pub allocated: bool, - /// The processor is allocated **to this process**. A planner that ignores - /// this places work on processors the process may not use, which is a wrong - /// plan rather than a slow one. + /// The processor is allocated **to this process** through the CPU-set API. + /// + /// # This is not "may we run here", and it is not populated + /// + /// Two corrections, both established by experiment rather than reasoning + /// (D-23 in `DESIGN-NOTES.md`). + /// + /// It does not mean the process may use the processor. It means the CPU set + /// was explicitly allocated through `SetProcessDefaultCpuSets` or + /// `SetThreadSelectedCpuSets`, which a process that never called them has + /// not done -- so `false` is the ordinary answer for an ordinary process on + /// every processor it is perfectly free to run on. + /// + /// And on Windows 11 25H2 (10.0.26200.9168, AMD64) it is not populated at + /// all. Calling `SetProcessDefaultCpuSets` successfully, and confirming + /// with `GetProcessDefaultCpuSets` that the allocation stuck, still leaves + /// the whole `AllFlags` byte reading `0x00` for every processor -- under a + /// null handle, the current-process pseudo-handle, and a real `OpenProcess` + /// handle alike. + /// + /// **So do not branch on this.** A reader of `false` is reading a byte the + /// kernel did not write, not a fact about the machine. pub allocated_to_target_process: bool, /// The processor is marked real-time. + /// + /// **Measured to carry no information on Windows 11 25H2** -- see + /// [`Self::allocated_to_target_process`]. pub real_time: bool, /// The scheduling class, which shares its union with a reserved `u32`, so /// confirm its meaning against current SDK documentation before relying on @@ -90,6 +118,16 @@ pub struct CpuSet { /// /// Changing any value is a breaking change: these mirror the SDK's bitfield /// order, which is part of the ABI rather than this crate's choice. +/// +/// # These are unverified, and cannot be verified from this machine +/// +/// `AllFlags` reads `0x00` for every processor on the build measured (D-23), +/// even after allocating CPU sets to this process -- so no bit has ever been +/// observed set, and nothing here distinguishes a correct transcription from a +/// wrong one. They stand on the SDK's declared order alone. +/// +/// That is survivable only because nothing branches on them. If a consumer ever +/// does, these need a machine that populates the byte first. mod flags { pub(super) const PARKED: u8 = 1 << 0; pub(super) const ALLOCATED: u8 = 1 << 1; @@ -99,9 +137,9 @@ mod flags { /// Enumerate the CPU sets the current process can see. /// -/// Passing a null process handle asks about the calling process, so -/// `allocated_to_target_process` answers "may *we* use it" rather than "does it -/// exist". +/// A null process handle asks about the calling process. It makes **no +/// difference to the flags** on the build this was measured against -- see +/// [`CpuSet::allocated_to_target_process`] and D-23 in `DESIGN-NOTES.md`. /// /// # Errors /// diff --git a/crates/windows-topology-sys/src/cpu_set/tests.rs b/crates/windows-topology-sys/src/cpu_set/tests.rs index ec6a84da..35bfdfb1 100644 --- a/crates/windows-topology-sys/src/cpu_set/tests.rs +++ b/crates/windows-topology-sys/src/cpu_set/tests.rs @@ -231,3 +231,32 @@ fn windows_llc_grouping_is_not_the_derived_partitioning_cache() { llc.len() ); } + +/// D-23: the `AllFlags` byte is measured constant zero on this build. +/// +/// Not asserted as a property of Windows -- it is a property of the machine the +/// suite runs on, and the point is to **notice if it changes**. A build that +/// populates the byte would make the availability fields meaningful and would +/// let `M4+.5` finally verify the bit positions, which cannot be done while +/// every bit reads zero. +#[test] +fn the_availability_flags_are_all_clear_on_this_host() { + let Ok(sets) = super::enumerate() else { + return; + }; + if sets.is_empty() { + return; + } + + let any_set = sets + .iter() + .any(|s| s.parked || s.allocated || s.allocated_to_target_process || s.real_time); + assert!( + !any_set, + "a flag is populated on this build -- D-23 no longer holds, and the bit \ + positions in `flags` can now be verified rather than assumed: {:?}", + sets.iter() + .filter(|s| s.parked || s.allocated || s.allocated_to_target_process || s.real_time) + .collect::>() + ); +} diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index d1a6bfc1..7bdc2cf2 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -642,12 +642,14 @@ pub struct ProcessorFacts<'a> { /// A planner ignoring it places work on processors the process may not use, /// which is a wrong plan rather than a slow one. /// - /// **Reads `false` for every processor on the development host**, which is - /// not obviously right for a process that is plainly running on them. The - /// CPU-set flag *bit positions* are transcribed from the SDK's bitfield - /// order and have never been verified against a machine where they differ, - /// so this may be reporting the wrong bit. Tracked as `M4+.5`; treat the - /// value as unconfirmed until it is. + /// **Measured to carry no information on Windows 11 25H2** (D-23): the + /// whole `AllFlags` byte reads `0x00` for every processor even after CPU + /// sets are successfully allocated to this process, so `Known(false)` here + /// reports a byte the kernel did not write. + /// + /// It also does not mean what its name suggests -- allocation is the + /// explicit `SetProcessDefaultCpuSets` kind, not "may we run here". Do not + /// branch on it; see [`CpuSet::allocated_to_target_process`]. pub allocated_to_this_process: Observed, /// The memory domain this processor allocates from, or /// [`Observed::NotObserved`] for the **unplaced** case, which has no honest From 26a38c099d94322657f2e3b9db60eaea6d4bf097 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 16:18:26 -0700 Subject: [PATCH 276/361] refactor(topology): the partitioning rule has one implementation (M5+.3) windows-platform-probes restated the rule as `filter(domains > 1).max_by_key(level)`. By the time M4+.4 landed that restatement differed from the crate's own answer in TWO ways, not the one the item recorded: it omitted the pairwise-disjointness check, and it ordered candidates by level number, which the topology crate stopped doing because a higher number is not always coarser. Observation now captures partitioning_cache_level from MachineMemoryTopology::outermost_partitioning_cache at survey time and looks the summary up. SH-16.9 records this rule going wrong three times in two crates; there is now one implementation. AN EXISTING PLATFORM-PROBES TEST CAUGHT A REAL REGRESSION IN M4+.4, and it was right to. It asserts nothing deeper may also partition. On this host L1 and L2 split the machine into the SAME eight pairs, so neither refines the other, and M4+.4's first-wins tie-break answered L1 -- naming the inner cache for a boundary the outer one also owns. Ties between IDENTICAL partitions now take the higher level. That is not the level ordering M2+.2 forbids: distinct partitions are still ordered by inclusion, and level decides only which of two names for one boundary is the outer. Both the tie case and the discriminating case now have tests. Worth noting the shape of this: my synthetic M4+.4 tests all used partitions that differed, so none of them could see the tie. The case that mattered was the one the real machine has. Completed item: M5+.3: The partitioning rule is stated three times in two crates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-platform-probes/src/tests.rs | 50 +++++++++++++++++++ .../windows-platform-probes/src/topology.rs | 37 ++++++++++---- crates/windows-topology-sys/CHECKLIST.md | 11 +++- crates/windows-topology-sys/src/topology.rs | 15 ++++-- .../src/topology/tests.rs | 27 ++++++++++ 5 files changed, 126 insertions(+), 14 deletions(-) diff --git a/crates/windows-platform-probes/src/tests.rs b/crates/windows-platform-probes/src/tests.rs index 515df7a1..8740f0ee 100644 --- a/crates/windows-platform-probes/src/tests.rs +++ b/crates/windows-platform-probes/src/tests.rs @@ -753,6 +753,7 @@ fn agreeing_observation() -> crate::topology::Observation { packages: 1, cores: Vec::new(), caches: Vec::new(), + partitioning_cache_level: None, raw_active_processors: 4, raw_group_count: 1, raw_highest_numa_node: Some(0), @@ -885,3 +886,52 @@ fn every_policy_would_produce_at_least_one_domain() { ); } } + +// --- M5+.3: the partitioning rule has one implementation --- + +#[test] +fn the_survey_reports_the_topology_crates_partitioning_level_not_its_own() { + // The restatement this removes differed from the crate's answer in two + // ways: it omitted the pairwise-disjointness check, and it ordered + // candidates by LEVEL NUMBER, which the topology crate stopped doing + // because a higher number is not always coarser. + // + // Here the higher level is the finer partition, so the two rules disagree: + // the old `max_by_key(level)` would answer L3, and asking the crate answers + // L2. The survey must report what the crate says. + let mut observation = agreeing_observation(); + observation.caches = vec![ + crate::topology::CacheLevel { + level: 2, + domains: 2, + processors_per_domain: vec![2, 2], + }, + crate::topology::CacheLevel { + level: 3, + domains: 4, + processors_per_domain: vec![1, 1, 1, 1], + }, + ]; + observation.partitioning_cache_level = Some(2); + + assert_eq!( + observation + .outermost_partitioning_cache() + .map(|c| c.level), + Some(2), + "the survey must not re-derive; it looks up what the crate decided" + ); +} + +#[test] +fn no_partitioning_level_is_a_real_answer_in_the_survey_too() { + let mut observation = agreeing_observation(); + observation.caches = vec![crate::topology::CacheLevel { + level: 3, + domains: 1, + processors_per_domain: vec![4], + }]; + observation.partitioning_cache_level = None; + + assert!(observation.outermost_partitioning_cache().is_none()); +} \ No newline at end of file diff --git a/crates/windows-platform-probes/src/topology.rs b/crates/windows-platform-probes/src/topology.rs index 62c9c485..d76293b6 100644 --- a/crates/windows-platform-probes/src/topology.rs +++ b/crates/windows-platform-probes/src/topology.rs @@ -101,6 +101,14 @@ pub struct Observation { pub cores: Vec, /// Cache levels, ascending, each summarised across the machine. pub caches: Vec, + /// Which of [`Self::caches`] the topology crate says actually partitions + /// the machine, if any. + /// + /// Captured from `MachineMemoryTopology::outermost_partitioning_cache` + /// rather than derived from the summaries above, so the rule has one + /// implementation (`SH-16.9`). `None` is a real answer -- no reported level + /// divides this machine -- and not a failure. + pub partitioning_cache_level: Option, // --- read independently through Win32 --- /// `GetActiveProcessorCount(ALL_PROCESSOR_GROUPS)`. @@ -115,17 +123,23 @@ impl Observation { /// The outermost cache level that actually splits the machine into more /// than one domain, if any. /// - /// This is the rule the design wants, and it is deliberately *not* "level - /// 3". A shipping ARM64 laptop measured during the 2026-08-30 session - /// reports **no L3 at all**, with two L2 domains of six processors forming - /// the real cluster boundary, which is why the heuristic is phrased over - /// "the outermost level that partitions" rather than over a fixed number. + /// **Asked of `windows-topology-sys`, not re-derived here.** This method + /// used to restate the rule as "the highest level with more than one + /// domain", and by the time `M4+.4` landed that restatement differed from + /// the crate's own answer in two ways: it omitted the pairwise-disjointness + /// check, so a hand-built topology with overlapping blocks would have been + /// accepted, and it ordered candidates by **level number**, which the + /// topology crate stopped doing because a higher number is not always + /// coarser -- the ARM64 machine with no L3 is the standing counterexample. + /// + /// So the level is now captured at survey time from + /// `MachineMemoryTopology::outermost_partitioning_cache`, and this method + /// only looks up the summary for it. `SH-16.9` records this rule going + /// wrong three times in two crates; there is now one implementation. #[must_use] pub fn outermost_partitioning_cache(&self) -> Option<&CacheLevel> { - self.caches - .iter() - .filter(|c| c.domains > 1) - .max_by_key(|c| c.level) + let level = self.partitioning_cache_level?; + self.caches.iter().find(|c| c.level == level) } /// How many execution domains each candidate policy would produce. @@ -288,6 +302,10 @@ pub fn measure() -> io::Result { None }; + // Asked once, here, rather than restated: the crate that owns the topology + // owns the rule (D-21). + let partitioning_cache_level = topology.outermost_partitioning_cache().map(|(level, _)| level); + Ok(Observation { online_processors, groups, @@ -297,6 +315,7 @@ pub fn measure() -> io::Result { packages, cores, caches, + partitioning_cache_level, raw_active_processors, raw_group_count, raw_highest_numa_node, diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 25ee9361..0e5f6824 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -668,9 +668,18 @@ separately and then re-fixed. from a **description**, where "the field was omitted" and "this node's capacity is unknown" are the same value. The [D-13](DESIGN-NOTES.md#d-13) audit found this and documentation cannot fix it. -- [ ] **M5+.3** -- The partitioning rule is stated **three times in two crates**, and two of the +- [x] **M5+.3** -- The partitioning rule is stated **three times in two crates**, and two of the three differ: `windows-platform-probes` omits the pairwise-disjointness check this crate requires. M4+.4 removes the reason to restate it. + **Done, and by M4+.4 the restatement had drifted in a second way**: it also ordered candidates by + **level number**, which this crate stopped doing. `Observation` now captures + `partitioning_cache_level` from `MachineMemoryTopology::outermost_partitioning_cache` at survey + time and looks the summary up, so there is one implementation of the rule. + **An existing platform-probes test then caught a real regression in M4+.4** -- and it was right to. + On this host L1 and L2 split the machine into the **same** eight pairs, so neither refines the + other and the first-wins tie-break answered `L1`, naming the inner cache for a boundary the outer + one also owns. Ties between *identical* partitions now take the higher level, which reads the + source's own labelling of one boundary rather than ordering distinct partitions by number. - [ ] **M5+.4** -- `windows-placement-probe` **refuses a partially-covering cache level** that this crate deliberately hands back, failing an entire measurement run over a topology this crate diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 1d3fe1c3..64893106 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -591,16 +591,23 @@ impl MachineMemoryTopology { .collect(); // Coarsest by inclusion: the candidate no other candidate is coarser - // than. Ties -- two candidates refining each other, which means equal - // block memberships under different levels -- keep the first, matching - // `cache_partitions_at_level`'s own first-wins rule. + // than. Where two candidates describe the *same* partition -- identical + // blocks under different levels, which is the ordinary case for an L1 + // and L2 that split a machine the same way -- neither refines the other, + // so the tie is broken by taking the **higher level**. + // + // That tie-break reads the source's own labelling of one boundary and is + // not the level ordering `M2+.2` forbids: distinct partitions are still + // ordered by inclusion, and level decides only which of two names for + // the identical partition is the outer one. candidates .iter() - .find(|candidate| { + .filter(|candidate| { !candidates .iter() .any(|other| Self::refines(&candidate.1, &other.1)) }) + .max_by_key(|(level, _)| *level) .map(|(level, blocks)| (*level, blocks.clone())) } diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 4508e1c6..e9ffb634 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -1378,6 +1378,33 @@ fn the_outermost_partition_is_the_coarsest_one_not_the_highest_level() { assert_eq!(blocks.len(), 2); } +#[test] +fn identical_partitions_under_two_levels_report_the_outer_one() { + // The ordinary shape on this host: L1 and L2 split the machine into the + // same eight pairs. Neither refines the other, so inclusion cannot separate + // them -- and answering "L1" would name the inner cache for a boundary the + // outer one also owns. + // + // Caught by an existing platform-probes test, which asserted that nothing + // deeper may also partition. That test was right. + let topo = machine_of( + 8, + vec![ + cache_at(1, 0, &[0, 1]), + cache_at(1, 1, &[2, 3]), + cache_at(1, 2, &[4, 5]), + cache_at(1, 3, &[6, 7]), + cache_at(2, 4, &[0, 1]), + cache_at(2, 5, &[2, 3]), + cache_at(2, 6, &[4, 5]), + cache_at(2, 7, &[6, 7]), + ], + ); + + let (level, blocks) = topo.outermost_partitioning_cache().expect("both partition"); + assert_eq!(level, 2, "the outer name for one boundary"); + assert_eq!(blocks.len(), 4); +} #[test] fn the_usual_ordering_is_unchanged_where_the_two_rules_agree() { // Ordinary hardware: the higher level is also the coarser one, so the From 0300f3971a987517c335559e80aa12a8d7b9d50d Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 19:31:10 -0400 Subject: [PATCH 277/361] feat(topology)!: adopt Observed for memory capacity and cache placement (M5+.2, M5+.4) Two fields carried an ambiguity that documentation could not fix, and both are fixed the same way -- by giving the field the vocabulary M2+.5 built. `DomainKind::Memory::memory_bytes` becomes `Observed`. From `discover` it was never ambiguous, but from a *description* "the field was omitted" and "this node's capacity is unknown" were the same value. Serde now carries all three states as different bytes: a number for `Known`, an explicit `null` for `Absent`, and omission for `NotObserved`. `ProcessorPlace::cache_domain` becomes `Observed` and the `MissingPlacement::CacheDomain` refusal is deleted. This crate deliberately hands back a cache level that partitions the machine without naming every processor; `windows-placement-probe` was failing an entire measurement run over one. An uncovered processor now reports `NotObserved`, which is neither `Absent` (no level partitions this machine -- the ordinary single-domain case) nor a fabricated domain. The hazard the refusal existed to prevent is closed one level down instead: `Slice::same_cache_domain` answers `None` when any participant is `NotObserved`, so two uncovered processors are never reported as sharing a cache. Verified by sabotage -- deleting that guard turns the answer into `Some(true)` and exactly one test goes red. The two items land together because they share test fixtures in `windows-placement-probe`: M5+.2's type change rewrites the same `DomainKind::Memory` literals that M5+.4's new coverage tests construct. Separating them would not produce a commit that compiles. Breaking on both counts, hence `!`. M5 is now 5 of 5, which completes the MachineMemoryTopology reshape. Completed items: M5+.2, M5+.4 Completed item: M5+.2: `DomainKind::Memory::memory_bytes` is unambiguous from `discover` but ambiguous from a description, where "the field was omitted" and "this node's capacity is unknown" are the same value. Completed item: M5+.4: `windows-placement-probe` refuses a partially-covering cache level that this crate deliberately hands back, failing an entire measurement run over a topology this crate considers describable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/core_affinity/tests.rs | 136 +++++++++----- .../src/fingerprint.rs | 51 +++-- .../src/fingerprint/tests.rs | 174 ++++++++++++++++-- .../src/bin/core_affinity.rs | 9 +- crates/windows-platform-probes/src/tests.rs | 6 +- .../windows-platform-probes/src/topology.rs | 4 +- crates/windows-topology-sys/CHECKLIST.md | 17 +- crates/windows-topology-sys/src/domain.rs | 51 +++-- .../windows-topology-sys/src/domain/tests.rs | 99 ++++++++-- .../src/granularity/tests.rs | 9 +- crates/windows-topology-sys/src/topology.rs | 8 +- .../src/topology/tests.rs | 20 +- 12 files changed, 472 insertions(+), 112 deletions(-) diff --git a/crates/windows-placement-probe/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs index 7a9e964b..b9cf7e09 100644 --- a/crates/windows-placement-probe/src/core_affinity/tests.rs +++ b/crates/windows-placement-probe/src/core_affinity/tests.rs @@ -17,7 +17,11 @@ use crate::fingerprint::ProcessorPlace; /// /// Single-node, matching every host measured so far; use [`on_node`] to move /// one onto another NUMA node. -fn place(number: u8, efficiency_class: u8, cache_domain: Option) -> ProcessorPlace { +fn place( + number: u8, + efficiency_class: u8, + cache_domain: windows_topology_sys::Observed, +) -> ProcessorPlace { ProcessorPlace { group: 0, number, @@ -47,7 +51,7 @@ fn sibling( number: u8, core: u32, efficiency_class: u8, - cache_domain: Option, + cache_domain: windows_topology_sys::Observed, ) -> ProcessorPlace { ProcessorPlace { group: 0, @@ -61,29 +65,29 @@ fn sibling( #[test] fn same_cache_and_same_class_is_classified_as_such() { - let a = place(0, 1, Some(0)); - let b = place(1, 1, Some(0)); + let a = place(0, 1, windows_topology_sys::Observed::Known(0)); + let b = place(1, 1, windows_topology_sys::Observed::Known(0)); assert_eq!(classify(a, b), Placement::SameCacheSameClass); } #[test] fn a_differing_class_alone_is_cross_class() { - let a = place(0, 1, Some(0)); - let b = place(1, 0, Some(0)); + let a = place(0, 1, windows_topology_sys::Observed::Known(0)); + let b = place(1, 0, windows_topology_sys::Observed::Known(0)); assert_eq!(classify(a, b), Placement::SameCacheCrossClass); } #[test] fn a_differing_cache_alone_is_cross_cache() { - let a = place(0, 1, Some(0)); - let b = place(6, 1, Some(1)); + let a = place(0, 1, windows_topology_sys::Observed::Known(0)); + let b = place(6, 1, windows_topology_sys::Observed::Known(1)); assert_eq!(classify(a, b), Placement::CrossCacheSameClass); } #[test] fn differing_in_both_is_cross_cross() { - let a = place(0, 1, Some(0)); - let b = place(6, 0, Some(1)); + let a = place(0, 1, windows_topology_sys::Observed::Known(0)); + let b = place(6, 0, windows_topology_sys::Observed::Known(1)); assert_eq!(classify(a, b), Placement::CrossCacheCrossClass); } @@ -93,8 +97,8 @@ fn classification_is_symmetric_in_the_two_ends() { // not change it. The measurement may well differ by direction -- a fast // producer feeding a slow consumer is not the same experiment as the // reverse -- but that is a difference in the result, not in the label. - let a = place(0, 1, Some(0)); - let b = place(6, 0, Some(1)); + let a = place(0, 1, windows_topology_sys::Observed::Known(0)); + let b = place(6, 0, windows_topology_sys::Observed::Known(1)); assert_eq!(classify(a, b), classify(b, a)); } @@ -102,14 +106,16 @@ fn classification_is_symmetric_in_the_two_ends() { fn a_machine_with_no_partitioning_cache_still_classifies() { // Both `None`, which compares equal: a machine whose caches do not divide // it has every pair in the "same cache" category rather than in none. - let a = place(0, 1, None); - let b = place(1, 0, None); + let a = place(0, 1, windows_topology_sys::Observed::Absent); + let b = place(1, 0, windows_topology_sys::Observed::Absent); assert_eq!(classify(a, b), Placement::SameCacheCrossClass); } #[test] fn a_homogeneous_single_cache_machine_offers_only_one_placement() { - let places: Vec<_> = (0..4).map(|n| place(n, 0, Some(0))).collect(); + let places: Vec<_> = (0..4) + .map(|n| place(n, 0, windows_topology_sys::Observed::Known(0))) + .collect(); let pairs = representative_pairs(&places); assert_eq!( @@ -131,7 +137,11 @@ fn a_heterogeneous_two_cache_machine_offers_all_four() { for cache in 0..2_u32 { for class in 0..2_u8 { for _ in 0..2 { - places.push(place(number, class, Some(cache))); + places.push(place( + number, + class, + windows_topology_sys::Observed::Known(cache), + )); number += 1; } } @@ -158,7 +168,11 @@ fn a_machine_whose_classes_follow_its_caches_offers_only_two() { let mut places = Vec::new(); for number in 0..12_u8 { let side = u32::from(number) / 6; - places.push(place(number, side as u8, Some(side))); + places.push(place( + number, + side as u8, + windows_topology_sys::Observed::Known(side), + )); } let pairs = representative_pairs(&places); @@ -177,7 +191,13 @@ fn a_machine_whose_classes_follow_its_caches_offers_only_two() { #[test] fn a_pair_never_puts_both_ends_on_one_processor() { let places: Vec<_> = (0..4) - .map(|n| place(n, n % 2, Some(u32::from(n) / 2))) + .map(|n| { + place( + n, + n % 2, + windows_topology_sys::Observed::Known(u32::from(n) / 2), + ) + }) .collect(); for (_, (producer, consumer)) in representative_pairs(&places) { assert_ne!( @@ -190,7 +210,13 @@ fn a_pair_never_puts_both_ends_on_one_processor() { #[test] fn every_expressible_placement_is_chosen_exactly_once() { let places: Vec<_> = (0..8) - .map(|n| place(n, n % 2, Some(u32::from(n) / 4))) + .map(|n| { + place( + n, + n % 2, + windows_topology_sys::Observed::Known(u32::from(n) / 4), + ) + }) .collect(); let pairs = representative_pairs(&places); @@ -208,15 +234,15 @@ fn smt_siblings_are_their_own_placement() { // and a two-core pair behind one cache landed in the same bucket, and the // probe reported whichever it happened to select first -- on an SMT host, // which is exactly where the distinction matters. - let a = sibling(0, 0, 0, Some(0)); - let b = sibling(1, 0, 0, Some(0)); + let a = sibling(0, 0, 0, windows_topology_sys::Observed::Known(0)); + let b = sibling(1, 0, 0, windows_topology_sys::Observed::Known(0)); assert_eq!(classify(a, b), Placement::SameCoreSiblings); } #[test] fn siblings_outrank_the_cache_and_class_they_also_share() { - let a = sibling(0, 0, 1, Some(3)); - let b = sibling(1, 0, 1, Some(3)); + let a = sibling(0, 0, 1, windows_topology_sys::Observed::Known(3)); + let b = sibling(1, 0, 1, windows_topology_sys::Observed::Known(3)); assert_ne!( classify(a, b), Placement::SameCacheSameClass, @@ -233,7 +259,12 @@ fn an_smt_host_expresses_a_placement_a_non_smt_host_cannot() { let mut places = Vec::new(); for core in 0..8_u32 { for lane in 0..2_u8 { - places.push(sibling(core as u8 * 2 + lane, core, 0, Some(0))); + places.push(sibling( + core as u8 * 2 + lane, + core, + 0, + windows_topology_sys::Observed::Known(0), + )); } } let pairs = representative_pairs(&places); @@ -250,7 +281,13 @@ fn an_smt_host_expresses_a_placement_a_non_smt_host_cannot() { #[test] fn a_non_smt_host_cannot_express_the_sibling_placement() { let places: Vec<_> = (0..12) - .map(|n| place(n, u8::from(n >= 6), Some(u32::from(n) / 6))) + .map(|n| { + place( + n, + u8::from(n >= 6), + windows_topology_sys::Observed::Known(u32::from(n) / 6), + ) + }) .collect(); let pairs = representative_pairs(&places); @@ -262,8 +299,8 @@ fn a_non_smt_host_cannot_express_the_sibling_placement() { #[test] fn different_numa_nodes_are_classified_as_a_node_crossing() { - let a = place(0, 1, Some(0)); - let b = on_node(place(1, 1, Some(1)), 1); + let a = place(0, 1, windows_topology_sys::Observed::Known(0)); + let b = on_node(place(1, 1, windows_topology_sys::Observed::Known(1)), 1); assert_eq!(classify(a, b), Placement::CrossNumaNode); } @@ -274,8 +311,8 @@ fn a_node_crossing_outranks_the_cache_and_class_it_also_crosses() { // `CrossCacheCrossClass` and the node crossing is invisible, so an // expensive run on a real NUMA machine would be recorded as a cache // effect. - let a = place(0, 1, Some(0)); - let b = on_node(place(1, 0, Some(1)), 1); + let a = place(0, 1, windows_topology_sys::Observed::Known(0)); + let b = on_node(place(1, 0, windows_topology_sys::Observed::Known(1)), 1); assert_eq!(classify(a, b), Placement::CrossNumaNode); } @@ -286,8 +323,8 @@ fn a_node_crossing_is_reported_even_when_cache_and_class_match() { // configuration real hardware offers, but the classifier must not depend on // that: it decides on the node, not on the fields the node happens to // correlate with. - let a = place(0, 1, Some(0)); - let b = on_node(place(1, 1, Some(0)), 1); + let a = place(0, 1, windows_topology_sys::Observed::Known(0)); + let b = on_node(place(1, 1, windows_topology_sys::Observed::Known(0)), 1); assert_eq!(classify(a, b), Placement::CrossNumaNode); } @@ -299,8 +336,11 @@ fn siblings_outrank_a_node_crossing_because_one_core_cannot_span_nodes() { // sibling relationship would win. Pinning the order down here means a later // reordering of `classify` is caught by a test rather than by a confusing // table on a machine nobody has yet run. - let a = sibling(0, 0, 1, Some(0)); - let b = on_node(sibling(1, 0, 1, Some(0)), 1); + let a = sibling(0, 0, 1, windows_topology_sys::Observed::Known(0)); + let b = on_node( + sibling(1, 0, 1, windows_topology_sys::Observed::Known(0)), + 1, + ); assert_eq!(classify(a, b), Placement::SameCoreSiblings); } @@ -311,7 +351,13 @@ fn a_single_node_machine_never_produces_a_node_crossing() { // the case that must stay quiet: the new variant must not appear where it // cannot apply. let places: Vec<_> = (0..8) - .map(|number| place(number, u8::from(number < 4), Some(u32::from(number) / 2))) + .map(|number| { + place( + number, + u8::from(number < 4), + windows_topology_sys::Observed::Known(u32::from(number) / 2), + ) + }) .collect(); let pairs = representative_pairs(&places); @@ -327,7 +373,11 @@ fn a_single_node_machine_never_produces_a_node_crossing() { fn a_two_node_machine_expresses_the_node_crossing() { let places: Vec<_> = (0..8) .map(|number| { - let base = place(number, 1, Some(u32::from(number) / 2)); + let base = place( + number, + 1, + windows_topology_sys::Observed::Known(u32::from(number) / 2), + ); on_node(base, u32::from(number) / 4) }) .collect(); @@ -390,7 +440,7 @@ fn synthesize(spec: &HostSpec) -> Vec { number, core, efficiency_class: 0, - cache_domain: Some(cache_domain), + cache_domain: windows_topology_sys::Observed::Known(cache_domain), numa_node: node, }); number += 1; @@ -791,7 +841,7 @@ mod processor_groups { for group in 0..2_u16 { for number in 0..4_u8 { let domain = u32::from(group) * 2 + u32::from(number) / 2; - let base = place(number, 0, Some(domain)); + let base = place(number, 0, windows_topology_sys::Observed::Known(domain)); places.push(in_group(base, group)); } } @@ -831,9 +881,9 @@ mod processor_groups { // why this is not tested through `two_groups`, and why the defect // survived the group work: it is invisible unless the discarded pair is // the only representative of its placement. - let mut here = place(0, 0, Some(0)); + let mut here = place(0, 0, windows_topology_sys::Observed::Known(0)); here.numa_node = 0; - let mut there = in_group(place(0, 0, Some(1)), 1); + let mut there = in_group(place(0, 0, windows_topology_sys::Observed::Known(1)), 1); there.numa_node = 1; let pairs = representative_pairs(&[here, there]); @@ -857,7 +907,7 @@ mod processor_groups { // The slice string is how a measurement's provenance travels into a // checklist or a submitted record. If it omits the group, two different // processors render identically and the record cannot be read back. - let zero = place(5, 0, Some(0)); + let zero = place(5, 0, windows_topology_sys::Observed::Known(0)); let one = in_group(zero, 1); assert_ne!(zero.to_string(), one.to_string()); @@ -871,7 +921,7 @@ mod processor_groups { // core id were derived from the number alone, two processors in // different groups would be classified as SMT siblings -- physically // impossible, since a core cannot span a group. - let zero = sibling(5, 5, 0, Some(0)); + let zero = sibling(5, 5, 0, windows_topology_sys::Observed::Known(0)); let one = in_group(zero, 1); assert_ne!( @@ -1074,8 +1124,8 @@ fn hop_row( achieved: Option, nanos: f64, ) -> super::Measurement { - let mut producer = place(0, 0, Some(0)); - let mut consumer = place(1, 0, Some(0)); + let mut producer = place(0, 0, windows_topology_sys::Observed::Known(0)); + let mut consumer = place(1, 0, windows_topology_sys::Observed::Known(0)); producer.numa_node = pair.0; consumer.numa_node = pair.1; super::Measurement { diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index 8b2a303b..1ca870a8 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -76,7 +76,7 @@ use std::fmt; -use windows_topology_sys::{DomainKind, MachineMemoryTopology, Provenance, Source}; +use windows_topology_sys::{DomainKind, MachineMemoryTopology, Observed, Provenance, Source}; /// One logical processor's position in the machine. /// @@ -111,7 +111,7 @@ pub struct ProcessorPlace { pub efficiency_class: u8, /// Which cache domain it sits behind, at the outermost level that /// partitions the machine, or `None` if no level does. - pub cache_domain: Option, + pub cache_domain: Observed, /// Which NUMA node it belongs to. /// /// Carried even though every host measured so far reports a single node, @@ -151,9 +151,13 @@ impl fmt::Display for ProcessorPlace { "g{}/cpu{}/core{}/ec{}", self.group, self.number, self.core, self.efficiency_class )?; + // Three states get three renderings, so a fingerprint cannot read as + // "no level partitions this machine" when the truth is "this processor + // was left out of the level that does". match self.cache_domain { - Some(id) => write!(f, "/cd{id}")?, - None => write!(f, "/cd-")?, + Observed::Known(id) => write!(f, "/cd{id}")?, + Observed::Absent => write!(f, "/cd-")?, + Observed::NotObserved => write!(f, "/cd?")?, } write!(f, "/n{}", self.numa_node) } @@ -207,9 +211,24 @@ impl Slice { let Self::Pinned { participants } = self else { return None; }; + // `None` when any participant's domain was never observed: two + // unobserved processors must not compare equal and be reported as + // sharing a cache. That is the hazard the old refusal existed to + // prevent, moved from "refuse the whole run" to "answer the one + // question that cannot be answered". let mut domains = participants.iter().map(|(_, place)| place.cache_domain); let first = domains.next()?; - Some(domains.all(|domain| domain == first)) + if first == Observed::NotObserved { + return None; + } + let mut same = true; + for domain in domains { + if domain == Observed::NotObserved { + return None; + } + same &= domain == first; + } + Some(same) } /// Whether every participant is of the same efficiency class. @@ -729,15 +748,21 @@ pub fn places_from_topology( None if !any_core_domain => 0, None => return refuse(MissingPlacement::Core), }; + // Three states, three answers, and no refusal (M5+.4). The old code + // refused a processor missing from a level that DOES partition, + // because `None` had to serve as both "no level partitions this + // machine" and "this processor was left out" -- and two omitted + // processors would then compare equal and be reported as sharing a + // cache they do not. + // + // `Observed` separates them, so the run continues over a topology + // this crate deliberately hands back. The comparison in + // `Slice::same_cache_domain` is what makes that safe: it answers + // "unknown" rather than "same" when either side is NotObserved. let cache_domain = match cache_of.get(&id).copied() { - Some(domain) => Some(domain), - // `None` means "no level partitions this machine", which is a - // real and uniform answer. It must not also mean "this - // processor was left out of the level that does": two omitted - // processors would then compare equal and be reported as - // sharing a cache. - None if !any_cache_partition => None, - None => return refuse(MissingPlacement::CacheDomain), + Some(domain) => Observed::Known(domain), + None if !any_cache_partition => Observed::Absent, + None => Observed::NotObserved, }; let numa_node = match numa_of.get(&id).copied() { Some(node) => node, diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index 37bf2d37..147a7792 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -391,7 +391,9 @@ mod from_topology { } for (id, members) in node_members { domains.push(Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: windows_topology_sys::Observed::NotObserved, + }, processors: set_of(&members), observations: vec![windows_topology_sys::Observation::new( windows_topology_sys::Source::RelationshipWalk, @@ -543,7 +545,7 @@ mod from_topology { let places = places_from_topology(&flat).expect("the fixture places every processor"); assert!( - places.iter().all(|p| p.cache_domain.is_none()), + places.iter().all(|p| p.cache_domain.known().is_none()), "an undivided machine reported a partitioning cache domain" ); } @@ -670,7 +672,9 @@ mod multi_group_conversion { )], }); domains.push(Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: windows_topology_sys::Observed::NotObserved, + }, processors: ProcessorSet::from_group_mask(group, mask), observations: vec![windows_topology_sys::Observation::new( windows_topology_sys::Source::RelationshipWalk, @@ -732,7 +736,7 @@ mod multi_group_conversion { ); assert_eq!( place.cache_domain, - Some(100 + u32::from(place.group)), + windows_topology_sys::Observed::Known(100 + u32::from(place.group)), "{place} was read into the wrong cache domain" ); } @@ -849,7 +853,9 @@ mod multi_group_conversion { let mut topology = bare_processors(3); for (id, mask) in [(1_u32, 0b001_usize), (2, 0b010)] { topology.domains.push(Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: windows_topology_sys::Observed::NotObserved, + }, processors: ProcessorSet::from_group_mask(0, mask), observations: vec![windows_topology_sys::Observation::new( windows_topology_sys::Source::RelationshipWalk, @@ -876,7 +882,9 @@ mod multi_group_conversion { let mut topology = bare_processors(2); for (id, mask) in [(1_u32, 0b01_usize), (2, 0b10)] { topology.domains.push(Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: windows_topology_sys::Observed::NotObserved, + }, processors: ProcessorSet::from_group_mask(0, mask), observations: vec![windows_topology_sys::Observation::new( windows_topology_sys::Source::RelationshipWalk, @@ -986,7 +994,7 @@ mod multi_group_conversion { } #[test] - fn a_processor_omitted_from_the_partitioning_cache_level_is_refused() { + fn a_processor_omitted_from_the_partitioning_cache_level_is_recorded_not_refused() { // `None` already means "no level partitions this machine". Letting it // also mean "this processor was left out of the level that does" makes // two omitted processors compare equal, and `classify` reports them as @@ -1026,11 +1034,32 @@ mod multi_group_conversion { }); } - let refused = places_from_topology(&topology) - .expect_err("cpu2 is in no cache domain at the partitioning level"); - - assert_eq!((refused.group, refused.number), (0, 2)); - assert_eq!(refused.missing, MissingPlacement::CacheDomain); + // M5+.4 removed this refusal. The topology crate deliberately hands + // back a partially-covering cache level, and failing an entire + // measurement run over one is the defect, not the guard. + // + // What replaced it: cpu2 reports `NotObserved`, which is neither + // "no level partitions this machine" (`Absent`) nor a fabricated + // domain -- and `Slice::same_cache_domain` answers *unknown* for it, + // so the hazard this refusal existed to prevent is still closed. + let places = places_from_topology(&topology) + .expect("a partial cache level must not fail the whole run"); + + let cpu2 = places + .iter() + .find(|p| p.number == 2) + .expect("cpu2 is still placed"); + assert_eq!( + cpu2.cache_domain, + windows_topology_sys::Observed::NotObserved + ); + assert!( + places + .iter() + .filter(|p| p.number != 2) + .all(|p| p.cache_domain.was_observed()), + "only the uncovered processor is unobserved" + ); } #[test] @@ -1040,7 +1069,11 @@ mod multi_group_conversion { let places = places_from_topology(&bare_processors(2)).expect("no cache level divides this machine"); - assert!(places.iter().all(|place| place.cache_domain.is_none())); + assert!( + places + .iter() + .all(|place| place.cache_domain.known().is_none()) + ); } #[test] @@ -1122,4 +1155,119 @@ mod multi_group_conversion { removes it should fail here and be made on purpose" ); } + + // --- M5+.4: a partially-covering cache level no longer fails the run --- + + mod partial_cache_coverage { + use super::*; + use crate::fingerprint::Slice; + use windows_topology_sys::Observed; + + /// Four processors, an L2 that partitions but names only three of them. + /// This crate deliberately hands such a topology back; the probe used to + /// refuse the whole measurement over it. + fn partially_covered() -> MachineMemoryTopology { + covering(4, &[0b0011, 0b0100]) + } + + /// Six processors, an L2 that partitions but names only four of them, + /// so *two* are left uncovered and can be compared against each other. + fn two_uncovered() -> MachineMemoryTopology { + covering(6, &[0b000_011, 0b001_100]) + } + + fn covering(count: u8, masks: &[usize]) -> MachineMemoryTopology { + let all = (1_usize << count) - 1; + let mut topology = bare_processors(count); + topology.domains.push(Domain { + kind: DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }, + processors: ProcessorSet::from_group_mask(0, all), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 0, + )], + }); + for (label, mask) in masks.iter().copied().enumerate() { + topology.domains.push(Domain { + kind: DomainKind::Cache { + level: 2, + associativity: 8, + line_size: 64, + size_bytes: 512 * 1024, + cache_type: windows_topology_sys::CacheKind::Unified, + }, + processors: ProcessorSet::from_group_mask(0, mask), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + label as u32, + )], + }); + } + topology + } + + #[test] + fn the_run_proceeds_instead_of_being_refused() { + let places = places_from_topology(&partially_covered()) + .expect("a partial cache level must not fail the whole measurement"); + assert_eq!(places.len(), 4); + } + + #[test] + fn the_uncovered_processor_is_not_observed_not_absent() { + let places = places_from_topology(&partially_covered()).expect("places"); + let by_number = |n: u8| { + places + .iter() + .find(|p| p.number == n) + .expect("processor") + .cache_domain + }; + + assert_eq!(by_number(0), Observed::Known(0)); + assert_eq!(by_number(2), Observed::Known(1)); + assert_eq!( + by_number(3), + Observed::NotObserved, + "left out of a level that does partition -- a gap, not an answer" + ); + } + + #[test] + fn two_uncovered_processors_are_not_reported_as_sharing_a_cache() { + // The hazard the refusal existed to prevent. It must still be closed: + // the answer is "unknown", never "same". + let places = places_from_topology(&two_uncovered()).expect("places"); + let uncovered: Vec<_> = places + .iter() + .filter(|p| p.cache_domain == Observed::NotObserved) + .collect(); + assert_eq!( + uncovered.len(), + 2, + "the fixture must leave exactly two processors uncovered: {places:?}" + ); + + let slice = Slice::pair(*uncovered[0], *uncovered[1]); + assert_eq!( + slice.same_cache_domain(), + None, + "two unobserved domains must answer unknown, never same" + ); + } + + #[test] + fn a_machine_where_no_level_partitions_still_answers_same_cache() { + // `Absent` is uniform across the machine and is a real answer, so it + // must keep comparing equal -- otherwise removing the refusal would + // have broken the ordinary single-domain case. + let places = places_from_topology(&bare_processors(2)).expect("places"); + assert!(places.iter().all(|p| p.cache_domain == Observed::Absent)); + + let slice = Slice::pair(places[0], places[1]); + assert_eq!(slice.same_cache_domain(), Some(true)); + } + } } diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index b336277c..eb5f23d3 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -7,6 +7,7 @@ use std::fmt::Write as _; use windows_placement_probe::core_affinity::{Observation, Placement, measure}; use windows_placement_probe::peer_index_cache::Strategy; use windows_platform_probes::report::{Stdout, emit}; +use windows_topology_sys::Observed; fn main() -> std::io::Result<()> { // The only place that names the real stream. Everything below composes @@ -47,9 +48,11 @@ fn render(observation: &Observation) -> String { " {:>8} {:>16} {:>13}", format!("g{}/cpu{}", place.group, place.number), place.efficiency_class, - place - .cache_domain - .map_or_else(|| "none".to_owned(), |id| id.to_string()) + match place.cache_domain { + Observed::Known(id) => id.to_string(), + Observed::Absent => "none".to_owned(), + Observed::NotObserved => "unknown".to_owned(), + } ); } diff --git a/crates/windows-platform-probes/src/tests.rs b/crates/windows-platform-probes/src/tests.rs index 8740f0ee..41f45c71 100644 --- a/crates/windows-platform-probes/src/tests.rs +++ b/crates/windows-platform-probes/src/tests.rs @@ -915,9 +915,7 @@ fn the_survey_reports_the_topology_crates_partitioning_level_not_its_own() { observation.partitioning_cache_level = Some(2); assert_eq!( - observation - .outermost_partitioning_cache() - .map(|c| c.level), + observation.outermost_partitioning_cache().map(|c| c.level), Some(2), "the survey must not re-derive; it looks up what the crate decided" ); @@ -934,4 +932,4 @@ fn no_partitioning_level_is_a_real_answer_in_the_survey_too() { observation.partitioning_cache_level = None; assert!(observation.outermost_partitioning_cache().is_none()); -} \ No newline at end of file +} diff --git a/crates/windows-platform-probes/src/topology.rs b/crates/windows-platform-probes/src/topology.rs index d76293b6..c4d6e021 100644 --- a/crates/windows-platform-probes/src/topology.rs +++ b/crates/windows-platform-probes/src/topology.rs @@ -304,7 +304,9 @@ pub fn measure() -> io::Result { // Asked once, here, rather than restated: the crate that owns the topology // owns the rule (D-21). - let partitioning_cache_level = topology.outermost_partitioning_cache().map(|(level, _)| level); + let partitioning_cache_level = topology + .outermost_partitioning_cache() + .map(|(level, _)| level); Ok(Observation { online_processors, diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 0e5f6824..a1db8874 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -38,7 +38,7 @@ the **adapter's** problem and must not be filed here as a gap. | M2 the granularity model | **6 of 6 done** | nothing -- complete | | M3 observation and provenance | **4 of 4 done** | nothing -- complete | | M4 the queries | **5 of 5 done** | nothing -- complete | -| M5 the defects this subsumes | parked | M4, **except M5+.5 (done)** | +| M5 the defects this subsumes | **5 of 5 done** | nothing -- complete | **M1 was decision work, not implementation**, and it is complete. Each item was a question the session left open, and each would have changed the shape of everything below it. @@ -664,9 +664,13 @@ separately and then re-fixed. it -- removing a published field is a second breaking change with no additional benefit once the honest answer exists beside it. -- [ ] **M5+.2** -- `DomainKind::Memory::memory_bytes` is unambiguous from `discover` but ambiguous +- [x] **M5+.2** -- `DomainKind::Memory::memory_bytes` is unambiguous from `discover` but ambiguous from a **description**, where "the field was omitted" and "this node's capacity is unknown" are the same value. The [D-13](DESIGN-NOTES.md#d-13) audit found this and documentation cannot fix it. + **Done**: the field is now `Observed`, and serde carries all three states distinctly -- a + number for `Known`, an explicit `null` for `Absent` ("this node has no memory of its own"), and + **omission** for `NotObserved`. The two that used to collide are now different bytes on the wire, + so a description round-trips through the distinction rather than losing it. - [x] **M5+.3** -- The partitioning rule is stated **three times in two crates**, and two of the three differ: `windows-platform-probes` omits the pairwise-disjointness check this crate requires. @@ -681,9 +685,16 @@ separately and then re-fixed. one also owns. Ties between *identical* partitions now take the higher level, which reads the source's own labelling of one boundary rather than ordering distinct partitions by number. -- [ ] **M5+.4** -- `windows-placement-probe` **refuses a partially-covering cache level** that this +- [x] **M5+.4** -- `windows-placement-probe` **refuses a partially-covering cache level** that this crate deliberately hands back, failing an entire measurement run over a topology this crate considers describable. M2+.5 gives it the vocabulary to accept one. + **Done**: `ProcessorPlace::cache_domain` is `Observed`, the `MissingPlacement::CacheDomain` + refusal is gone, and an uncovered processor reports `NotObserved` -- which is neither `Absent` + ("no level partitions this machine", the ordinary single-domain case) nor a fabricated domain. + **The hazard the refusal existed to prevent is closed one level down instead**: + `Slice::same_cache_domain` answers `None` when any participant is `NotObserved`, so two uncovered + processors are never reported as sharing a cache. Verified by sabotage -- deleting that guard turns + the answer into `Some(true)` and exactly one test goes red. - [x] **M5+.5** -- **Delete `MachineMemoryTopology::distances` and the `Distances` type**, per [D-20](DESIGN-NOTES.md#d-20). **Not gated on M4**: the reshape does not fix this one, deletion diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index 7bdc2cf2..446f04ac 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -91,14 +91,32 @@ pub enum DomainKind { /// A memory locality domain -- a NUMA node modelled as a memory domain /// that may contain no processors at all (D-5), because CXL expanders, /// persistent memory, HBM tiers, and coherent GPU memory all present that - /// way. `memory_bytes` is `None` when the size is not known: Windows's - /// own enumeration (`GetLogicalProcessorInformationEx`) does not report a - /// NUMA node's capacity at all, so a domain discovered by this crate - /// always has `memory_bytes: None`; a hand-written or fed-in description - /// may supply it. + /// way. Memory { - /// The domain's memory capacity, if known. - memory_bytes: Option, + /// The domain's memory capacity. + /// + /// [`Observed::NotObserved`] from `discover`: Windows's own enumeration + /// (`GetLogicalProcessorInformationEx`) does not report a NUMA node's + /// capacity at all, so this crate never learns it. A hand-written or + /// fed-in description may supply one. + /// + /// # Why this is an `Observed` and not an `Option` + /// + /// So that a description **omitting** the field and one writing + /// `"memory_bytes": null` stop being the same value: the first is + /// `NotObserved` (nobody addressed it) and the second is `Absent` (the + /// writer addressed it and had no value). Both still mean "the capacity + /// is unknown" to a planner, so the distinction is one of **provenance + /// of the description**, not of planning -- recorded plainly because + /// `M5+.2` framed it as more than that. + /// + /// The distinction that actually matters was already available and is + /// preserved: `Known(0)` is a node with genuinely no memory -- the + /// CXL-expander shape [D-5](../DESIGN-NOTES.md) exists to represent -- + /// and is not confusable with an unknown capacity. That is + /// [D-11](../DESIGN-NOTES.md)'s point, and it is why `Some(0)` was + /// rejected as a stand-in in the first place. + memory_bytes: Observed, }, /// A domain kind this crate does not have a name for, carrying its raw /// name and whatever attributes came with it, so a description this @@ -491,8 +509,18 @@ mod serde_impl { map.serialize_entry("cache_type", cache_type)?; } DomainKind::Memory { memory_bytes } => { - if let Some(bytes) = memory_bytes { - map.serialize_entry("memory_bytes", bytes)?; + // Three states, three wire shapes: a number, an explicit + // `null` for "addressed and unknown", and omission for + // "nobody said". Writing `null` for both would put the + // ambiguity back on the wire that the type just removed. + match memory_bytes { + crate::observed::Observed::Known(bytes) => { + map.serialize_entry("memory_bytes", bytes)?; + } + crate::observed::Observed::Absent => { + map.serialize_entry("memory_bytes", &Option::::None)?; + } + crate::observed::Observed::NotObserved => {} } } DomainKind::Other { attributes, .. } => { @@ -568,8 +596,9 @@ mod serde_impl { }, "memory" => DomainKind::Memory { memory_bytes: match fields.remove("memory_bytes") { - None | Some(AttributeValue::Null) => None, - Some(value) => Some(as_u64(value)?), + None => crate::observed::Observed::NotObserved, + Some(AttributeValue::Null) => crate::observed::Observed::Absent, + Some(value) => crate::observed::Observed::Known(as_u64(value)?), }, }, other => DomainKind::Other { diff --git a/crates/windows-topology-sys/src/domain/tests.rs b/crates/windows-topology-sys/src/domain/tests.rs index d1a92a06..d4fd6d31 100644 --- a/crates/windows-topology-sys/src/domain/tests.rs +++ b/crates/windows-topology-sys/src/domain/tests.rs @@ -25,7 +25,7 @@ fn a_memory_domain_may_have_no_processors() { // discovery. let domain = Domain { kind: DomainKind::Memory { - memory_bytes: Some(64 * 1024 * 1024 * 1024), + memory_bytes: Observed::Known(64 * 1024 * 1024 * 1024), }, processors: ProcessorSet::empty(), observations: Vec::new(), @@ -34,7 +34,7 @@ fn a_memory_domain_may_have_no_processors() { let DomainKind::Memory { memory_bytes } = domain.kind else { panic!("expected Memory") }; - assert_eq!(memory_bytes, Some(64 * 1024 * 1024 * 1024)); + assert_eq!(memory_bytes, Observed::Known(64 * 1024 * 1024 * 1024)); } #[test] @@ -43,14 +43,16 @@ fn a_discovered_memory_domain_has_no_known_size() { // node memory capacity at all, so that arm must stay `None` rather than // guessing `Some(0)`, which would be indistinguishable from "no memory". let domain = Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }, processors: ProcessorSet::empty(), observations: Vec::new(), }; let DomainKind::Memory { memory_bytes } = domain.kind else { panic!("expected Memory") }; - assert_eq!(memory_bytes, None); + assert_eq!(memory_bytes, Observed::NotObserved); } #[test] @@ -95,6 +97,7 @@ mod serde_tests { use std::collections::BTreeMap; use super::super::*; + use crate::observed::Observed; use crate::processor_set::ProcessorSet; fn round_trip(domain: &Domain) -> Domain { @@ -192,7 +195,7 @@ mod serde_tests { // no processors at all. let domain = Domain { kind: DomainKind::Memory { - memory_bytes: Some(64 * 1024 * 1024 * 1024), + memory_bytes: Observed::Known(64 * 1024 * 1024 * 1024), }, processors: ProcessorSet::empty(), observations: Vec::new(), @@ -208,7 +211,9 @@ mod serde_tests { #[test] fn a_memory_domain_with_unknown_size_omits_memory_bytes_rather_than_writing_null() { let domain = Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }, processors: ProcessorSet::empty(), observations: Vec::new(), }; @@ -282,7 +287,7 @@ mod serde_tests { let precise: u64 = (1u64 << 53) + 1; let domain = Domain { kind: DomainKind::Memory { - memory_bytes: Some(precise), + memory_bytes: Observed::Known(precise), }, processors: ProcessorSet::empty(), observations: Vec::new(), @@ -291,7 +296,7 @@ mod serde_tests { let DomainKind::Memory { memory_bytes } = restored.kind else { panic!("expected Memory") }; - assert_eq!(memory_bytes, Some(precise)); + assert_eq!(memory_bytes, Observed::Known(precise)); } #[test] @@ -332,7 +337,7 @@ mod serde_tests { assert_eq!( domain.kind, DomainKind::Memory { - memory_bytes: Some(549_755_813_888) + memory_bytes: Observed::Known(549_755_813_888) } ); } @@ -422,7 +427,7 @@ mod serde_tests { assert_eq!( domain.kind, DomainKind::Memory { - memory_bytes: Some(1024) + memory_bytes: Observed::Known(1024) }, "a generator emitting a whole number as a float is ordinary JSON" ); @@ -464,7 +469,7 @@ mod serde_tests { assert_eq!( domain.kind, DomainKind::Memory { - memory_bytes: Some(0) + memory_bytes: Observed::Known(0) } ); } @@ -477,7 +482,7 @@ mod serde_tests { assert_eq!( domain.kind, DomainKind::Memory { - memory_bytes: Some(4096) + memory_bytes: Observed::Known(4096) } ); } @@ -636,3 +641,73 @@ mod serde_tests { assert_eq!(cache_type, CacheKind::Other(99)); } } + +// --- M5+.2: omitted and explicit-null stop being the same value --- + +#[cfg(feature = "serde")] +#[test] +fn an_omitted_memory_bytes_and_an_explicit_null_are_different_values() { + // The defect: both used to parse to `None`, so a description that never + // addressed the field and one that addressed it and had no answer were + // indistinguishable. + let omitted: Domain = + serde_json::from_str(r#"{"kind": "memory", "id": 0, "processors": []}"#).expect("parse"); + let explicit_null: Domain = serde_json::from_str( + r#"{"kind": "memory", "id": 0, "processors": [], "memory_bytes": null}"#, + ) + .expect("parse"); + + let DomainKind::Memory { + memory_bytes: from_omission, + } = omitted.kind + else { + panic!("memory"); + }; + let DomainKind::Memory { + memory_bytes: from_null, + } = explicit_null.kind + else { + panic!("memory"); + }; + + assert_eq!(from_omission, Observed::NotObserved, "nobody addressed it"); + assert_eq!(from_null, Observed::Absent, "addressed, and no value"); + assert_ne!(from_omission, from_null); +} + +#[cfg(feature = "serde")] +#[test] +fn the_three_memory_bytes_states_round_trip_distinctly() { + // A wire format that collapsed any two would put the ambiguity back where + // the type just removed it. + for state in [ + Observed::Known(4096_u64), + Observed::Absent, + Observed::NotObserved, + ] { + let domain = Domain { + kind: DomainKind::Memory { + memory_bytes: state, + }, + processors: ProcessorSet::empty(), + observations: Vec::new(), + }; + let json = serde_json::to_string(&domain).expect("serialize"); + let back: Domain = serde_json::from_str(&json).expect("deserialize"); + let DomainKind::Memory { memory_bytes } = back.kind else { + panic!("memory"); + }; + assert_eq!(memory_bytes, state, "round trip changed {json}"); + } +} + +#[test] +fn a_node_with_genuinely_no_memory_is_known_zero_not_an_absence() { + // D-11's point, preserved: `Known(0)` is the CXL-shaped node that really + // has no memory, and it must not be confusable with an unknown capacity. + // This was already true of `Some(0)`; the change must not lose it. + let zero = Observed::Known(0_u64); + assert!(zero.was_observed()); + assert_ne!(zero, Observed::Absent); + assert_ne!(zero, Observed::NotObserved); +} diff --git a/crates/windows-topology-sys/src/granularity/tests.rs b/crates/windows-topology-sys/src/granularity/tests.rs index 2d43f377..f4450fee 100644 --- a/crates/windows-topology-sys/src/granularity/tests.rs +++ b/crates/windows-topology-sys/src/granularity/tests.rs @@ -3,6 +3,7 @@ use super::Granularity; use crate::domain::{Domain, DomainKind, Processor, ProcessorId}; +use crate::observed::Observed; use crate::processor_set::ProcessorSet; use crate::provenance::Provenance; use crate::relation::CacheKind; @@ -54,7 +55,9 @@ fn core(numbers: &[u8]) -> Domain { fn memory(numbers: &[u8]) -> Domain { Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }, processors: set(numbers), observations: Vec::new(), } @@ -245,7 +248,9 @@ fn the_order_holds_across_processor_groups() { let mut spanning = set(&[0, 1]); spanning.insert(1, 0); t.domains.push(Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }, processors: spanning, observations: Vec::new(), }); diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 64893106..176c7bdb 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -173,7 +173,9 @@ impl MachineMemoryTopology { self.fold_memberships( &nodes, |kind| matches!(kind, DomainKind::Memory { .. }), - |_| DomainKind::Memory { memory_bytes: None }, + |_| DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }, ); // The attribute subject, which relation unification cannot reach: both @@ -361,7 +363,9 @@ impl MachineMemoryTopology { } for node in &relations.numa_nodes { domains.push(Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }, processors: node.processors.clone(), observations: vec![Observation::new(Source::RelationshipWalk, node.node_number)], }); diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index e9ffb634..3b30670a 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -67,7 +67,9 @@ fn synthetic() -> MachineMemoryTopology { observations: Vec::new(), }, Domain { - kind: DomainKind::Memory { memory_bytes: None }, + kind: DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }, processors: ProcessorSet::empty(), observations: Vec::new(), }, @@ -481,7 +483,9 @@ mod serde_tests { // A memory domain covering the same processors as a core must not // absorb the core's CPU-sets observation. let mut memory = core_domain(0, &[0, 1], 0); - memory.kind = DomainKind::Memory { memory_bytes: None }; + memory.kind = DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }; let mut topology = MachineMemoryTopology { processors: Vec::new(), domains: vec![memory], @@ -1133,7 +1137,9 @@ fn a_split_instruction_and_data_cache_is_one_partition_not_two() { #[test] fn a_processor_in_a_memory_domain_reports_it() { let mut node = core_domain_at(0, &[0, 1]); - node.kind = DomainKind::Memory { memory_bytes: None }; + node.kind = DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }; let topo = machine_of(4, vec![node]); assert!( @@ -1152,7 +1158,9 @@ fn an_unplaced_processor_is_not_observed_rather_than_node_zero() { // fallback, because the pool must be allocated somewhere and guessing means // quietly allocating remote memory for the life of the process. let mut node = core_domain_at(0, &[0, 1]); - node.kind = DomainKind::Memory { memory_bytes: None }; + node.kind = DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }; let topo = machine_of(4, vec![node]); let unplaced = topo.memory_domain_of(ProcessorId { @@ -1179,7 +1187,9 @@ fn an_unplaced_processor_is_not_observed_rather_than_node_zero() { #[test] fn a_fully_covered_machine_has_no_unplaced_processors() { let mut node = core_domain_at(0, &[0, 1, 2, 3]); - node.kind = DomainKind::Memory { memory_bytes: None }; + node.kind = DomainKind::Memory { + memory_bytes: Observed::NotObserved, + }; assert!(machine_of(4, vec![node]).unplaced_processors().is_empty()); } From bea613af4bd87a0dea9e130d87cea32509a521d3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 19:35:04 -0400 Subject: [PATCH 278/361] docs(topology): archive the completed MMT plan (M1-M5) All 25 items across the five milestones are done, so the checklist moves to its resting state: the milestones are appended to COMPLETED-CHECKLIST.md under `Moved 2026-09-03`, CHECKLIST.md becomes a stub pointing at them, and the PLANS.md row moves to COMPLETED-PLANS.md. Pure content relocation -- the milestone text, including every "Done:" note recording what the work actually found, is carried across unchanged. The "Where this stands" context is carried with it because the items refer to it. Two things are recorded in the stub as *deferred with a named blocker* rather than dropped, so the absence of a checklist item for them is visibly deliberate: the CPU-set flag bit positions (D-23 -- unfalsifiable on a build that leaves `AllFlags` zero) and the planner adapters (D-21 -- they belong on topology-planner's side of the boundary and are planned there). topology-planner's citation of `M4+.1` is retargeted to the archive in the same commit so the link does not rot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/topology-planner/CHECKLIST.md | 2 +- crates/windows-topology-sys/CHECKLIST.md | 730 +----------------- .../COMPLETED-CHECKLIST.md | 718 +++++++++++++++++ .../windows-topology-sys/COMPLETED-PLANS.md | 1 + crates/windows-topology-sys/PLANS.md | 6 +- 5 files changed, 744 insertions(+), 713 deletions(-) diff --git a/crates/topology-planner/CHECKLIST.md b/crates/topology-planner/CHECKLIST.md index 8f1f8a02..3273e095 100644 --- a/crates/topology-planner/CHECKLIST.md +++ b/crates/topology-planner/CHECKLIST.md @@ -148,7 +148,7 @@ whether the topology can answer it today -- so the model is designed against a r **One of the four has since been corrected**, and it is recorded here rather than rewritten in the session, which is an append-only record of what was handed over. "A pairwise query must exist" is right about the requirement and wrong about the shape: per - [windows-topology-sys](../windows-topology-sys/CHECKLIST.md) `M4+.1` the ordered collection is the + [windows-topology-sys](../windows-topology-sys/COMPLETED-CHECKLIST.md) `M4+.1` the ordered collection is the surface and the pairwise query is derived from it, because an answer obliged to carry the block containing both processors is a question about the partition rather than about the pair. The *coverage* half -- recording which requirements the settled model answers and which it diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index a1db8874..ae6e5ffe 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -1,718 +1,28 @@ -# Checklist: reshaping the machine memory topology +# Checklist: windows-topology-sys -A fresh plan, deliberately numbered `MMT-*` rather than continuing the release checklist's `SH-*`. -It supersedes the model items filed there during PR #56's tenth review round; those are marked and -point here. +No pending work. -Design decisions live in [DESIGN-NOTES.md](DESIGN-NOTES.md). The session that produced this plan is -[DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md); -the consumer whose requirements shaped it is -[topology-planner](../topology-planner/DESIGN-NOTES.md). +The `MMT-*` plan -- the MachineMemoryTopology reshape that gated PR #56 -- is complete and archived in +[COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md) under `Moved 2026-09-03`, together with the M1-M4 +enumeration plan that preceded it. Cite item IDs (`MMT-1.1`, `M4+.1`, `M5+.4`, ...) against that file. -The crate's *original* design session, which produced the model this plan reshapes, is -[DESIGN-SESSION-2026-08-22-topology-schema.md](design-sessions/DESIGN-SESSION-2026-08-22-topology-schema.md). -Completed milestones are archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md). +Decisions live in [DESIGN-NOTES.md](DESIGN-NOTES.md), which is the authority for current behaviour; +the archived checklist records what was *done*, not what is *true now*. -## What this is for +Plan status is tracked in [PLANS.md](PLANS.md) and [COMPLETED-PLANS.md](COMPLETED-PLANS.md). New work +against this crate reopens a milestone here and adds a row back to `PLANS.md`. -This crate publishes a **refined view of what the platform publishes** -([D-21](DESIGN-NOTES.md#d-21)). The current model does that badly: it describes a machine as a list -of domains and answers questions with one global projection (`outermost_partitioning_cache`) that -three consumers have independently re-derived, two of them differently. It cannot say whether a fact -was observed or merely absent, it cannot answer anything about a *pair* of processors, and it -collapses a seven-kind, any-depth locality graph onto a single cache boundary. +## Deferred, and why -The reshape has one governing idea, settled with the engineer: **model the observed connectivity.** -Presence and observation are facts to represent, not shapes to infer from. +Two things were deliberately left out of the reshape rather than forgotten: -**The scope test is "is this a refinement of what Windows reports?"** -- never "does the planner need -it?". [D-20](DESIGN-NOTES.md#d-20) draws the lower bound (the crate does not go below the Win32 -topology APIs); D-21 draws the upper one. A planner requirement with no platform correspondence is -the **adapter's** problem and must not be filed here as a gap. +- **CPU-set flag bit positions** ([D-23](DESIGN-NOTES.md#d-23)). `SYSTEM_CPU_SET_INFORMATION::AllFlags` + reads constant zero on this build, *even after* `SetProcessDefaultCpuSets` succeeds and + `GetProcessDefaultCpuSets` confirms the allocation. The bit positions are therefore neither + confirmed nor falsifiable here; verification needs a machine that populates the byte. This is a + blocked measurement, not an unwritten one. -## Where this stands - -| Milestone | State | What it is waiting on | -|---|---|---| -| M1 settle what is still open | **5 of 5 done** | nothing -- complete | -| M2 the granularity model | **6 of 6 done** | nothing -- complete | -| M3 observation and provenance | **4 of 4 done** | nothing -- complete | -| M4 the queries | **5 of 5 done** | nothing -- complete | -| M5 the defects this subsumes | **5 of 5 done** | nothing -- complete | - -**M1 was decision work, not implementation**, and it is complete. Each item was a question the -session left open, and each would have changed the shape of everything below it. - -**M2 onward is implementation, and it is in scope for PR #56.** Per -[D-21](DESIGN-NOTES.md#d-21) the reshape is self-justified as the refined view rather than waiting on -a consumer, so nothing here is gated on the planner. Taking it into the current PR means -`windows-topology-sys` 0.2.0 ships the shape once, instead of publishing a surface already known to -be wrong and breaking again later. - -## M1: settle what is still open - -- [x] **MMT-1.1** -- **Are several observations of one relation held as a set, or reduced on insert - with the reduction recorded?** No longer speculative: `GetLogicalProcessorInformationEx` and - `GetSystemCpuSetInformation` both report a processor's core, NUMA node and efficiency class, from - different kernel paths, and both are read today. A set is honest and pushes adjudication onto every - caller; reducing on insert is convenient and throws away the disagreement, which is the one thing a - second observer is for. - **Done, as [D-15](DESIGN-NOTES.md#d-15): a set -- but the reason is not the one above.** Measured - rather than argued. The two sources **agree exactly** on the core partition (eight groups each) and - **label it completely differently** (`[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]`). So the - disagreement a reduction would resolve is between *dictionaries*, not about the machine. - That makes **a relation identified by `(kind, membership)`**, with a source's label an attribute of - the *observation*. Reduce-on-insert is then not merely lossy but **arbitrary** -- it would pick - between two correct labels by coin toss, while the fact that mattered needed no reduction because - the sources agreed. And a set costs nothing in the common case: agreement is one relation with two - observations, not two competing relations. - **Honest about the evidence:** only the core comparison is strong. NUMA is one group here so it - matches under almost any bug, and efficiency class is zero everywhere -- which is both trivially - matchable and the exact value `Processor::capacity`'s sentinel is indistinguishable from, so that - row confirms nothing. A hybrid, multi-node machine would test all three; none is available. - -- [x] **MMT-1.2** -- **What a query returns when observations differ.** ~~And there are three cases, - not two~~ -- **the third case dissolved.** [D-14](DESIGN-NOTES.md#d-14) found that CPU Sets reports - one last-level-cache group where the derivation reports eight L2 partitions, neither wrong because - they answer **different questions**, and this item was going to have to invent vocabulary for it. - [D-15](DESIGN-NOTES.md#d-15) removes the need: under `(kind, membership)` identity, different - memberships at different kinds are simply **different relations**, so they never meet to disagree. - **What remains is narrower**: two sources claiming the same *kind* over overlapping-but-unequal - memberships -- a real contradiction about the machine. Decide what a query returns then: a value - plus a conflict marker, or the conflict itself, forcing the caller to adjudicate. - Note the detection machinery partly exists. Overlapping-but-unequal sets at one kind is exactly - what `are_pairwise_disjoint` checks for cache domains today -- though only at *query* time, inside - `outermost_partitioning_cache`, and `Core` and `Memory` domains are never validated at all. - - ### The specifics, since "observations differ" is too vague to decide on - - **Where it arises:** `discover()`, populating a `MachineMemoryTopology`. It makes **two separate, - sequential Win32 calls** -- `relation::discover()` then `cpu_set::enumerate()` -- and nothing - compares their results. - - **Two shapes of conflict, not one.** The item above describes only the first: - - - **A, partition conflict:** same kind, memberships overlap without being equal. GLPIE says a core - is `{0,1}`, CPU Sets groups `{0,1,2}` under one `CoreIndex`. `(kind, membership)` identity - from [D-15](DESIGN-NOTES.md#d-15) makes this detectable. - - **B, attribute conflict:** same processor, same attribute, **different scalar**. GLPIE's - `Core { efficiency_class }` against CPU Sets' `EfficiencyClass`. This is not a membership - question and D-15 does not reach it, which the item as first written did not notice. - - **A third case: `discover()`'s two calls are not atomic.** Raised, then twice mis-corrected, then - wrongly retired, and finally **answered by [D-16](DESIGN-NOTES.md#d-16): collect again.** The whole - path is kept because the wrong turns are instructive. - - If the incoherence is detectable and harmful, **re-initiate collection**. Both calls are - whole-machine enumerations and trivially inexpensive, so a retry costs almost nothing, and more - than a couple of passes failing to find a coherent set is not plausible. - - **Retry is also the discriminator this item twice claimed could not exist.** The assertion was that - a transient inconsistency and a genuine one are indistinguishable *from a single observation* -- - true, and the conclusion that the model must therefore tolerate the ambiguity does not follow. Stop - using a single observation: transience resolves on the next pass, and what survives is *proved* - genuine. So only what has already been classified reaches the representation question below. - - The earlier missteps, kept short: - - - **It is not a torn read.** Nothing tears -- each call returns a self-consistent snapshot and the - buffers are process-private. The accurate term is a *non-atomic composite*. - - **Parking cannot cause it**, which was the example first given. Parking changes a CPU-Sets-only - field; GLPIE does not report parked state, and none of the three overlapping facts move when a - core parks -- `CoreIndex` and `NumaNodeIndex` are unchanged, `EfficiencyClass` is static. - - **And then it was retired for proving too much** -- on the grounds that even an atomic - `discover()` returns a topology stale the instant it returns, so the two-call window is only a - larger instance of an unavoidable problem. True, and **not a reason to do nothing**: the two are - not equally addressable. Staleness after the fact is the executor's to validate, and is already - owned as `M-inf.1` in [topology-planner](../topology-planner/CHECKLIST.md). - Incoherence *during* collection is ours, detectable, and cheap to fix. - - The framing is what caused the miss. Asking "what do we **store** when sources disagree" admits - refuse, record, or prefer -- and quietly excludes "ask again", which is the standard shape every - compare-exchange loop in this workspace already uses. - - ### What is left to decide - - Retry removes the transient cases, so what reaches representation is proved genuine. Remaining: - - 1. ~~What is compared, to call a collection coherent.~~ **Not a decision -- an inventory, and it - falls out of the implementation.** What *can* be compared is fixed by the data: whatever both - sources report about the same thing, which is the processor sets naming each other, the core and - NUMA groupings, and per-processor efficiency class. The one trap -- comparing *labels* rather than - memberships, which would flag `[0, 2, 4, ...]` against `[0, 1, 2, ...]` as a conflict when the - sources fully agree -- is already closed by [D-15](DESIGN-NOTES.md#d-15). - And under [D-16](DESIGN-NOTES.md#d-16) a *partial* comparison is actively wrong: an incoherence in - an uncompared fact survives the retry and is never classified, defeating the mechanism. So retry - forces comparing everything, which makes the scope determined rather than chosen. - It was listed as a decision while the question was still "detect or not, and how much", where - scope would genuinely have been a knob. `D-16` removed the knob. - - 2. **The bound**, and what exhausting it *means* -- not a failure to collect, but the **conclusion** - that the disagreement is genuine, and the point at which the partition and attribute shapes - apply. The *meaning* is settled by [D-16](DESIGN-NOTES.md#d-16); only the number is open, and it - is small -- a couple of passes failing to find a coherent set is not plausible. - - 3. **How a topology records its coherence.** *Records*, not reports -- an earlier draft of this item - said "precisely enough to file a bug", which put a downstream concern in the crate that states - facts, the same layering error as `outermost_partitioning_cache`. - What is required is what each source said, kept rather than collapsed, so a reader can tell how - far the parts may be **correlated** -- a different question from whether any one part is accurate. - Turning that into something actionable, with the identifying provenance an actionable report - needs, is the probe tools' job and is tracked as **M7** in - [CHECKLIST-placement-tool.md](../../CHECKLIST-placement-tool.md). - > **-> CROSS-COMPONENT HANDOFF:** the reporting half is `PT-7.1` and `PT-7.2` in - > [CHECKLIST-placement-tool.md](../../CHECKLIST-placement-tool.md). That tool already carries the - > review this needs -- the runner sees real values before sending (`PT-4.5`), the README lists what - > is collected (`PT-4.3`), and suppression is recorded rather than merely absent. - Only possible because [D-15](DESIGN-NOTES.md#d-15) keeps both observations: a disagreement cannot - be reported after it has been collapsed. - - 4. **The attribute shape has no representation.** `(kind, membership)` identity does not reach a - per-processor scalar disagreement, and that gap is untouched by any of the above. - - ### Closed by [D-18](DESIGN-NOTES.md#d-18) - - **The attribute shape (4):** an observation is `(subject, claim, source)`, and a **subject** is either a relation - identity `(kind, membership)` or a processor attribute `(processor, attribute)`. The mechanism above - it is unchanged -- observations of one subject are a set, agreement is one subject observed twice, - disagreement is a set with more than one distinct claim. So the second shape needs no second - mechanism. D-15 had simply described the subject too narrowly, having been derived from the one case - that was measurable at the time. - - **Recording coherence (3):** two facts, one derivable and one not. *That* collection concluded - incoherently is a fact about the process -- the retry ran, the bound was exhausted, the sources still - disagreed -- and nothing in the data says so, so it is recorded. *Which* subjects disagreed is - derivable, and is recorded anyway: leaving it to be re-derived is exactly the arrangement `SH-16.9` - documents going wrong three times in two different ways. A rendered report is **not** recorded; per - [D-17](DESIGN-NOTES.md#d-17) that belongs to the probe tools. - - **The bound (2):** a small documented constant, cheap even when exhausted, since a persistently - inconsistent machine pays only a few extra whole-machine enumerations. Its meaning was already - settled by [D-16](DESIGN-NOTES.md#d-16) -- exhaustion is the **conclusion** that the disagreement is - genuine, not a failure to collect, and `discover()` still returns a topology. - - **What made this closable** was not one insight but the arsenal accumulating: D-15 gave the identity, - D-16 removed the transient cases so only proved-genuine ones needed representing, D-17 moved - reporting out of the crate, and D-18 widened D-15's subject. Three of the item's four questions - dissolved rather than being answered -- one already covered by a prior decision, one forced by the - retry mechanism, one a constant with a rationale. - - Refusing outright remains rejected on its own merits: a genuine inconsistency is something a caller - would rather be told about and route around than be unable to run at all. - - **A finding that came out of checking it:** `online` (GLPIE, from `active_processors`) and `parked` - (CPU Sets) are **complementary, not overlapping**. Parked is not offline -- a parked processor is - active and the scheduler is merely avoiding it. So the two sources together give a *fuller* - availability picture than either alone, which is an argument for consuming both that has nothing to - do with conflict. - - **And a fourth, within a single source:** a processor named by two `Core` domains, from malformed - firmware or a hand-built description. Unchecked today. - - Two questions this block used to ask are now answered and are not repeated: whether `discover()` - detects at populate time (**yes** -- it must, in order to retry), and whether it may prefer a source - (**no** -- [D-15](DESIGN-NOTES.md#d-15) rejected reduce-on-insert, and preferring is that by another - name). What remains is listed above. - -- [x] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that - the model answers without further measurement. Degrade to a documented weaker policy, refuse, or - answer with an explicit "chosen without knowing X" marker. - **Narrowed by [D-19](DESIGN-NOTES.md#d-19), then resolved as not-this-crate's by - [D-21](DESIGN-NOTES.md#d-21).** D-19 removed two thirds of it: a contested subject is one the - unified view does not cover, which is D-13's not-observed, so there is one degradation path rather - than one per reason a fact is missing. - D-21 then places the remainder. This crate publishes a **refined view of what the platform - publishes**; what a consumer *does* with an unobserved fact is not a question about that view. The - model owes only that the absence be **representable and distinguishable** -- which is - [D-13](DESIGN-NOTES.md#d-13), implemented by **M2+.5**. The behavioural decision is the consumer's - and stays with `EP-1.4`. - **This item was gating M2, and through it M3, M4 and M5** -- a decision that was never the model's - to make was holding the whole reshape. Recorded as a planning defect rather than quietly fixed: it - landed in a "decisions that shape everything below" milestone because it *looked* foundational, and - foundational-looking is not the same as being about this component. - > **-> CROSS-COMPONENT HANDOFF:** the behavioural half is `EP-1.4` in - > [topology-planner](../topology-planner/CHECKLIST.md). It no longer has a counterpart here, so it - > is that component's decision alone rather than a joint one. - -- [x] **MMT-1.4** -- **Does `distances` survive at all?** The two-component architecture says the - *synthesizer* measures, with the caller's permission, for its own scenario -- so a measured number - is its working state and its justification for a choice, not a property of the machine. That - reverses this session's earlier conclusion that measured facts must live in the model, which - assumed a single component. If it holds, `distances` is **deleted rather than filled**, which is - the opposite of what the release checklist proposed. Decide before removing anything. - **Decided by the engineer, and the ruling is a scope boundary rather than a judgement about the - field: this crate does not go below the Win32 topology APIs, so if they do not provide distance - data, we do not have distance data.** Recorded as [D-20](DESIGN-NOTES.md#d-20). `distances` is - deleted. - **What the check found:** zero read sites. `render_node_distances` in `windows-platform-probes` - reads the *probe's own* measured `Observation`, not this field, so the one thing that looked like a - consumer is not one. Removal is spawned as **M5+.5** and is not gated on the reshape. - -- [x] **MMT-1.5** -- **Does the synthesizer live in this crate, and therefore what is this crate - called?** Recorded as open rather than settled: see - [topology-planner/COMPONENT.md](../topology-planner/COMPONENT.md). The naming follows - the merge rather than leading it -- while this crate is only a Win32 wrapper, `-sys` is correct - for it; if it gains a synthesizer that measures, it stops being one and the name should change - then. - **Answered by the engineer's architectural shift, recorded as - [EP-D-4](../topology-planner/DESIGN-NOTES.md#ep-d-4): no.** The planner is a separate - component named **`topology-planner`** -- with no `windows-` prefix, because it plans against an - abstracted idealized machine and emits a platform-neutral plan. So this crate does not gain the - synthesizer, remains a pure Win32 wrapper, and **keeps its name**. - What settles it is not a naming preference but the shift's second half: the planner queries an - *abstract* model through traits, and **adapters** bridge this crate's objects to those traits. A - crate that is one side of an adapter boundary is exactly what `-sys` names. - [D-20](DESIGN-NOTES.md#d-20) reinforces it from the other direction -- a crate whose scope is - "what the Win32 topology APIs report" is a `-sys` crate by construction. - -## M2: the granularity model - -**Ready.** M1 is closed, and [D-21](DESIGN-NOTES.md#d-21) makes every item here a refinement of what -Windows reports rather than something a planner asked for. - -**Re-planned 2026-09-03, on execution.** Checking these six against the code they reshape found two -that already describe the status quo and three that are one deliverable. Recorded rather than -quietly worked around, per the re-plan rule. - -- [x] **M2+.1** -- Model **observed sharing relations**, not a ladder of levels with optional rungs. - A machine with no L3 has no L3 relation, which is an observation rather than a missing value. - **Already satisfied; this item described the existing design.** `Domain` *is* a relation over a - `ProcessorSet`; `DomainKind` is open with seven kinds (D-4); `Cache { level: u8 }` has no fixed - rungs; and `cache_levels()` is documented as "derived from what the topology actually contains - rather than from a fixed ceiling", with a regression test guarding the exact hazard this item - names. A machine with no L3 simply has no `Cache { level: 3 }` domain today. - Not absorbed silently: separating *relation identity* `(kind, membership)` from the **label** - `Domain::id`, which [D-15](DESIGN-NOTES.md#d-15) requires, is real remaining work -- but it is - observation work and belongs to **M3**, not here. - -- [x] **M2+.2** -- Derive the order from **observed set inclusion**, never from firmware level - numbers. Inclusion is checkable; numbering is asserted, and this crate has been bitten by asserted - structure before -- the ARM64 host with no L3, and the guard test against a consumer sweeping - `1..=4`. - **The concrete target:** `cache_levels()` sorts by firmware `level`, and - `outermost_partitioning_cache()` walks it with `.rev()` -- so today's only ordering *is* firmware - numbering, which is what this item forbids. - -- [x] **M2+.3** -- Give the order an explicit **top** ("the machine"), so a pairwise query is total. - Two processors always share one address space, one scheduler and one memory system; without a top, - every caller writes the same empty-case branch for a cross-node pair. - -- [x] **M2+.4** -- Represent **incomparable** granularities. An inclusion order is partial, so two - granularities may not nest, and the honest answer to "tightest shared" is then a set of minimal - elements -- almost always one, but not by construction. - - > **M2+.2, M2+.3 and M2+.4 are one deliverable and land in one commit citing all three.** They are - > not independently implementable: an inclusion-derived order cannot be defined without deciding - > what its top is and what happens when two elements do not nest, and a type that answered only one - > of the three would not compile into anything coherent. This is the acknowledged-coupling case, - > named rather than disguised by splitting the commits. - > - > **Shape:** the order is over *relations*, compared by processor-set inclusion, with a synthetic - > `Machine` top that is **not** inserted into `domains` -- putting it there would claim the platform - > observed it. The operation the order exists to support is "the minimal relations containing this - > set of processors", which returns a `Vec` precisely because M2+.4 says minimality need not be - > unique. The *pairwise* query built on it, with membership and the upper-bound flag, is `M4+.1`. - > - > **Done.** `src/granularity.rs` -- `Granularity::{Relation, Machine}`, - > `MachineMemoryTopology::{machine_processors, minimal_shared, is_finer_than}`, on a new - > `ProcessorSet::is_subset`. 21 tests. - > **The top is a fallback, not a competitor**: `Machine` is returned exactly when no reported - > relation covers the query, so it never appears beside an observed relation. The alternative -- - > treating it as an ordinary element -- was rejected on measurement of its consequence: on a machine - > whose group domain spans every processor, every answer would carry a redundant second element, - > which is systematic noise rather than M2+.4's "almost always one". - > **Totality is over processors the topology knows.** A query naming an unknown processor answers - > *empty*, not `Machine`, because claiming the machine contains a processor it has never heard of - > would be an invention. - > **Sabotage-verified, and it found a real gap.** Removing the strictness from minimality failed 10 - > of 20 tests. Breaking `is_subset` failed only **one**, incidentally -- the new primitive the whole - > order rests on had no direct tests, and every granularity test used group 0 alone, so the - > multi-group path was untested. Seven `is_subset` tests and a cross-group order test took that from - > 1 detection to 4, two of which name the defect directly. - -- [x] **M2+.5** -- Make absence first-class per [D-13](DESIGN-NOTES.md#d-13): **not observed**, - **observed and absent**, and **a negative result** are three different facts that an `Option` - spells identically. Per [D-19](DESIGN-NOTES.md#d-19) this also carries the contested case -- a - subject the sources genuinely disagreed on is one the unified view does not cover, which is *not - observed*, so no fourth state is added. - **Deliverable: the vocabulary type**, which `M5+.2` and `M5+.4` then consume -- M5+.4 already says - "M2+.5 gives it the vocabulary to accept one". Independent of M2+.2/.3/.4, so it lands separately. - **Done.** `src/observed.rs` -- `Observed` with `Known`, `Absent`, `NotObserved`, plus `known()`, - `was_observed()`, `map()`, and a `Default` of `NotObserved` (D-12's reasoning: forgetting a field - must not assert something about the machine). 9 tests. - **Two variants, not three, and the omission is deliberate.** The *negative result* is not an - absence -- it is a computed answer whose value happens to be "no" -- so giving it a variant would - re-create the conflation the type removes. It stays an ordinary value, or an `Option` documented as - meaning exactly that. - **Sabotage-verified:** making `was_observed()` treat `Absent` as a gap -- the precise conflation - this type exists to prevent -- is caught by the test named for that claim. - Not yet *applied* to any field: `M5+.2` (`memory_bytes` from a description) and `M5+.4` (the probe - refusing a partially-covering cache level) are the sites, and both are M5 items. - -- [x] **M2+.6** -- **Relations carry attributes, not only memberships.** Required by - [D-19](DESIGN-NOTES.md#d-19): once the relation set *is* the unified model, - `DomainKind::Memory { memory_bytes }` and `Core { efficiency_class, simultaneous_multithreading }` - have nowhere to live unless a relation holds a payload alongside its processor set. - **Already satisfied, and the premise was wrong.** `DomainKind` has carried per-kind attributes - since D-4: `Memory { memory_bytes }`, `Core { simultaneous_multithreading, efficiency_class }`, - `Cache { level, associativity, line_size, size_bytes, cache_type }`, and `Other { name, attributes - }` for a kind this crate cannot interpret. Nothing had "nowhere to live". - The item was written from the abstract `(kind, membership)` framing while recording D-19, without - checking it against the type -- which had solved this two decisions earlier. Kept rather than - deleted because it is the second time in this milestone that an item asserted a gap the code did - not have, and once is a slip while twice is a method problem: **check the item against the code - before planning work from it.** - -## M3: observation and provenance - -**Ready.** M1 is closed. - -**Re-planned 2026-09-03, before implementing.** Checking these against the code found `M3+.3`'s -premise wrong in the same way `M2+.6`'s was, and found work this milestone had been assigned but -never given an item. Recorded rather than absorbed silently -- this is the third item in the reshape -to assert a gap the crate does not have, and the pattern is what the M2 re-plan named: **check the -item against the code before planning work from it.** - -- [x] **M3+.1** -- Provenance is **per relation**, not per source. Per-relation subsumes per-source - by repetition, and the reverse fails on the case that matters: two sources describing the *same* - relation. - **What that means concretely, which the item did not say.** "The case that matters" does not exist - in the code yet: `domains` is built from `GetLogicalProcessorInformationEx` alone, and `cpu_sets` - sits beside it as a parallel list, so no relation is currently described by two sources. Satisfying - this item therefore means **unifying the two sources into one relation set keyed by - `(kind, membership)`** per [D-15](DESIGN-NOTES.md#d-15), with each relation recording which sources - observed it. That is the heart of [D-19](DESIGN-NOTES.md#d-19)'s unified view, and it does not - exist yet. - **It absorbs the `Domain::id` work rather than leaving it a separate item.** D-15 requires the - *label* to move from the relation to the observation, and unification forces it: the two sources - agree on the core partition while labelling it `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]`, so a - single unified relation cannot carry one `id`. The two are the same change. - *(The M2 re-plan said this work "belongs to M3" without filing it anywhere, so until now it was - owned by no item at all.)* - **Split into three sub-steps on execution**, because `Domain::id` turns out to have **14 uses - across three other crates** -- `windows-ioring-sys`' `ring_copy` example, `windows-placement-probe`, - and `windows-platform-probes` -- plus a hand-written JSON shape. Doing this as one commit would mix - an additive change, a behavioural one, and a cross-crate breaking one. Each sub-step below compiles - and is testable on its own, and the breaking change is last and isolated. - - - [x] **M3+.1.1** -- Introduce `Source` and `Observation`, and give `Domain` its observations, - populated by `from_relations` with the label it currently puts in `id`. **Additive**: `id` stays, - nothing downstream changes, and the unified view has somewhere to record what it unifies. - **"Nothing downstream changes" was wrong** -- the fourth item in this reshape to assert something - the code contradicts. `Domain` has public fields and is not `#[non_exhaustive]`, so a new field - breaks every struct literal: **59 of them across five crates**, 17 outside this one. rustc - enumerated each site; test literals take `Vec::new()` (a hand-built relation nobody reported, - which is honest) and the seven real builders in `from_relations` take a genuine - `Source::RelationshipWalk` observation carrying the label they already used. - **Deserialization drops platform observations, and that is the point.** The wire shape does not - encode them and no `Description` observation is synthesized. Carrying "the relationship walk - observed this" out of a file would be exactly the forgery [D-12](DESIGN-NOTES.md#d-12) refuses, - and synthesizing `Description` would restate what the object's `Provenance::Restored` already - says -- which [D-22](DESIGN-NOTES.md#d-22) had just finished separating. Twelve round-trip tests - failed on this and were right to: the question was real, not a test defect. - - - [x] **M3+.1.2** -- Fold CPU Sets into the relation set. For a `Core` or `Memory` membership that - matches an existing relation, add an observation; otherwise add a relation observed only by CPU - Sets. **This is where the unified view comes into being** -- today `discover` builds `domains` - from the relationship walk alone and leaves `cpu_sets` beside it, so the two sources have never - met. - Deliberately *not* folded: `LastLevelCacheIndex`. Per [D-14](DESIGN-NOTES.md#d-14) it answers a - different question from the derived cache partitioning -- one LLC group where the derivation finds - eight L2 partitions -- so under [D-15](DESIGN-NOTES.md#d-15) it is a **different relation**, not a - second observation of the same one. `EfficiencyClass` is likewise a per-processor attribute rather - than a membership, and belongs to [D-18](DESIGN-NOTES.md#d-18)'s other subject kind. - **Done, and measured rather than asserted.** On this host the fold produces 44 relations, **9 - doubly observed** and 0 CPU-sets-only: the eight cores carry `walk#N` beside `cpuSets#2N`, which - is D-15's `[0,1,...,7]` against `[0,2,...,14]` with both labels now kept, plus the single NUMA - node. Caches carry walk observations only, per D-14. - `cpu_sets` is still kept verbatim beside the folded view -- D-19's unified model is presented *in - addition to* the individual ones, not instead of them. - **A fabrication caught before it shipped.** The first version defaulted a CPU-sets-only core's - `efficiency_class` to `0`, which would have reinvented the `Processor::capacity` sentinel this - reshape exists to remove, since `0` is a legitimate class. It now takes the value from the records - themselves. - **Sabotage found a second host-shaped test gap.** Weakening the match from equal membership to - containment passed all 169 tests, because every membership on this machine is *exactly* equal so - the two rules coincide. Four synthetic fold tests -- built to disagree on purpose -- now catch it, - and the sabotage had to be injected in the semantically plausible direction to be meaningful. - - - [x] **M3+.1.3** -- Remove `Domain::id`, now that observations carry the labels, and update the - three downstream crates and the wire shape. **Breaking**, and correct: there is no single - canonical id once two sources label the same relation differently -- measured as - `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]` -- and a relation observed only by CPU Sets has no - walk label at all. Keeping `id` beside the observations would be two statements of one fact, which - is the restatement drift this repository has a rule about. - **Done.** `Domain::label_from(source)` and `observed_by(source)` replace it; the accessor takes a - source *because there is no answer without one*. 59 literals and 14 call sites across four crates, - all located by rustc rather than by a regex guessing at them. - The wire `"id"` survives as an informational field: written when the walk labelled the relation, - and **optional on read and discarded**, since a file cannot establish which source observed - anything (D-12). Making it optional was forced by the change -- serialization stops writing it for - a relation no source labelled, so requiring it would have rejected descriptions this crate itself - produces. - **A real bug, caught by the tests and not by review.** Replacing `domain.id` with a positional - index in `windows-placement-probe` is fine for the cache and core maps, which only need an - equivalence class -- but `numa_of`'s value reaches **`VirtualAllocExNuma`**, so a position would - allocate on the wrong node on any machine whose nodes are not numbered `0..n`. It now takes the - walk's label, which is the real node number. The fixture's nodes happen to be `0` and `1`, so the - NUMA assertion passed by coincidence and only the cache assertion failed -- the bug was one - coincidence away from shipping. - - - [x] **M3+.1.4** -- **Record a per-processor attribute conflict**, which relation unification - cannot reach. `M3+.1.2` matches relations by `(kind, membership)`, so two sources describing one - core agree on *that* even if they disagree about its `efficiency_class` -- and the unified - relation keeps the walk's value while the CPU-sets value goes unrecorded. - This is [MMT-1.2](CHECKLIST.md)'s **attribute shape** and [D-18](DESIGN-NOTES.md#d-18)'s second - subject kind: an observation whose subject is `(processor, attribute)` rather than - `(kind, membership)`. Filed as its own item because the fold's doc comments cite it, and a rule - cited in code but scheduled nowhere is exactly the orphaning the "design notes are not a work - queue" rule exists to stop. - **Not observable on this host** -- every efficiency class reads `0` here -- so it is testable only - synthetically, per [D-17](DESIGN-NOTES.md#d-17). - **Done.** `ProcessorAttribute`, `AttributeObservation`, and - `MachineMemoryTopology::{processor_attributes, attribute_conflicts}`. The walk's claim is fanned - out from each core to its processors rather than left for a consumer to re-derive -- that - reconstruction is what this model exists to stop. - **Reported, never resolved.** `attribute_conflicts` names the contested subjects and both claims - stay: picking a winner would destroy the disagreement, and on a hybrid part the choice decides - whether a processor is treated as a performance or an efficiency core. - Five tests, four of them synthetic, plus one that asserts *this host has no conflict* -- measured - rather than standing in for the conflicting case. Sabotage-verified: making agreement count as a - conflict fails three of them. - -- [x] **M3+.2** -- Keep two properties of the old `Provenance` **because they re-derive**, not - because they were there: the default is the untrusted value (a *stronger* argument per-relation, - since there are more places to forget), and trust never upgrades (a file still cannot establish it - describes the machine you are on). - **Done, and both turned out to be already satisfied by `M3+.1.1` rather than needing new - mechanism** -- which is the item's own claim vindicated: they *re-derive*. - The untrusted default is an **empty** observation list. A relation nobody reported says exactly - that, rather than defaulting to a source and asserting something no API said; nothing fills it in - on a caller's behalf. - Never-upgrade is deserialization dropping platform observations, so a description claiming the - relationship walk observed something cannot establish that it did. Both now have named tests - asserting them at the relation level, beside the object-level ones D-12 already had. - -- [x] **M3+.3** -- ~~Supersede the whole-object `Provenance` **without replacing it with another - whole-object scalar**. With trust per relation, an object-level scalar can only be the minimum -- - ninety-nine measured relations and one synthetic reading `SYNTHETIC` -- or the maximum, which is - dishonest. Trust belongs to an *answer*.~~ - **Rewritten. The premise is wrong, recorded as [D-22](DESIGN-NOTES.md#d-22).** `Provenance` is not - an aggregate of anything: it records **how the object was obtained** -- `discover()` stamps - `Measured`, deserialization is capped at `Restored`, hand construction defaults to `Synthetic`. That - is a fact about the construction *act*, and no per-relation value can express it. The mixed - "ninety-nine measured and one synthetic" case cannot arise from collection at all; it needs someone - to hand-insert a relation into a discovered topology, which is exactly what per-relation provenance - makes **visible** rather than a reason to delete the object-level fact. - It also has a real consumer that wants precisely it: `windows-placement-probe`'s - `Record::is_trustworthy` gates on `is_measured()` to decide whether a measurement counts, and its - record schema carries the value at the top level deliberately so a collector need not reach into - the fingerprint. - **Revised deliverable:** keep the type, and make its documentation say what it actually means -- - the construction act, orthogonal to per-relation provenance -- so the next reader does not repeat - this item's mistake. - **Done.** `Provenance`'s documentation now opens with "what this records: the construction act", - states the orthogonality, and names the superseded argument so it is not re-proposed. No type or - behaviour change: the correct outcome here was *not* changing the code. - -- [x] **M3+.4** -- Carry both observers without merging, per MMT-1.1's decision. `Topology::cpu_sets` - already lands this way; this item is whether that stays a parallel list or becomes observations - attached to relations. - **Answered by [D-19](DESIGN-NOTES.md#d-19): both.** The question presented the two as alternatives, - and they are not. Observations attach to relations, which is what makes the *unified* view exist at - all; the raw per-source list stays for a caller that wants what one source said, verbatim. That is - what "a unified model in addition to the individual ones" means concretely. No implementation is - owed here -- M2+.6 and M4 carry the surface -- so this item is closed as a decision, not as code. - -## M4: the queries - -Parked on M2 and M3. - -**Each is a refinement of what Windows reports**, per [D-21](DESIGN-NOTES.md#d-21) -- not a planner -requirement, which is how they were first justified. The change matters because it changes what is -in scope: a query the platform's data supports belongs here whether or not any planner wants it, and -a planner requirement with no platform correspondence belongs to the adapter. - -Read on their own terms, most of these were never planner-shaped. `M4+.2` and `M4+.3` are ordinary -facts about processors and memory stated without sentinels; `M4+.4` fixes a rule the **probes** have -restated three times in two crates. Only `M4+.1`'s pairwise helper is consumer-flavoured, and the -ordered collection it derives from is what stops that restatement recurring. - -They remain cross-referenced to [topology-planner](../topology-planner/DESIGN-NOTES.md) as -**evidence** the shape is right rather than as its justification -- stating those requirements found -the `Processor::capacity` sentinel collision that reviewing the model alone had not. - -- [x] **M4+.1** -- **The ordered relations are the query surface; pairwise proximity is a method on - them.** The requirement arrived from [EP-D-2](../topology-planner/DESIGN-NOTES.md#ep-d-2) as a - *pairwise* query returning the minimal shared granularities, **their membership**, and whether a - finer granularity went **unobserved** so the answer can be an upper bound and say so. All three - requirements stand. The **shape** does not, and the requirement says so itself: it asks the answer - to carry the whole block containing both processors, "or the planner asks O(n^2) times and - reconstructs the grouping". An answer that must carry the block is not about the pair -- the pair is - an index into a partition. - Everything the planner does is an operation on the partitions: choosing domain granularity is - *selecting one*, sizing an MPSC fan-in is *a block's cardinality*, choosing a channel is *the finest - block containing both*. Pairwise is three lines over that. The reverse is derivable too, but only by - union-find over O(n^2) queries -- which is exactly the reconstruction `SH-16.9` records three - consumers performing, two of them differently. Building pairwise as the primary surface would ship - the stated requirement and re-create the defect one level up. - Both are provided; **the collection is primary and the pairwise helper is derived from it**, so - there is one implementation of the grouping. - *Terminology:* it is a **poset with a top**, not a lattice -- M2+.4's incomparable granularities - mean meets need not be unique, which is also why a pairwise function has to return a *set* and is - an awkward face on an ordered collection. - **Done.** `Proximity { shared, finer_unobserved }` and - `MachineMemoryTopology::proximity(&[ProcessorId])`, whose body is `minimal_shared` plus the - coverage check -- so there is one implementation of the grouping, not one per caller. - Generalised past a pair, because nothing in the query is specific to two: the same call sizes an - MPSC fan-in over a whole block. - **The third requirement is the one that needed building.** `finer_unobserved` says the answer is an - **upper bound**: some kind the machine reports covers one of these processors in no instance, so the - platform has said nothing about whether they share it. The distinction that makes it honest is - [D-13](DESIGN-NOTES.md#d-13)'s -- absence of a *kind* describes a machine without it, while absence - of a processor *from* a kind that exists is a gap. `Memory` is excluded because a processor in no - memory domain is `M4+.3`'s unplaced case, which has its own answer. - `Proximity::only()` returns `None` on a tie rather than taking the first, so a caller that cannot - handle M2+.4's multi-element answer has to say so. - Sabotage-verified: dropping the kind-exists guard -- which would make a machine with no caches look - incomplete -- fails three tests, including the D-13 conflation case. - -- [x] **M4+.2** -- The **shard-set** surface (EP-D-1): identity as `(group, number)`, online, core - membership and SMT, efficiency class **without a sentinel**, and availability. - **Done.** `ProcessorFacts` and `MachineMemoryTopology::shard_set()`. Every optional field is an - `Observed`, so "the platform said zero" and "nobody asked" are different values -- which is the - `M5+.1` collision fixed from the other side, since `Processor::capacity` spells offline, in-no-core, - and class-zero as the same `0` and the third is every processor on every non-hybrid machine. - **It states availability and does not judge it.** A `usable()` helper was written and then removed: - which of online, parked and allocation disqualifies a processor is a **policy**, and per - [D-21](DESIGN-NOTES.md#d-21) this crate states facts -- baking the judgement in is exactly what - `outermost_partitioning_cache` was criticised for. It would also have been wrong, which is how the - next item was found. - -- [x] **M4+.3** -- **Residency** (EP-D-3): processor to memory domain, with the unplaced case - distinguishable rather than defaulted -- an unknown cache domain costs an optimisation, an unknown - memory domain has no honest fallback. - **Done.** `memory_domain_of(processor) -> Observed<&Domain>` and `unplaced_processors()`. The - unplaced case is `NotObserved`, never node zero: the pool has to be allocated somewhere, and - guessing means quietly allocating remote memory for the life of the process. `Observed::Absent` is - never returned -- a memory domain covering no processors is a real shape (D-5), but a processor - belonging to no node is a gap in what the firmware said, not a statement that it has no memory. - -- [x] **M4+.4** -- Reduce `outermost_partitioning_cache` to a **named projection** over the order -- - "the coarsest granularity with more than one group" -- so it is a query rather than a rule, and - cannot be restated wrongly because there is nothing to restate. - **Note on what "over the order" can and cannot mean here.** M2's order is over *relations*, and a - partition is a *set* of relations, so the projection needs both: a **grouping** key to say which - relations form one candidate partition, and an **order** to say which candidate is coarsest. Level - is used for the first and must not be used for the second -- grouping by level reads what the - source said, whereas ordering by level asserts that a higher number is always coarser, which is the - structure `M2+.2` forbids and this host's ARM64 sibling disproves. - **Done.** The projection now collects every level that forms a partition, then picks the one no - other **refines** -- checkable against the memberships Windows reported, where "a higher number is - coarser" is asserted. - **Every pre-existing test passed under both rules**, so the change would have been invisible: the - discriminating case is a machine whose *lower* level forms the *coarser* partition, which no - fixture built from real hardware has. Reverting to the old `.rev()` over level numbers now fails - exactly one test, and it is the one written for that case. - -**Overlap noticed on reading, not deferred:** `M4+.2`'s "efficiency class **without a sentinel**" and -`M5+.1`'s `Processor::capacity` collision are the same defect from two sides. They land together, in -`M4+.2`, and `M5+.1` records that rather than repeating the work. - -- [x] **M4+.5** -- **Verify the CPU-set flag bit positions against the SDK**, which `M4+.2`'s - measurement gave evidence are wrong. `allocated_to_this_process` reads **false for every processor** - on the development host -- for a process plainly running on them, which is not a credible answer. - `parked` reads false everywhere too, so neither has ever been observed true and nothing distinguishes - "the bit is clear" from "we are reading the wrong bit". - The positions in `cpu_set::flags` are transcribed from the SDK's bitfield order and the module's own - doc calls changing them a breaking change -- but transcription is exactly the **asserted structure** - this reshape exists to distrust, and it has never been checked against a machine where the answer - would differ. - **Until it is verified, no behaviour may depend on these flags.** That is why `M4+.2` ships the - values as facts and no judgement over them; a `usable()` helper written against them refused every - processor on this machine. - **Investigated, and the answer is neither of the two the item expected.** Recorded as - [D-23](DESIGN-NOTES.md#d-23). - The bit positions are **not** wrong -- and cannot be shown right either. The whole `AllFlags` byte - reads `0x00` for every processor, so no bit has ever been observed set and nothing distinguishes a - correct transcription from a wrong one. They stand on the SDK's declared order alone. - The byte is **not populated on this build**, which is the actual finding and was established by - experiment rather than argued: `SetProcessDefaultCpuSets` succeeded, `GetProcessDefaultCpuSets` - confirmed the allocation stuck (`[0x100, 0x101]`), and the byte still read zero -- under a null - handle, the pseudo-handle, and a real `OpenProcess` handle alike. Windows 11 25H2 - (10.0.26200.9168, AMD64). - **And the field's own documentation was wrong**, which the experiment exposed on the way: - `allocated_to_target_process` does not mean "may we run here". It means the CPU set was explicitly - allocated via `SetProcessDefaultCpuSets`, which an ordinary process never does -- so `false` is the - ordinary answer for a processor it is entirely free to run on. The old doc said the opposite. - A regression test pins the measurement, so a build that *does* populate the byte is noticed rather - than silently changing what these fields mean. That build is also the only thing that could finish - verifying the bit positions. - -## M5: the defects this subsumes - -Parked on M4, **except M5+.5, which is independent and ready now**. Each of the others already -exists as a defect that the reshape is what fixes, so they are listed here rather than fixed -separately and then re-fixed. - -- [x] **M5+.1** -- `Processor::capacity` uses `0` as both a legitimate efficiency class and a "not - known" sentinel, and the two collide on **every non-hybrid machine**. Worse than an ambiguous - `Option`: a colliding sentinel cannot be distinguished even by a careful caller. - **Subsumed by `M4+.2`, which is where the sentinel-free answer lives**: `ProcessorFacts` reports - `efficiency_class: Observed`, so class zero and "no core names this processor" are different - values. `Processor::capacity` itself is left in place and its documentation already warns against - it -- removing a published field is a second breaking change with no additional benefit once the - honest answer exists beside it. - -- [x] **M5+.2** -- `DomainKind::Memory::memory_bytes` is unambiguous from `discover` but ambiguous - from a **description**, where "the field was omitted" and "this node's capacity is unknown" are the - same value. The [D-13](DESIGN-NOTES.md#d-13) audit found this and documentation cannot fix it. - **Done**: the field is now `Observed`, and serde carries all three states distinctly -- a - number for `Known`, an explicit `null` for `Absent` ("this node has no memory of its own"), and - **omission** for `NotObserved`. The two that used to collide are now different bytes on the wire, - so a description round-trips through the distinction rather than losing it. - -- [x] **M5+.3** -- The partitioning rule is stated **three times in two crates**, and two of the - three differ: `windows-platform-probes` omits the pairwise-disjointness check this crate requires. - M4+.4 removes the reason to restate it. - **Done, and by M4+.4 the restatement had drifted in a second way**: it also ordered candidates by - **level number**, which this crate stopped doing. `Observation` now captures - `partitioning_cache_level` from `MachineMemoryTopology::outermost_partitioning_cache` at survey - time and looks the summary up, so there is one implementation of the rule. - **An existing platform-probes test then caught a real regression in M4+.4** -- and it was right to. - On this host L1 and L2 split the machine into the **same** eight pairs, so neither refines the - other and the first-wins tie-break answered `L1`, naming the inner cache for a boundary the outer - one also owns. Ties between *identical* partitions now take the higher level, which reads the - source's own labelling of one boundary rather than ordering distinct partitions by number. - -- [x] **M5+.4** -- `windows-placement-probe` **refuses a partially-covering cache level** that this - crate deliberately hands back, failing an entire measurement run over a topology this crate - considers describable. M2+.5 gives it the vocabulary to accept one. - **Done**: `ProcessorPlace::cache_domain` is `Observed`, the `MissingPlacement::CacheDomain` - refusal is gone, and an uncovered processor reports `NotObserved` -- which is neither `Absent` - ("no level partitions this machine", the ordinary single-domain case) nor a fabricated domain. - **The hazard the refusal existed to prevent is closed one level down instead**: - `Slice::same_cache_domain` answers `None` when any participant is `NotObserved`, so two uncovered - processors are never reported as sharing a cache. Verified by sabotage -- deleting that guard turns - the answer into `Some(true)` and exactly one test goes red. - -- [x] **M5+.5** -- **Delete `MachineMemoryTopology::distances` and the `Distances` type**, per - [D-20](DESIGN-NOTES.md#d-20). **Not gated on M4**: the reshape does not fix this one, deletion - does, so it does not wait for the rest of M5. - A **breaking change to a published crate** (0.1.0), so the commit takes the Conventional Commits - `!` marker. It is not a *parse* break -- the crate does not `deny_unknown_fields`, so a description - carrying `"distances"` still deserializes and the field is ignored. - Two things to do rather than skip: keep the Linux-shaped description test, retargeted to assert the - field is now **ignored** rather than deleting the evidence that such a description parses; and note - in the doc comment that round-tripping such a description no longer preserves it, since that is a - real if small behaviour change and a silent drop is exactly what this crate has objected to - elsewhere. - **Done.** Field, type, and re-export removed; three call sites in `windows-placement-probe`'s - fingerprint fixtures updated. The Linux-shaped test survives as - `a_linux_shaped_description_parses_and_its_distances_are_ignored`, keeping the **populated** matrix - so what it proves is that an existing description still parses, and gaining an assertion that the - value does **not** reappear on re-serialize -- the silent drop asserted rather than assumed. - `distances_is_expected_to_be_square` was deleted with the type it tested (125 tests to 124). - Two stale statements sweeps found and fixed: the [D-13](DESIGN-NOTES.md#d-13) audit row, and the - Linux-comparison summary, which had recorded optional distances as a decision that *held up* -- - sound about the schema, and reversed by a ruling about scope. +- **The planner adapters.** Per [D-21](DESIGN-NOTES.md#d-21) this crate is the refined view of what the + platform publishes and is self-justified as such; the adapter onto + [topology-planner](../topology-planner/CHECKLIST.md)'s traits belongs on the planner's side of the + boundary, and is planned there. diff --git a/crates/windows-topology-sys/COMPLETED-CHECKLIST.md b/crates/windows-topology-sys/COMPLETED-CHECKLIST.md index e2c5b06e..88b24919 100644 --- a/crates/windows-topology-sys/COMPLETED-CHECKLIST.md +++ b/crates/windows-topology-sys/COMPLETED-CHECKLIST.md @@ -87,3 +87,721 @@ This file is append-only; new completed groups are added at the bottom. > **-> CROSS-COMPONENT HANDOFF:** with M4 complete this crate serves > [windows-ioring-sys](../windows-ioring-sys/CHECKLIST.md) -> `M7` (`ring-copy`, the topology-aligned > sample), which was blocked on this milestone and carries the reciprocal prerequisite callout. + +## Moved 2026-09-03 -- the MachineMemoryTopology reshape (MMT-*), M1 through M5 + +The plan that gated PR #56's merge, complete: 25 items across five milestones. It replaced the +ladder-of-levels model with **observed connectivity** -- relations carried as a set with per-relation +provenance, presence and observation represented as facts rather than inferred, and the pairwise +queries derived from an ordered partitioning rather than restated. Five items turned out to assert +gaps the crate did not have and were closed as already-satisfied or re-planned rather than +implemented; the notes below record which, and why. + +Design record: [DESIGN-NOTES.md](DESIGN-NOTES.md) `D-13` through `D-23`, and +[DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md). + +The context the milestones were written against, preserved because the items refer to it: +## What this is for + +This crate publishes a **refined view of what the platform publishes** +([D-21](DESIGN-NOTES.md#d-21)). The current model does that badly: it describes a machine as a list +of domains and answers questions with one global projection (`outermost_partitioning_cache`) that +three consumers have independently re-derived, two of them differently. It cannot say whether a fact +was observed or merely absent, it cannot answer anything about a *pair* of processors, and it +collapses a seven-kind, any-depth locality graph onto a single cache boundary. + +The reshape has one governing idea, settled with the engineer: **model the observed connectivity.** +Presence and observation are facts to represent, not shapes to infer from. + +**The scope test is "is this a refinement of what Windows reports?"** -- never "does the planner need +it?". [D-20](DESIGN-NOTES.md#d-20) draws the lower bound (the crate does not go below the Win32 +topology APIs); D-21 draws the upper one. A planner requirement with no platform correspondence is +the **adapter's** problem and must not be filed here as a gap. + +## Where this stands + +| Milestone | State | What it is waiting on | +|---|---|---| +| M1 settle what is still open | **5 of 5 done** | nothing -- complete | +| M2 the granularity model | **6 of 6 done** | nothing -- complete | +| M3 observation and provenance | **4 of 4 done** | nothing -- complete | +| M4 the queries | **5 of 5 done** | nothing -- complete | +| M5 the defects this subsumes | **5 of 5 done** | nothing -- complete | + +**M1 was decision work, not implementation**, and it is complete. Each item was a question the +session left open, and each would have changed the shape of everything below it. + +**M2 onward is implementation, and it is in scope for PR #56.** Per +[D-21](DESIGN-NOTES.md#d-21) the reshape is self-justified as the refined view rather than waiting on +a consumer, so nothing here is gated on the planner. Taking it into the current PR means +`windows-topology-sys` 0.2.0 ships the shape once, instead of publishing a surface already known to +be wrong and breaking again later. + + +## M1: settle what is still open + +- [x] **MMT-1.1** -- **Are several observations of one relation held as a set, or reduced on insert + with the reduction recorded?** No longer speculative: `GetLogicalProcessorInformationEx` and + `GetSystemCpuSetInformation` both report a processor's core, NUMA node and efficiency class, from + different kernel paths, and both are read today. A set is honest and pushes adjudication onto every + caller; reducing on insert is convenient and throws away the disagreement, which is the one thing a + second observer is for. + **Done, as [D-15](DESIGN-NOTES.md#d-15): a set -- but the reason is not the one above.** Measured + rather than argued. The two sources **agree exactly** on the core partition (eight groups each) and + **label it completely differently** (`[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]`). So the + disagreement a reduction would resolve is between *dictionaries*, not about the machine. + That makes **a relation identified by `(kind, membership)`**, with a source's label an attribute of + the *observation*. Reduce-on-insert is then not merely lossy but **arbitrary** -- it would pick + between two correct labels by coin toss, while the fact that mattered needed no reduction because + the sources agreed. And a set costs nothing in the common case: agreement is one relation with two + observations, not two competing relations. + **Honest about the evidence:** only the core comparison is strong. NUMA is one group here so it + matches under almost any bug, and efficiency class is zero everywhere -- which is both trivially + matchable and the exact value `Processor::capacity`'s sentinel is indistinguishable from, so that + row confirms nothing. A hybrid, multi-node machine would test all three; none is available. + +- [x] **MMT-1.2** -- **What a query returns when observations differ.** ~~And there are three cases, + not two~~ -- **the third case dissolved.** [D-14](DESIGN-NOTES.md#d-14) found that CPU Sets reports + one last-level-cache group where the derivation reports eight L2 partitions, neither wrong because + they answer **different questions**, and this item was going to have to invent vocabulary for it. + [D-15](DESIGN-NOTES.md#d-15) removes the need: under `(kind, membership)` identity, different + memberships at different kinds are simply **different relations**, so they never meet to disagree. + **What remains is narrower**: two sources claiming the same *kind* over overlapping-but-unequal + memberships -- a real contradiction about the machine. Decide what a query returns then: a value + plus a conflict marker, or the conflict itself, forcing the caller to adjudicate. + Note the detection machinery partly exists. Overlapping-but-unequal sets at one kind is exactly + what `are_pairwise_disjoint` checks for cache domains today -- though only at *query* time, inside + `outermost_partitioning_cache`, and `Core` and `Memory` domains are never validated at all. + + ### The specifics, since "observations differ" is too vague to decide on + + **Where it arises:** `discover()`, populating a `MachineMemoryTopology`. It makes **two separate, + sequential Win32 calls** -- `relation::discover()` then `cpu_set::enumerate()` -- and nothing + compares their results. + + **Two shapes of conflict, not one.** The item above describes only the first: + + - **A, partition conflict:** same kind, memberships overlap without being equal. GLPIE says a core + is `{0,1}`, CPU Sets groups `{0,1,2}` under one `CoreIndex`. `(kind, membership)` identity + from [D-15](DESIGN-NOTES.md#d-15) makes this detectable. + - **B, attribute conflict:** same processor, same attribute, **different scalar**. GLPIE's + `Core { efficiency_class }` against CPU Sets' `EfficiencyClass`. This is not a membership + question and D-15 does not reach it, which the item as first written did not notice. + + **A third case: `discover()`'s two calls are not atomic.** Raised, then twice mis-corrected, then + wrongly retired, and finally **answered by [D-16](DESIGN-NOTES.md#d-16): collect again.** The whole + path is kept because the wrong turns are instructive. + + If the incoherence is detectable and harmful, **re-initiate collection**. Both calls are + whole-machine enumerations and trivially inexpensive, so a retry costs almost nothing, and more + than a couple of passes failing to find a coherent set is not plausible. + + **Retry is also the discriminator this item twice claimed could not exist.** The assertion was that + a transient inconsistency and a genuine one are indistinguishable *from a single observation* -- + true, and the conclusion that the model must therefore tolerate the ambiguity does not follow. Stop + using a single observation: transience resolves on the next pass, and what survives is *proved* + genuine. So only what has already been classified reaches the representation question below. + + The earlier missteps, kept short: + + - **It is not a torn read.** Nothing tears -- each call returns a self-consistent snapshot and the + buffers are process-private. The accurate term is a *non-atomic composite*. + - **Parking cannot cause it**, which was the example first given. Parking changes a CPU-Sets-only + field; GLPIE does not report parked state, and none of the three overlapping facts move when a + core parks -- `CoreIndex` and `NumaNodeIndex` are unchanged, `EfficiencyClass` is static. + - **And then it was retired for proving too much** -- on the grounds that even an atomic + `discover()` returns a topology stale the instant it returns, so the two-call window is only a + larger instance of an unavoidable problem. True, and **not a reason to do nothing**: the two are + not equally addressable. Staleness after the fact is the executor's to validate, and is already + owned as `M-inf.1` in [topology-planner](../topology-planner/CHECKLIST.md). + Incoherence *during* collection is ours, detectable, and cheap to fix. + + The framing is what caused the miss. Asking "what do we **store** when sources disagree" admits + refuse, record, or prefer -- and quietly excludes "ask again", which is the standard shape every + compare-exchange loop in this workspace already uses. + + ### What is left to decide + + Retry removes the transient cases, so what reaches representation is proved genuine. Remaining: + + 1. ~~What is compared, to call a collection coherent.~~ **Not a decision -- an inventory, and it + falls out of the implementation.** What *can* be compared is fixed by the data: whatever both + sources report about the same thing, which is the processor sets naming each other, the core and + NUMA groupings, and per-processor efficiency class. The one trap -- comparing *labels* rather than + memberships, which would flag `[0, 2, 4, ...]` against `[0, 1, 2, ...]` as a conflict when the + sources fully agree -- is already closed by [D-15](DESIGN-NOTES.md#d-15). + And under [D-16](DESIGN-NOTES.md#d-16) a *partial* comparison is actively wrong: an incoherence in + an uncompared fact survives the retry and is never classified, defeating the mechanism. So retry + forces comparing everything, which makes the scope determined rather than chosen. + It was listed as a decision while the question was still "detect or not, and how much", where + scope would genuinely have been a knob. `D-16` removed the knob. + + 2. **The bound**, and what exhausting it *means* -- not a failure to collect, but the **conclusion** + that the disagreement is genuine, and the point at which the partition and attribute shapes + apply. The *meaning* is settled by [D-16](DESIGN-NOTES.md#d-16); only the number is open, and it + is small -- a couple of passes failing to find a coherent set is not plausible. + + 3. **How a topology records its coherence.** *Records*, not reports -- an earlier draft of this item + said "precisely enough to file a bug", which put a downstream concern in the crate that states + facts, the same layering error as `outermost_partitioning_cache`. + What is required is what each source said, kept rather than collapsed, so a reader can tell how + far the parts may be **correlated** -- a different question from whether any one part is accurate. + Turning that into something actionable, with the identifying provenance an actionable report + needs, is the probe tools' job and is tracked as **M7** in + [CHECKLIST-placement-tool.md](../../CHECKLIST-placement-tool.md). + > **-> CROSS-COMPONENT HANDOFF:** the reporting half is `PT-7.1` and `PT-7.2` in + > [CHECKLIST-placement-tool.md](../../CHECKLIST-placement-tool.md). That tool already carries the + > review this needs -- the runner sees real values before sending (`PT-4.5`), the README lists what + > is collected (`PT-4.3`), and suppression is recorded rather than merely absent. + Only possible because [D-15](DESIGN-NOTES.md#d-15) keeps both observations: a disagreement cannot + be reported after it has been collapsed. + + 4. **The attribute shape has no representation.** `(kind, membership)` identity does not reach a + per-processor scalar disagreement, and that gap is untouched by any of the above. + + ### Closed by [D-18](DESIGN-NOTES.md#d-18) + + **The attribute shape (4):** an observation is `(subject, claim, source)`, and a **subject** is either a relation + identity `(kind, membership)` or a processor attribute `(processor, attribute)`. The mechanism above + it is unchanged -- observations of one subject are a set, agreement is one subject observed twice, + disagreement is a set with more than one distinct claim. So the second shape needs no second + mechanism. D-15 had simply described the subject too narrowly, having been derived from the one case + that was measurable at the time. + + **Recording coherence (3):** two facts, one derivable and one not. *That* collection concluded + incoherently is a fact about the process -- the retry ran, the bound was exhausted, the sources still + disagreed -- and nothing in the data says so, so it is recorded. *Which* subjects disagreed is + derivable, and is recorded anyway: leaving it to be re-derived is exactly the arrangement `SH-16.9` + documents going wrong three times in two different ways. A rendered report is **not** recorded; per + [D-17](DESIGN-NOTES.md#d-17) that belongs to the probe tools. + + **The bound (2):** a small documented constant, cheap even when exhausted, since a persistently + inconsistent machine pays only a few extra whole-machine enumerations. Its meaning was already + settled by [D-16](DESIGN-NOTES.md#d-16) -- exhaustion is the **conclusion** that the disagreement is + genuine, not a failure to collect, and `discover()` still returns a topology. + + **What made this closable** was not one insight but the arsenal accumulating: D-15 gave the identity, + D-16 removed the transient cases so only proved-genuine ones needed representing, D-17 moved + reporting out of the crate, and D-18 widened D-15's subject. Three of the item's four questions + dissolved rather than being answered -- one already covered by a prior decision, one forced by the + retry mechanism, one a constant with a rationale. + + Refusing outright remains rejected on its own merits: a genuine inconsistency is something a caller + would rather be told about and route around than be unable to run at all. + + **A finding that came out of checking it:** `online` (GLPIE, from `active_processors`) and `parked` + (CPU Sets) are **complementary, not overlapping**. Parked is not offline -- a parked processor is + active and the scheduler is merely avoiding it. So the two sources together give a *fuller* + availability picture than either alone, which is an argument for consuming both that has nothing to + do with conflict. + + **And a fourth, within a single source:** a processor named by two `Core` domains, from malformed + firmware or a hand-built description. Unchecked today. + + Two questions this block used to ask are now answered and are not repeated: whether `discover()` + detects at populate time (**yes** -- it must, in order to retry), and whether it may prefer a source + (**no** -- [D-15](DESIGN-NOTES.md#d-15) rejected reduce-on-insert, and preferring is that by another + name). What remains is listed above. + +- [x] **MMT-1.3** -- **What a consumer does when a needed fact was not observed**, given the bar that + the model answers without further measurement. Degrade to a documented weaker policy, refuse, or + answer with an explicit "chosen without knowing X" marker. + **Narrowed by [D-19](DESIGN-NOTES.md#d-19), then resolved as not-this-crate's by + [D-21](DESIGN-NOTES.md#d-21).** D-19 removed two thirds of it: a contested subject is one the + unified view does not cover, which is D-13's not-observed, so there is one degradation path rather + than one per reason a fact is missing. + D-21 then places the remainder. This crate publishes a **refined view of what the platform + publishes**; what a consumer *does* with an unobserved fact is not a question about that view. The + model owes only that the absence be **representable and distinguishable** -- which is + [D-13](DESIGN-NOTES.md#d-13), implemented by **M2+.5**. The behavioural decision is the consumer's + and stays with `EP-1.4`. + **This item was gating M2, and through it M3, M4 and M5** -- a decision that was never the model's + to make was holding the whole reshape. Recorded as a planning defect rather than quietly fixed: it + landed in a "decisions that shape everything below" milestone because it *looked* foundational, and + foundational-looking is not the same as being about this component. + > **-> CROSS-COMPONENT HANDOFF:** the behavioural half is `EP-1.4` in + > [topology-planner](../topology-planner/CHECKLIST.md). It no longer has a counterpart here, so it + > is that component's decision alone rather than a joint one. + +- [x] **MMT-1.4** -- **Does `distances` survive at all?** The two-component architecture says the + *synthesizer* measures, with the caller's permission, for its own scenario -- so a measured number + is its working state and its justification for a choice, not a property of the machine. That + reverses this session's earlier conclusion that measured facts must live in the model, which + assumed a single component. If it holds, `distances` is **deleted rather than filled**, which is + the opposite of what the release checklist proposed. Decide before removing anything. + **Decided by the engineer, and the ruling is a scope boundary rather than a judgement about the + field: this crate does not go below the Win32 topology APIs, so if they do not provide distance + data, we do not have distance data.** Recorded as [D-20](DESIGN-NOTES.md#d-20). `distances` is + deleted. + **What the check found:** zero read sites. `render_node_distances` in `windows-platform-probes` + reads the *probe's own* measured `Observation`, not this field, so the one thing that looked like a + consumer is not one. Removal is spawned as **M5+.5** and is not gated on the reshape. + +- [x] **MMT-1.5** -- **Does the synthesizer live in this crate, and therefore what is this crate + called?** Recorded as open rather than settled: see + [topology-planner/COMPONENT.md](../topology-planner/COMPONENT.md). The naming follows + the merge rather than leading it -- while this crate is only a Win32 wrapper, `-sys` is correct + for it; if it gains a synthesizer that measures, it stops being one and the name should change + then. + **Answered by the engineer's architectural shift, recorded as + [EP-D-4](../topology-planner/DESIGN-NOTES.md#ep-d-4): no.** The planner is a separate + component named **`topology-planner`** -- with no `windows-` prefix, because it plans against an + abstracted idealized machine and emits a platform-neutral plan. So this crate does not gain the + synthesizer, remains a pure Win32 wrapper, and **keeps its name**. + What settles it is not a naming preference but the shift's second half: the planner queries an + *abstract* model through traits, and **adapters** bridge this crate's objects to those traits. A + crate that is one side of an adapter boundary is exactly what `-sys` names. + [D-20](DESIGN-NOTES.md#d-20) reinforces it from the other direction -- a crate whose scope is + "what the Win32 topology APIs report" is a `-sys` crate by construction. + +## M2: the granularity model + +**Ready.** M1 is closed, and [D-21](DESIGN-NOTES.md#d-21) makes every item here a refinement of what +Windows reports rather than something a planner asked for. + +**Re-planned 2026-09-03, on execution.** Checking these six against the code they reshape found two +that already describe the status quo and three that are one deliverable. Recorded rather than +quietly worked around, per the re-plan rule. + +- [x] **M2+.1** -- Model **observed sharing relations**, not a ladder of levels with optional rungs. + A machine with no L3 has no L3 relation, which is an observation rather than a missing value. + **Already satisfied; this item described the existing design.** `Domain` *is* a relation over a + `ProcessorSet`; `DomainKind` is open with seven kinds (D-4); `Cache { level: u8 }` has no fixed + rungs; and `cache_levels()` is documented as "derived from what the topology actually contains + rather than from a fixed ceiling", with a regression test guarding the exact hazard this item + names. A machine with no L3 simply has no `Cache { level: 3 }` domain today. + Not absorbed silently: separating *relation identity* `(kind, membership)` from the **label** + `Domain::id`, which [D-15](DESIGN-NOTES.md#d-15) requires, is real remaining work -- but it is + observation work and belongs to **M3**, not here. + +- [x] **M2+.2** -- Derive the order from **observed set inclusion**, never from firmware level + numbers. Inclusion is checkable; numbering is asserted, and this crate has been bitten by asserted + structure before -- the ARM64 host with no L3, and the guard test against a consumer sweeping + `1..=4`. + **The concrete target:** `cache_levels()` sorts by firmware `level`, and + `outermost_partitioning_cache()` walks it with `.rev()` -- so today's only ordering *is* firmware + numbering, which is what this item forbids. + +- [x] **M2+.3** -- Give the order an explicit **top** ("the machine"), so a pairwise query is total. + Two processors always share one address space, one scheduler and one memory system; without a top, + every caller writes the same empty-case branch for a cross-node pair. + +- [x] **M2+.4** -- Represent **incomparable** granularities. An inclusion order is partial, so two + granularities may not nest, and the honest answer to "tightest shared" is then a set of minimal + elements -- almost always one, but not by construction. + + > **M2+.2, M2+.3 and M2+.4 are one deliverable and land in one commit citing all three.** They are + > not independently implementable: an inclusion-derived order cannot be defined without deciding + > what its top is and what happens when two elements do not nest, and a type that answered only one + > of the three would not compile into anything coherent. This is the acknowledged-coupling case, + > named rather than disguised by splitting the commits. + > + > **Shape:** the order is over *relations*, compared by processor-set inclusion, with a synthetic + > `Machine` top that is **not** inserted into `domains` -- putting it there would claim the platform + > observed it. The operation the order exists to support is "the minimal relations containing this + > set of processors", which returns a `Vec` precisely because M2+.4 says minimality need not be + > unique. The *pairwise* query built on it, with membership and the upper-bound flag, is `M4+.1`. + > + > **Done.** `src/granularity.rs` -- `Granularity::{Relation, Machine}`, + > `MachineMemoryTopology::{machine_processors, minimal_shared, is_finer_than}`, on a new + > `ProcessorSet::is_subset`. 21 tests. + > **The top is a fallback, not a competitor**: `Machine` is returned exactly when no reported + > relation covers the query, so it never appears beside an observed relation. The alternative -- + > treating it as an ordinary element -- was rejected on measurement of its consequence: on a machine + > whose group domain spans every processor, every answer would carry a redundant second element, + > which is systematic noise rather than M2+.4's "almost always one". + > **Totality is over processors the topology knows.** A query naming an unknown processor answers + > *empty*, not `Machine`, because claiming the machine contains a processor it has never heard of + > would be an invention. + > **Sabotage-verified, and it found a real gap.** Removing the strictness from minimality failed 10 + > of 20 tests. Breaking `is_subset` failed only **one**, incidentally -- the new primitive the whole + > order rests on had no direct tests, and every granularity test used group 0 alone, so the + > multi-group path was untested. Seven `is_subset` tests and a cross-group order test took that from + > 1 detection to 4, two of which name the defect directly. + +- [x] **M2+.5** -- Make absence first-class per [D-13](DESIGN-NOTES.md#d-13): **not observed**, + **observed and absent**, and **a negative result** are three different facts that an `Option` + spells identically. Per [D-19](DESIGN-NOTES.md#d-19) this also carries the contested case -- a + subject the sources genuinely disagreed on is one the unified view does not cover, which is *not + observed*, so no fourth state is added. + **Deliverable: the vocabulary type**, which `M5+.2` and `M5+.4` then consume -- M5+.4 already says + "M2+.5 gives it the vocabulary to accept one". Independent of M2+.2/.3/.4, so it lands separately. + **Done.** `src/observed.rs` -- `Observed` with `Known`, `Absent`, `NotObserved`, plus `known()`, + `was_observed()`, `map()`, and a `Default` of `NotObserved` (D-12's reasoning: forgetting a field + must not assert something about the machine). 9 tests. + **Two variants, not three, and the omission is deliberate.** The *negative result* is not an + absence -- it is a computed answer whose value happens to be "no" -- so giving it a variant would + re-create the conflation the type removes. It stays an ordinary value, or an `Option` documented as + meaning exactly that. + **Sabotage-verified:** making `was_observed()` treat `Absent` as a gap -- the precise conflation + this type exists to prevent -- is caught by the test named for that claim. + Not yet *applied* to any field: `M5+.2` (`memory_bytes` from a description) and `M5+.4` (the probe + refusing a partially-covering cache level) are the sites, and both are M5 items. + +- [x] **M2+.6** -- **Relations carry attributes, not only memberships.** Required by + [D-19](DESIGN-NOTES.md#d-19): once the relation set *is* the unified model, + `DomainKind::Memory { memory_bytes }` and `Core { efficiency_class, simultaneous_multithreading }` + have nowhere to live unless a relation holds a payload alongside its processor set. + **Already satisfied, and the premise was wrong.** `DomainKind` has carried per-kind attributes + since D-4: `Memory { memory_bytes }`, `Core { simultaneous_multithreading, efficiency_class }`, + `Cache { level, associativity, line_size, size_bytes, cache_type }`, and `Other { name, attributes + }` for a kind this crate cannot interpret. Nothing had "nowhere to live". + The item was written from the abstract `(kind, membership)` framing while recording D-19, without + checking it against the type -- which had solved this two decisions earlier. Kept rather than + deleted because it is the second time in this milestone that an item asserted a gap the code did + not have, and once is a slip while twice is a method problem: **check the item against the code + before planning work from it.** + +## M3: observation and provenance + +**Ready.** M1 is closed. + +**Re-planned 2026-09-03, before implementing.** Checking these against the code found `M3+.3`'s +premise wrong in the same way `M2+.6`'s was, and found work this milestone had been assigned but +never given an item. Recorded rather than absorbed silently -- this is the third item in the reshape +to assert a gap the crate does not have, and the pattern is what the M2 re-plan named: **check the +item against the code before planning work from it.** + +- [x] **M3+.1** -- Provenance is **per relation**, not per source. Per-relation subsumes per-source + by repetition, and the reverse fails on the case that matters: two sources describing the *same* + relation. + **What that means concretely, which the item did not say.** "The case that matters" does not exist + in the code yet: `domains` is built from `GetLogicalProcessorInformationEx` alone, and `cpu_sets` + sits beside it as a parallel list, so no relation is currently described by two sources. Satisfying + this item therefore means **unifying the two sources into one relation set keyed by + `(kind, membership)`** per [D-15](DESIGN-NOTES.md#d-15), with each relation recording which sources + observed it. That is the heart of [D-19](DESIGN-NOTES.md#d-19)'s unified view, and it does not + exist yet. + **It absorbs the `Domain::id` work rather than leaving it a separate item.** D-15 requires the + *label* to move from the relation to the observation, and unification forces it: the two sources + agree on the core partition while labelling it `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]`, so a + single unified relation cannot carry one `id`. The two are the same change. + *(The M2 re-plan said this work "belongs to M3" without filing it anywhere, so until now it was + owned by no item at all.)* + **Split into three sub-steps on execution**, because `Domain::id` turns out to have **14 uses + across three other crates** -- `windows-ioring-sys`' `ring_copy` example, `windows-placement-probe`, + and `windows-platform-probes` -- plus a hand-written JSON shape. Doing this as one commit would mix + an additive change, a behavioural one, and a cross-crate breaking one. Each sub-step below compiles + and is testable on its own, and the breaking change is last and isolated. + + - [x] **M3+.1.1** -- Introduce `Source` and `Observation`, and give `Domain` its observations, + populated by `from_relations` with the label it currently puts in `id`. **Additive**: `id` stays, + nothing downstream changes, and the unified view has somewhere to record what it unifies. + **"Nothing downstream changes" was wrong** -- the fourth item in this reshape to assert something + the code contradicts. `Domain` has public fields and is not `#[non_exhaustive]`, so a new field + breaks every struct literal: **59 of them across five crates**, 17 outside this one. rustc + enumerated each site; test literals take `Vec::new()` (a hand-built relation nobody reported, + which is honest) and the seven real builders in `from_relations` take a genuine + `Source::RelationshipWalk` observation carrying the label they already used. + **Deserialization drops platform observations, and that is the point.** The wire shape does not + encode them and no `Description` observation is synthesized. Carrying "the relationship walk + observed this" out of a file would be exactly the forgery [D-12](DESIGN-NOTES.md#d-12) refuses, + and synthesizing `Description` would restate what the object's `Provenance::Restored` already + says -- which [D-22](DESIGN-NOTES.md#d-22) had just finished separating. Twelve round-trip tests + failed on this and were right to: the question was real, not a test defect. + + - [x] **M3+.1.2** -- Fold CPU Sets into the relation set. For a `Core` or `Memory` membership that + matches an existing relation, add an observation; otherwise add a relation observed only by CPU + Sets. **This is where the unified view comes into being** -- today `discover` builds `domains` + from the relationship walk alone and leaves `cpu_sets` beside it, so the two sources have never + met. + Deliberately *not* folded: `LastLevelCacheIndex`. Per [D-14](DESIGN-NOTES.md#d-14) it answers a + different question from the derived cache partitioning -- one LLC group where the derivation finds + eight L2 partitions -- so under [D-15](DESIGN-NOTES.md#d-15) it is a **different relation**, not a + second observation of the same one. `EfficiencyClass` is likewise a per-processor attribute rather + than a membership, and belongs to [D-18](DESIGN-NOTES.md#d-18)'s other subject kind. + **Done, and measured rather than asserted.** On this host the fold produces 44 relations, **9 + doubly observed** and 0 CPU-sets-only: the eight cores carry `walk#N` beside `cpuSets#2N`, which + is D-15's `[0,1,...,7]` against `[0,2,...,14]` with both labels now kept, plus the single NUMA + node. Caches carry walk observations only, per D-14. + `cpu_sets` is still kept verbatim beside the folded view -- D-19's unified model is presented *in + addition to* the individual ones, not instead of them. + **A fabrication caught before it shipped.** The first version defaulted a CPU-sets-only core's + `efficiency_class` to `0`, which would have reinvented the `Processor::capacity` sentinel this + reshape exists to remove, since `0` is a legitimate class. It now takes the value from the records + themselves. + **Sabotage found a second host-shaped test gap.** Weakening the match from equal membership to + containment passed all 169 tests, because every membership on this machine is *exactly* equal so + the two rules coincide. Four synthetic fold tests -- built to disagree on purpose -- now catch it, + and the sabotage had to be injected in the semantically plausible direction to be meaningful. + + - [x] **M3+.1.3** -- Remove `Domain::id`, now that observations carry the labels, and update the + three downstream crates and the wire shape. **Breaking**, and correct: there is no single + canonical id once two sources label the same relation differently -- measured as + `[0, 2, 4, ..., 14]` against `[0, 1, ..., 7]` -- and a relation observed only by CPU Sets has no + walk label at all. Keeping `id` beside the observations would be two statements of one fact, which + is the restatement drift this repository has a rule about. + **Done.** `Domain::label_from(source)` and `observed_by(source)` replace it; the accessor takes a + source *because there is no answer without one*. 59 literals and 14 call sites across four crates, + all located by rustc rather than by a regex guessing at them. + The wire `"id"` survives as an informational field: written when the walk labelled the relation, + and **optional on read and discarded**, since a file cannot establish which source observed + anything (D-12). Making it optional was forced by the change -- serialization stops writing it for + a relation no source labelled, so requiring it would have rejected descriptions this crate itself + produces. + **A real bug, caught by the tests and not by review.** Replacing `domain.id` with a positional + index in `windows-placement-probe` is fine for the cache and core maps, which only need an + equivalence class -- but `numa_of`'s value reaches **`VirtualAllocExNuma`**, so a position would + allocate on the wrong node on any machine whose nodes are not numbered `0..n`. It now takes the + walk's label, which is the real node number. The fixture's nodes happen to be `0` and `1`, so the + NUMA assertion passed by coincidence and only the cache assertion failed -- the bug was one + coincidence away from shipping. + + - [x] **M3+.1.4** -- **Record a per-processor attribute conflict**, which relation unification + cannot reach. `M3+.1.2` matches relations by `(kind, membership)`, so two sources describing one + core agree on *that* even if they disagree about its `efficiency_class` -- and the unified + relation keeps the walk's value while the CPU-sets value goes unrecorded. + This is [MMT-1.2](CHECKLIST.md)'s **attribute shape** and [D-18](DESIGN-NOTES.md#d-18)'s second + subject kind: an observation whose subject is `(processor, attribute)` rather than + `(kind, membership)`. Filed as its own item because the fold's doc comments cite it, and a rule + cited in code but scheduled nowhere is exactly the orphaning the "design notes are not a work + queue" rule exists to stop. + **Not observable on this host** -- every efficiency class reads `0` here -- so it is testable only + synthetically, per [D-17](DESIGN-NOTES.md#d-17). + **Done.** `ProcessorAttribute`, `AttributeObservation`, and + `MachineMemoryTopology::{processor_attributes, attribute_conflicts}`. The walk's claim is fanned + out from each core to its processors rather than left for a consumer to re-derive -- that + reconstruction is what this model exists to stop. + **Reported, never resolved.** `attribute_conflicts` names the contested subjects and both claims + stay: picking a winner would destroy the disagreement, and on a hybrid part the choice decides + whether a processor is treated as a performance or an efficiency core. + Five tests, four of them synthetic, plus one that asserts *this host has no conflict* -- measured + rather than standing in for the conflicting case. Sabotage-verified: making agreement count as a + conflict fails three of them. + +- [x] **M3+.2** -- Keep two properties of the old `Provenance` **because they re-derive**, not + because they were there: the default is the untrusted value (a *stronger* argument per-relation, + since there are more places to forget), and trust never upgrades (a file still cannot establish it + describes the machine you are on). + **Done, and both turned out to be already satisfied by `M3+.1.1` rather than needing new + mechanism** -- which is the item's own claim vindicated: they *re-derive*. + The untrusted default is an **empty** observation list. A relation nobody reported says exactly + that, rather than defaulting to a source and asserting something no API said; nothing fills it in + on a caller's behalf. + Never-upgrade is deserialization dropping platform observations, so a description claiming the + relationship walk observed something cannot establish that it did. Both now have named tests + asserting them at the relation level, beside the object-level ones D-12 already had. + +- [x] **M3+.3** -- ~~Supersede the whole-object `Provenance` **without replacing it with another + whole-object scalar**. With trust per relation, an object-level scalar can only be the minimum -- + ninety-nine measured relations and one synthetic reading `SYNTHETIC` -- or the maximum, which is + dishonest. Trust belongs to an *answer*.~~ + **Rewritten. The premise is wrong, recorded as [D-22](DESIGN-NOTES.md#d-22).** `Provenance` is not + an aggregate of anything: it records **how the object was obtained** -- `discover()` stamps + `Measured`, deserialization is capped at `Restored`, hand construction defaults to `Synthetic`. That + is a fact about the construction *act*, and no per-relation value can express it. The mixed + "ninety-nine measured and one synthetic" case cannot arise from collection at all; it needs someone + to hand-insert a relation into a discovered topology, which is exactly what per-relation provenance + makes **visible** rather than a reason to delete the object-level fact. + It also has a real consumer that wants precisely it: `windows-placement-probe`'s + `Record::is_trustworthy` gates on `is_measured()` to decide whether a measurement counts, and its + record schema carries the value at the top level deliberately so a collector need not reach into + the fingerprint. + **Revised deliverable:** keep the type, and make its documentation say what it actually means -- + the construction act, orthogonal to per-relation provenance -- so the next reader does not repeat + this item's mistake. + **Done.** `Provenance`'s documentation now opens with "what this records: the construction act", + states the orthogonality, and names the superseded argument so it is not re-proposed. No type or + behaviour change: the correct outcome here was *not* changing the code. + +- [x] **M3+.4** -- Carry both observers without merging, per MMT-1.1's decision. `Topology::cpu_sets` + already lands this way; this item is whether that stays a parallel list or becomes observations + attached to relations. + **Answered by [D-19](DESIGN-NOTES.md#d-19): both.** The question presented the two as alternatives, + and they are not. Observations attach to relations, which is what makes the *unified* view exist at + all; the raw per-source list stays for a caller that wants what one source said, verbatim. That is + what "a unified model in addition to the individual ones" means concretely. No implementation is + owed here -- M2+.6 and M4 carry the surface -- so this item is closed as a decision, not as code. + +## M4: the queries + +Parked on M2 and M3. + +**Each is a refinement of what Windows reports**, per [D-21](DESIGN-NOTES.md#d-21) -- not a planner +requirement, which is how they were first justified. The change matters because it changes what is +in scope: a query the platform's data supports belongs here whether or not any planner wants it, and +a planner requirement with no platform correspondence belongs to the adapter. + +Read on their own terms, most of these were never planner-shaped. `M4+.2` and `M4+.3` are ordinary +facts about processors and memory stated without sentinels; `M4+.4` fixes a rule the **probes** have +restated three times in two crates. Only `M4+.1`'s pairwise helper is consumer-flavoured, and the +ordered collection it derives from is what stops that restatement recurring. + +They remain cross-referenced to [topology-planner](../topology-planner/DESIGN-NOTES.md) as +**evidence** the shape is right rather than as its justification -- stating those requirements found +the `Processor::capacity` sentinel collision that reviewing the model alone had not. + +- [x] **M4+.1** -- **The ordered relations are the query surface; pairwise proximity is a method on + them.** The requirement arrived from [EP-D-2](../topology-planner/DESIGN-NOTES.md#ep-d-2) as a + *pairwise* query returning the minimal shared granularities, **their membership**, and whether a + finer granularity went **unobserved** so the answer can be an upper bound and say so. All three + requirements stand. The **shape** does not, and the requirement says so itself: it asks the answer + to carry the whole block containing both processors, "or the planner asks O(n^2) times and + reconstructs the grouping". An answer that must carry the block is not about the pair -- the pair is + an index into a partition. + Everything the planner does is an operation on the partitions: choosing domain granularity is + *selecting one*, sizing an MPSC fan-in is *a block's cardinality*, choosing a channel is *the finest + block containing both*. Pairwise is three lines over that. The reverse is derivable too, but only by + union-find over O(n^2) queries -- which is exactly the reconstruction `SH-16.9` records three + consumers performing, two of them differently. Building pairwise as the primary surface would ship + the stated requirement and re-create the defect one level up. + Both are provided; **the collection is primary and the pairwise helper is derived from it**, so + there is one implementation of the grouping. + *Terminology:* it is a **poset with a top**, not a lattice -- M2+.4's incomparable granularities + mean meets need not be unique, which is also why a pairwise function has to return a *set* and is + an awkward face on an ordered collection. + **Done.** `Proximity { shared, finer_unobserved }` and + `MachineMemoryTopology::proximity(&[ProcessorId])`, whose body is `minimal_shared` plus the + coverage check -- so there is one implementation of the grouping, not one per caller. + Generalised past a pair, because nothing in the query is specific to two: the same call sizes an + MPSC fan-in over a whole block. + **The third requirement is the one that needed building.** `finer_unobserved` says the answer is an + **upper bound**: some kind the machine reports covers one of these processors in no instance, so the + platform has said nothing about whether they share it. The distinction that makes it honest is + [D-13](DESIGN-NOTES.md#d-13)'s -- absence of a *kind* describes a machine without it, while absence + of a processor *from* a kind that exists is a gap. `Memory` is excluded because a processor in no + memory domain is `M4+.3`'s unplaced case, which has its own answer. + `Proximity::only()` returns `None` on a tie rather than taking the first, so a caller that cannot + handle M2+.4's multi-element answer has to say so. + Sabotage-verified: dropping the kind-exists guard -- which would make a machine with no caches look + incomplete -- fails three tests, including the D-13 conflation case. + +- [x] **M4+.2** -- The **shard-set** surface (EP-D-1): identity as `(group, number)`, online, core + membership and SMT, efficiency class **without a sentinel**, and availability. + **Done.** `ProcessorFacts` and `MachineMemoryTopology::shard_set()`. Every optional field is an + `Observed`, so "the platform said zero" and "nobody asked" are different values -- which is the + `M5+.1` collision fixed from the other side, since `Processor::capacity` spells offline, in-no-core, + and class-zero as the same `0` and the third is every processor on every non-hybrid machine. + **It states availability and does not judge it.** A `usable()` helper was written and then removed: + which of online, parked and allocation disqualifies a processor is a **policy**, and per + [D-21](DESIGN-NOTES.md#d-21) this crate states facts -- baking the judgement in is exactly what + `outermost_partitioning_cache` was criticised for. It would also have been wrong, which is how the + next item was found. + +- [x] **M4+.3** -- **Residency** (EP-D-3): processor to memory domain, with the unplaced case + distinguishable rather than defaulted -- an unknown cache domain costs an optimisation, an unknown + memory domain has no honest fallback. + **Done.** `memory_domain_of(processor) -> Observed<&Domain>` and `unplaced_processors()`. The + unplaced case is `NotObserved`, never node zero: the pool has to be allocated somewhere, and + guessing means quietly allocating remote memory for the life of the process. `Observed::Absent` is + never returned -- a memory domain covering no processors is a real shape (D-5), but a processor + belonging to no node is a gap in what the firmware said, not a statement that it has no memory. + +- [x] **M4+.4** -- Reduce `outermost_partitioning_cache` to a **named projection** over the order -- + "the coarsest granularity with more than one group" -- so it is a query rather than a rule, and + cannot be restated wrongly because there is nothing to restate. + **Note on what "over the order" can and cannot mean here.** M2's order is over *relations*, and a + partition is a *set* of relations, so the projection needs both: a **grouping** key to say which + relations form one candidate partition, and an **order** to say which candidate is coarsest. Level + is used for the first and must not be used for the second -- grouping by level reads what the + source said, whereas ordering by level asserts that a higher number is always coarser, which is the + structure `M2+.2` forbids and this host's ARM64 sibling disproves. + **Done.** The projection now collects every level that forms a partition, then picks the one no + other **refines** -- checkable against the memberships Windows reported, where "a higher number is + coarser" is asserted. + **Every pre-existing test passed under both rules**, so the change would have been invisible: the + discriminating case is a machine whose *lower* level forms the *coarser* partition, which no + fixture built from real hardware has. Reverting to the old `.rev()` over level numbers now fails + exactly one test, and it is the one written for that case. + +**Overlap noticed on reading, not deferred:** `M4+.2`'s "efficiency class **without a sentinel**" and +`M5+.1`'s `Processor::capacity` collision are the same defect from two sides. They land together, in +`M4+.2`, and `M5+.1` records that rather than repeating the work. + +- [x] **M4+.5** -- **Verify the CPU-set flag bit positions against the SDK**, which `M4+.2`'s + measurement gave evidence are wrong. `allocated_to_this_process` reads **false for every processor** + on the development host -- for a process plainly running on them, which is not a credible answer. + `parked` reads false everywhere too, so neither has ever been observed true and nothing distinguishes + "the bit is clear" from "we are reading the wrong bit". + The positions in `cpu_set::flags` are transcribed from the SDK's bitfield order and the module's own + doc calls changing them a breaking change -- but transcription is exactly the **asserted structure** + this reshape exists to distrust, and it has never been checked against a machine where the answer + would differ. + **Until it is verified, no behaviour may depend on these flags.** That is why `M4+.2` ships the + values as facts and no judgement over them; a `usable()` helper written against them refused every + processor on this machine. + **Investigated, and the answer is neither of the two the item expected.** Recorded as + [D-23](DESIGN-NOTES.md#d-23). + The bit positions are **not** wrong -- and cannot be shown right either. The whole `AllFlags` byte + reads `0x00` for every processor, so no bit has ever been observed set and nothing distinguishes a + correct transcription from a wrong one. They stand on the SDK's declared order alone. + The byte is **not populated on this build**, which is the actual finding and was established by + experiment rather than argued: `SetProcessDefaultCpuSets` succeeded, `GetProcessDefaultCpuSets` + confirmed the allocation stuck (`[0x100, 0x101]`), and the byte still read zero -- under a null + handle, the pseudo-handle, and a real `OpenProcess` handle alike. Windows 11 25H2 + (10.0.26200.9168, AMD64). + **And the field's own documentation was wrong**, which the experiment exposed on the way: + `allocated_to_target_process` does not mean "may we run here". It means the CPU set was explicitly + allocated via `SetProcessDefaultCpuSets`, which an ordinary process never does -- so `false` is the + ordinary answer for a processor it is entirely free to run on. The old doc said the opposite. + A regression test pins the measurement, so a build that *does* populate the byte is noticed rather + than silently changing what these fields mean. That build is also the only thing that could finish + verifying the bit positions. + +## M5: the defects this subsumes + +Parked on M4, **except M5+.5, which is independent and ready now**. Each of the others already +exists as a defect that the reshape is what fixes, so they are listed here rather than fixed +separately and then re-fixed. + +- [x] **M5+.1** -- `Processor::capacity` uses `0` as both a legitimate efficiency class and a "not + known" sentinel, and the two collide on **every non-hybrid machine**. Worse than an ambiguous + `Option`: a colliding sentinel cannot be distinguished even by a careful caller. + **Subsumed by `M4+.2`, which is where the sentinel-free answer lives**: `ProcessorFacts` reports + `efficiency_class: Observed`, so class zero and "no core names this processor" are different + values. `Processor::capacity` itself is left in place and its documentation already warns against + it -- removing a published field is a second breaking change with no additional benefit once the + honest answer exists beside it. + +- [x] **M5+.2** -- `DomainKind::Memory::memory_bytes` is unambiguous from `discover` but ambiguous + from a **description**, where "the field was omitted" and "this node's capacity is unknown" are the + same value. The [D-13](DESIGN-NOTES.md#d-13) audit found this and documentation cannot fix it. + **Done**: the field is now `Observed`, and serde carries all three states distinctly -- a + number for `Known`, an explicit `null` for `Absent` ("this node has no memory of its own"), and + **omission** for `NotObserved`. The two that used to collide are now different bytes on the wire, + so a description round-trips through the distinction rather than losing it. + +- [x] **M5+.3** -- The partitioning rule is stated **three times in two crates**, and two of the + three differ: `windows-platform-probes` omits the pairwise-disjointness check this crate requires. + M4+.4 removes the reason to restate it. + **Done, and by M4+.4 the restatement had drifted in a second way**: it also ordered candidates by + **level number**, which this crate stopped doing. `Observation` now captures + `partitioning_cache_level` from `MachineMemoryTopology::outermost_partitioning_cache` at survey + time and looks the summary up, so there is one implementation of the rule. + **An existing platform-probes test then caught a real regression in M4+.4** -- and it was right to. + On this host L1 and L2 split the machine into the **same** eight pairs, so neither refines the + other and the first-wins tie-break answered `L1`, naming the inner cache for a boundary the outer + one also owns. Ties between *identical* partitions now take the higher level, which reads the + source's own labelling of one boundary rather than ordering distinct partitions by number. + +- [x] **M5+.4** -- `windows-placement-probe` **refuses a partially-covering cache level** that this + crate deliberately hands back, failing an entire measurement run over a topology this crate + considers describable. M2+.5 gives it the vocabulary to accept one. + **Done**: `ProcessorPlace::cache_domain` is `Observed`, the `MissingPlacement::CacheDomain` + refusal is gone, and an uncovered processor reports `NotObserved` -- which is neither `Absent` + ("no level partitions this machine", the ordinary single-domain case) nor a fabricated domain. + **The hazard the refusal existed to prevent is closed one level down instead**: + `Slice::same_cache_domain` answers `None` when any participant is `NotObserved`, so two uncovered + processors are never reported as sharing a cache. Verified by sabotage -- deleting that guard turns + the answer into `Some(true)` and exactly one test goes red. + +- [x] **M5+.5** -- **Delete `MachineMemoryTopology::distances` and the `Distances` type**, per + [D-20](DESIGN-NOTES.md#d-20). **Not gated on M4**: the reshape does not fix this one, deletion + does, so it does not wait for the rest of M5. + A **breaking change to a published crate** (0.1.0), so the commit takes the Conventional Commits + `!` marker. It is not a *parse* break -- the crate does not `deny_unknown_fields`, so a description + carrying `"distances"` still deserializes and the field is ignored. + Two things to do rather than skip: keep the Linux-shaped description test, retargeted to assert the + field is now **ignored** rather than deleting the evidence that such a description parses; and note + in the doc comment that round-tripping such a description no longer preserves it, since that is a + real if small behaviour change and a silent drop is exactly what this crate has objected to + elsewhere. + **Done.** Field, type, and re-export removed; three call sites in `windows-placement-probe`'s + fingerprint fixtures updated. The Linux-shaped test survives as + `a_linux_shaped_description_parses_and_its_distances_are_ignored`, keeping the **populated** matrix + so what it proves is that an existing description still parses, and gaining an assertion that the + value does **not** reappear on re-serialize -- the silent drop asserted rather than assumed. + `distances_is_expected_to_be_square` was deleted with the type it tested (125 tests to 124). + Two stale statements sweeps found and fixed: the [D-13](DESIGN-NOTES.md#d-13) audit row, and the + Linux-comparison summary, which had recorded optional distances as a decision that *held up* -- + sound about the schema, and reversed by a ruling about scope. diff --git a/crates/windows-topology-sys/COMPLETED-PLANS.md b/crates/windows-topology-sys/COMPLETED-PLANS.md index 81511178..2080b931 100644 --- a/crates/windows-topology-sys/COMPLETED-PLANS.md +++ b/crates/windows-topology-sys/COMPLETED-PLANS.md @@ -7,3 +7,4 @@ was finished. Individual milestones are archived in [COMPLETED-CHECKLIST.md](COM | Path to CHECKLIST.md | Completion Date | Brief description | Design Notes | |---|---|---|---| | [CHECKLIST.md](CHECKLIST.md) | 2026-08-22 | M1-M4: safe enumeration of Windows processor, cache, and memory topology (a walk-by-`Size`, trailing-array-respecting wrapper over `GetLogicalProcessorInformationEx`), the open-kinded `Domain`/`Topology` description (including a memory domain with no processors, for CXL-shaped systems), JSON serialization behind a default-off `serde` feature with the schema explicitly not semver-covered, and crate documentation plus a worked example printing the host's topology. Unblocks `windows-ioring-sys`'s `M7` (`ring-copy`). | [DESIGN-NOTES.md](DESIGN-NOTES.md) | +| [CHECKLIST.md](CHECKLIST.md) | 2026-09-03 | **MMT-*: reshaping the machine memory topology.** Replaced the ladder-of-levels model with **observed connectivity**: relations held as a set with per-relation provenance rather than reduced on insert (`Observation`, `Source`), presence and observation represented as facts rather than inferred (`Observed`, adopted for `memory_bytes` and `cache_domain`), a granularity order with a `minimal_shared` meet, and the pairwise `proximity` query *derived* from an inclusion-ordered partitioning rather than restated -- which removed the third statement of the partitioning rule. Also dropped `distances` and `Domain::id`, folded CPU Sets into the relation set, and recorded per-processor attribute conflicts. Five of the planned items turned out to assert gaps the crate did not have and were closed as already-satisfied or re-planned. Three breaking changes; the crate goes to **0.2.0**. | [DESIGN-NOTES.md](DESIGN-NOTES.md) (`D-13`-`D-23`), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | diff --git a/crates/windows-topology-sys/PLANS.md b/crates/windows-topology-sys/PLANS.md index 662cd37d..3563afa9 100644 --- a/crates/windows-topology-sys/PLANS.md +++ b/crates/windows-topology-sys/PLANS.md @@ -1,7 +1,9 @@ # Plans: windows-topology-sys +No plans in progress. + | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| -| [CHECKLIST.md](CHECKLIST.md) | in progress | **Reshaping the machine memory topology**, numbered `MMT-*` and deliberately distinct from the release checklist's `SH-*`, six of whose items it supersedes. The governing idea, settled with the engineer: model the **observed connectivity** rather than a ladder of levels with optional rungs, and treat presence and observation as facts to represent rather than shapes to infer from. M1 is decision work and gates everything -- whether several observations of one relation are held as a set or reduced on insert, what a query returns when two sources differ (three cases, not two: D-14 found that CPU Sets and the derivation can differ by answering *different questions*), what a consumer does with a fact that was never observed, whether `distances` survives at all now that the synthesizer measures for its own scenario, and whether that synthesizer lives in this crate -- which decides the crate's name, since `-sys` is right only while this is purely a Win32 wrapper. M2 through M4 build the granularity order, per-relation provenance, and the pairwise queries the execution planner stated requirements for. M5 lists the defects the reshape subsumes rather than fixing them twice. | [DESIGN-NOTES.md](DESIGN-NOTES.md), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | -Completed plans are in [COMPLETED-PLANS.md](COMPLETED-PLANS.md). +Completed plans are in [COMPLETED-PLANS.md](COMPLETED-PLANS.md). New work against this crate adds a +row back here and reopens [CHECKLIST.md](CHECKLIST.md). From f3ccb420df72417c7fa2d6b8592c316be2edfc63 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 19:37:24 -0400 Subject: [PATCH 279/361] docs: discharge the six SH-16.x items the MMT plan superseded The archive commit before this one moved the MMT milestones out of windows-topology-sys/CHECKLIST.md, which rotted every link that cited an item by ID. Retargeted at COMPLETED-CHECKLIST.md, which now holds them: 18 in the ship checklist, 7 in windows-topology-sys/DESIGN-NOTES.md, 2 in topology-planner/DESIGN-NOTES.md, 1 inside the archive itself. The six SH-16.x items marked SUPERSEDED were still unchecked, which was right while the superseding work was pending and is wrong now that all of it has landed. Each is checked off with the MMT item that discharged it named inline -- SH-16.5 by M5+.4, SH-16.8 by M2, SH-16.9 by M5+.3, SH-16.11 by M5+.5, SH-16.12 by M5+.1/M4+.2, SH-16.13 by M3+.1.2 -- so the discharge can be traced without reading two files. Also corrects windows-topology-sys/DESIGN-NOTES.md's opening claim that "this crate does not exist yet as compiled code", which has been false since 0.1.0 shipped and which framed the whole file as a plan rather than as the authority for current behaviour. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 18 +++++++------- crates/topology-planner/DESIGN-NOTES.md | 4 ++-- .../COMPLETED-CHECKLIST.md | 2 +- crates/windows-topology-sys/DESIGN-NOTES.md | 24 +++++++++++-------- 4 files changed, 26 insertions(+), 22 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index c945c370..a07d8544 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -228,7 +228,7 @@ milestone exists to avoid. So SH-3.4 waits on it. **Updated 2026-09-03 -- what that work now is, and what discharges the gate.** All six of those items are superseded into the `MMT-*` plan in -[crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md), so the gate is +[crates/windows-topology-sys/COMPLETED-CHECKLIST.md](crates/windows-topology-sys/COMPLETED-CHECKLIST.md), so the gate is discharged by **MMT M2 through M5 landing in this PR**, which is the engineer's direction. Two things that previously stood in the way are gone: @@ -753,7 +753,7 @@ gap; the options in SH-14.3 instead make the recurrence harder to reach. > **Six of these items are superseded.** SH-16.5, SH-16.8, SH-16.9, SH-16.11, SH-16.12 and SH-16.13 > are all the same piece of work seen from different angles -- reshaping the machine memory topology > -- and they now live in -> [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) as a plan of +> [crates/windows-topology-sys/COMPLETED-CHECKLIST.md](crates/windows-topology-sys/COMPLETED-CHECKLIST.md) as a plan of > their own, numbered `MMT-*`. They are left here, unchecked and marked, rather than deleted: each > records how the defect was *found*, which the new plan does not repeat. > @@ -834,7 +834,7 @@ predicted about a 222-commit branch. Fixed by dropping empty domains, with the contrast against `memory_domains` (which deliberately keeps a processor-less domain, D-5) recorded at the filter. -- [ ] **SH-16.5** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **`windows-placement-probe` refuses a partially-covering cache level that +- [x] **SH-16.5** -- **DISCHARGED 2026-09-03 by M5+.4 -- `cache_domain` is `Observed`, the refusal is gone, and `Slice::same_cache_domain` answers `None` rather than `same` for an unobserved participant.** SUPERSEDED by [crates/windows-topology-sys/COMPLETED-CHECKLIST.md](crates/windows-topology-sys/COMPLETED-CHECKLIST.md) (MMT-*); kept for how it was found.** **`windows-placement-probe` refuses a partially-covering cache level that `windows-topology-sys` deliberately hands back.** `outermost_partitioning_cache` documents that "full coverage of the online processors is deliberately *not* required"; `places_from_topology` treats any online processor the chosen level does not name as `MissingPlacement::CacheDomain` and @@ -858,7 +858,7 @@ predicted about a 222-commit branch. landed the collapse the session existed to remove. The contradiction is fixed by `MMT` **M2+.5** and **M5+.4** instead. The patch is kept as the record of what was tried and why it was not taken. -- [ ] **SH-16.8** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **The locality model collapses a seven-kind, any-depth topology onto one cache +- [x] **SH-16.8** -- **DISCHARGED 2026-09-03 by M2 -- the granularity order carries all seven kinds and any depth, and `minimal_shared` is the meet rather than a single cache level.** SUPERSEDED by [crates/windows-topology-sys/COMPLETED-CHECKLIST.md](crates/windows-topology-sys/COMPLETED-CHECKLIST.md) (MMT-*); kept for how it was found.** **The locality model collapses a seven-kind, any-depth topology onto one cache boundary, and nothing records that as a choice.** Raised by the engineer during the SH-16.5 fix, and confirmed: `windows-topology-sys` hardcodes no level count (`level` is a `u8`, and a regression test already guards against a consumer sweeping `1..=4`) and models `Group`, `Package`, `Die`, `Module`, @@ -883,7 +883,7 @@ predicted about a 222-commit branch. for a ladder of levels with optional rungs. That rules out the SH-16.5 prototype's `Unknown` arm, which merges both. Shape still open. -- [ ] **SH-16.9** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **The "outermost partitioning cache" rule is stated three times, and two of the +- [x] **SH-16.9** -- **DISCHARGED 2026-09-03 by M5+.3 -- the rule has one implementation, which `windows-platform-probes` now asks rather than restates.** SUPERSEDED by [crates/windows-topology-sys/COMPLETED-CHECKLIST.md](crates/windows-topology-sys/COMPLETED-CHECKLIST.md) (MMT-*); kept for how it was found.** **The "outermost partitioning cache" rule is stated three times, and two of the three disagree.** `MachineMemoryTopology::outermost_partitioning_cache` requires more than one partition **and** pairwise disjointness. `Observation::outermost_partitioning_cache` in `windows-platform-probes` is `caches.iter().filter(|c| c.domains > 1).max_by_key(|c| c.level)` -- **no disjointness check** -- @@ -945,7 +945,7 @@ predicted about a 222-commit branch. checks the decode is self-consistent, not that it matches the OS. Confirm against a parked processor or an explicit `SetProcessDefaultCpuSets` before relying on the flags. -- [ ] **SH-16.13** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **Reconcile the CPU-set observation with the relationship walk.** `CoreIndex`, +- [x] **SH-16.13** -- **DISCHARGED 2026-09-03 by M3+.1.2 -- CPU Sets are folded into the relation set and carried beside the walk, so both observers are visible per relation.** SUPERSEDED by [crates/windows-topology-sys/COMPLETED-CHECKLIST.md](crates/windows-topology-sys/COMPLETED-CHECKLIST.md) (MMT-*); kept for how it was found.** **Reconcile the CPU-set observation with the relationship walk.** `CoreIndex`, `NumaNodeIndex` and `EfficiencyClass` **duplicate** facts `GetLogicalProcessorInformationEx` already reports, from a different kernel path -- so this is not redundancy to remove, it is a **second independent observer of the same relations**, and the two can disagree under a hypervisor @@ -962,12 +962,12 @@ predicted about a 222-commit branch. sentinel**, so it is a cleaner source for the field whose `capacity` encoding collides with "unknown". -- [ ] **SH-16.11** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** +- [x] **SH-16.11** -- **DISCHARGED 2026-09-03 by M5+.5 -- `distances` and the `Distances` type are deleted.** SUPERSEDED by [crates/windows-topology-sys/COMPLETED-CHECKLIST.md](crates/windows-topology-sys/COMPLETED-CHECKLIST.md) (MMT-*); kept for how it was found.** **And now ANSWERED, in the opposite direction to what this item proposed.** [D-20](crates/windows-topology-sys/DESIGN-NOTES.md#d-20) rules that the crate does not go below the Win32 topology APIs, so a fact Win32 does not report is not one the crate has: `distances` is **deleted, not filled**. The removal is `M5+.5` in - [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md). Everything + [crates/windows-topology-sys/COMPLETED-CHECKLIST.md](crates/windows-topology-sys/COMPLETED-CHECKLIST.md). Everything below is the reasoning that led there and is kept for that; it no longer describes work. **`MachineMemoryTopology::distances` is a field for a fact Win32 cannot supply, it is never populated, and the measurement that would fill it already exists elsewhere.** `discover()` hardcodes `distances: None`, every other construction sets `None`, and no consumer reads the @@ -1002,7 +1002,7 @@ predicted about a 222-commit branch. THIS MACHINE". Take that measurement on multi-node hardware before reopening D-9 on asymmetry grounds, not after. -- [ ] **SH-16.12** -- **SUPERSEDED by [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) (MMT-*); kept for how it was found.** **`Processor::capacity` uses `0` as both a legitimate efficiency class and a +- [x] **SH-16.12** -- **DISCHARGED 2026-09-03 by M5+.1, subsumed by M4+.2 -- the shard-set surface has no sentinel, so `0` is never overloaded.** SUPERSEDED by [crates/windows-topology-sys/COMPLETED-CHECKLIST.md](crates/windows-topology-sys/COMPLETED-CHECKLIST.md) (MMT-*); kept for how it was found.** **`Processor::capacity` uses `0` as both a legitimate efficiency class and a sentinel for "not known", and the two collide on the common case.** It is computed `online.then(|| find the owning Core domain).flatten().unwrap_or(0)`, so `0` means the processor is offline, *or* is online but named by no `Core` domain, *or* genuinely has efficiency class zero. diff --git a/crates/topology-planner/DESIGN-NOTES.md b/crates/topology-planner/DESIGN-NOTES.md index c569ae7b..430306c9 100644 --- a/crates/topology-planner/DESIGN-NOTES.md +++ b/crates/topology-planner/DESIGN-NOTES.md @@ -219,7 +219,7 @@ this query is the cause of that defect, not a separate problem. measured-only tier and a machine with no L3 both have positions. - Access to that order **as a collection**, with a pairwise helper derived from it, returning minimal shared granularities plus their membership. Stated here first as a pairwise query, which - [windows-topology-sys](../windows-topology-sys/CHECKLIST.md) `M4+.1` corrected: requiring the answer + [windows-topology-sys](../windows-topology-sys/COMPLETED-CHECKLIST.md) `M4+.1` corrected: requiring the answer to carry the block containing both processors makes it a question about the partition, not the pair, and a pairwise-primary surface would force the planner into the O(n^2) reconstruction that `SH-16.9` records going wrong three times. The three *requirements* below are unchanged; only the shape is. @@ -359,7 +359,7 @@ re-scopes the component that records it.* ### What it settles **The crate-naming question** (`MMT-1.5` in -[windows-topology-sys](../windows-topology-sys/CHECKLIST.md)). The planner does not live in +[windows-topology-sys](../windows-topology-sys/COMPLETED-CHECKLIST.md)). The planner does not live in `windows-topology-sys`, which therefore stays a pure Win32 wrapper and keeps its `-sys` name. The decisive point is not preference but the adapter boundary: a crate on one side of an adapter is exactly what `-sys` names, and [D-20](../windows-topology-sys/DESIGN-NOTES.md#d-20) already scoped diff --git a/crates/windows-topology-sys/COMPLETED-CHECKLIST.md b/crates/windows-topology-sys/COMPLETED-CHECKLIST.md index 88b24919..006de22e 100644 --- a/crates/windows-topology-sys/COMPLETED-CHECKLIST.md +++ b/crates/windows-topology-sys/COMPLETED-CHECKLIST.md @@ -551,7 +551,7 @@ item against the code before planning work from it.** cannot reach. `M3+.1.2` matches relations by `(kind, membership)`, so two sources describing one core agree on *that* even if they disagree about its `efficiency_class` -- and the unified relation keeps the walk's value while the CPU-sets value goes unrecorded. - This is [MMT-1.2](CHECKLIST.md)'s **attribute shape** and [D-18](DESIGN-NOTES.md#d-18)'s second + This is [MMT-1.2](COMPLETED-CHECKLIST.md)'s **attribute shape** and [D-18](DESIGN-NOTES.md#d-18)'s second subject kind: an observation whose subject is `(processor, attribute)` rather than `(kind, membership)`. Filed as its own item because the fold's doc comments cite it, and a rule cited in code but scheduled nowhere is exactly the orphaning the "design notes are not a work diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 21c7df0e..ffdad4f4 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -1,8 +1,12 @@ # Design notes: windows-topology-sys (Tier 1) -This crate does not exist yet as compiled code. This file, the checklist beside it, and the design session -it references are the design record that precedes it. Creating the Cargo skeleton is M1.1 in -[CHECKLIST.md](CHECKLIST.md). +This file is the authority for the crate's current behaviour. It began as a design record written +before any code existed; the crate now ships, so where a decision below and the code disagree, the +disagreement is a defect in one of them rather than a plan not yet executed. + +The plans that produced it are complete and archived in +[COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md) -- the `M1`-`M4` enumeration plan, then the `MMT-*` +reshape. Item IDs cited below (`MMT-1.1`, `M4+.1`, ...) resolve against that file. ## Intent @@ -176,7 +180,7 @@ first divides the machine. ## D-15: a relation is its membership, and observations are a set -*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.1.* +*Recorded by [CHECKLIST.md](COMPLETED-CHECKLIST.md) MMT-1.1.* ### The question, and why it looked balanced @@ -252,7 +256,7 @@ here. ## D-16: retry until coherent, and represent what survives -*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.2.* +*Recorded by [CHECKLIST.md](COMPLETED-CHECKLIST.md) MMT-1.2.* ### The problem, stated without the wrong framing @@ -309,12 +313,12 @@ shape. The retry is bounded. Exhausting the bound is not a failure to collect -- it is the **conclusion** that the disagreement is genuine, and the point at which the conflict representation -([MMT-1.2](CHECKLIST.md)'s partition and attribute shapes) applies. The two are one mechanism: retry to remove the +([MMT-1.2](COMPLETED-CHECKLIST.md)'s partition and attribute shapes) applies. The two are one mechanism: retry to remove the transient cases, then represent whatever is left. ## D-17: incoherence in the field, and what it demands -*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.2.* +*Recorded by [CHECKLIST.md](COMPLETED-CHECKLIST.md) MMT-1.2.* ### Windows is the oracle, and an oracle is not a proof @@ -390,7 +394,7 @@ writing down what was seen. ## D-18: what an observation is, and the last of MMT-1.2 -*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.2, which this closes.* +*Recorded by [CHECKLIST.md](COMPLETED-CHECKLIST.md) MMT-1.2, which this closes.* ### The gap D-15 left @@ -444,7 +448,7 @@ still returns a topology. The bound is where transience stops being a possible e ## D-19: the unified view, presented in addition to the individual ones -*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.3. Corrects a pessimistic reading of +*Recorded by [CHECKLIST.md](COMPLETED-CHECKLIST.md) MMT-1.3. Corrects a pessimistic reading of [D-15](#d-15) that had been carried into the M2/M4 plan.* ### The error this corrects @@ -509,7 +513,7 @@ the same requirement. ## D-20: the Win32 boundary, and the deletion of `distances` -*Recorded by [CHECKLIST.md](CHECKLIST.md) MMT-1.4. Supersedes `SH-16.11` in +*Recorded by [CHECKLIST.md](COMPLETED-CHECKLIST.md) MMT-1.4. Supersedes `SH-16.11` in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md), which proposed filling the field.* From be64f170cbe9348c09c5c3f42f0a24ea0bbe8b32 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 20:01:50 -0400 Subject: [PATCH 280/361] fix(topology)!: check the record size before reading its Type field Found reviewing the PR #56 diff (SH-3.1.1). In `cpu_set::decode`, `Type` sits at offset 4, but the loop guard only proved bytes `[offset, offset+4)` are in range, and the size guard admitted any `size >= 1`. The full-struct check that would have covered the read sat one line *after* it, so the read was justified by a predicate the code had not yet established -- the SAFETY comment asserted "this record is at least a full SYSTEM_CPU_SET_INFORMATION" while the check proving that came next. The backing buffer is `vec![0_u64; length.div_ceil(8)]`, which is exactly `length` bytes whenever `length % 8 == 0`. A trailing record declaring a `Size` of 1..=7 therefore put the `Type` read up to 7 bytes past the allocation: an out-of-bounds read, not merely a garbage value. The fix moves the size check ahead of the read and skips an undersized record rather than inspecting it. Behaviour is unchanged -- such a record was not decoded before either -- which is precisely why no test could witness this and why it survived until someone read the guard against the field offsets. Witnessed deterministically rather than argued: with the buffer placed flush against a PAGE_NOACCESS page, the original ordering raises 0xC0000005 and the fixed ordering returns cleanly. The two regression tests added here lock the contract (an undersized record is skipped; an overrunning one stops the walk); the guard-page harness was a throwaway, since an access violation kills the process rather than failing an assertion. Marked `!` because it is a behaviour change in a published crate's unsafe boundary, even though no caller observing well-formed kernel data can tell. Also opens M6 against `walk::decode`, which has the same defect class and worse: its guard is `while offset < length` (one byte proved), it never checks `offset + size <= length` at all, and `decode_body` trusts trailing-array counts unbounded. It is unchanged on this branch, so it is queued rather than folded into a merging PR. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 35 +++++++++++++++- crates/windows-topology-sys/CHECKLIST.md | 27 ++++++++++-- crates/windows-topology-sys/PLANS.md | 6 +-- crates/windows-topology-sys/src/cpu_set.rs | 19 +++++++-- .../windows-topology-sys/src/cpu_set/tests.rs | 41 +++++++++++++++++++ 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index a07d8544..55cb2ebe 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -277,6 +277,33 @@ that previously stood in the way are gone: **0.2.0** for the topology crate. If it proposes 0.1.1, the breaking-change marker did not take and the version would silently understate the break -- fix the marker rather than editing the version by hand, or the next break will do the same thing. + **This item named one crate; SH-3.1.1's diff review found four will be bumped.** The config sets + both `bump-minor-pre-major` and `bump-patch-for-minor-pre-major`, so for a 0.x crate a breaking + change bumps the **minor** and everything else the **patch**. Check all four, not just topology: + + | Crate | From | Expect | Driven by | + |---|---|---|---| + | `windows-topology-sys` | 0.1.0 | **0.2.0** | 7 breaking commits | + | `windows-waitable-queues` | 0.1.0 | **0.2.0** | 6 breaking commits -- but see SH-3.4.1 | + | `windows-file-watcher` | 0.1.3 | **0.2.0** | the reopen-by-id removal | + | `windows-ioring-sys` | 0.2.0 | **0.3.0** | path attribution only -- see SH-3.4.2 | + +- [ ] **SH-3.4.1** -- **Decide `windows-waitable-queues`' first published version before the release + PR merges.** The crate is not on crates.io, sits at 0.1.0 in the manifest, and carries six `!` + commits, so release-please will propose **0.2.0** and 0.1.0 will never exist. The `!` markers are + honest about the branch's history but describe an API that was never published, so nothing can + break. Either accept 0.2.0 as the first version, or force the first release with `Release-As: 0.1.0`. + Not a defect -- a naming decision that is cheap now and permanent afterwards. + +- [ ] **SH-3.4.2** -- **Decide what to do about `windows-ioring-sys`' unearned breaking bump.** + Release-please attributes a commit by the **paths it touches**, not by its Conventional Commits scope. + Two `feat(topology)!` commits (`b9e0c35`, `36e397d`) touched `crates/windows-ioring-sys/`, so it + will take a breaking **0.3.0**. Its public API did not break: the only changes there were one + doc-comment heading in `lib.rs` (`# Topology guidance` -> `# MachineMemoryTopology guidance`) and + code under `examples/ring_copy/`. A 0.3.0 whose CHANGELOG cites breaking changes would misinform + consumers who experience none. **The general lesson outlives this instance**: a breaking commit that + incidentally edits a second crate's files bumps that crate as breaking too, so either keep such + commits path-clean or expect to correct the bump. **The gate this used to hold over SH-2.2 is lifted** -- that item is closed, having had nothing left to do once the pins were deleted rather than maintained. **No longer carries a pin hazard.** An earlier version of this item warned that the PR must not be @@ -300,8 +327,12 @@ that previously stood in the way are gone: - [ ] **SH-4.2** -- Update `windows-ioring-sys` to depend on the published 0.2.0 and release it, per the order settled in SH-2.2. -- [ ] **SH-4.3** -- Release `windows-waitable-queues` 0.1.0, with SH-2.1's fix in place. Confirm the - tag triggered a publish rather than assuming it did. +- [ ] **SH-4.3** -- Release `windows-waitable-queues` at **the version SH-3.4.1 settles**, with + SH-2.1's fix in place. Confirm the tag triggered a publish rather than assuming it did. + **This item said "0.1.0" and that is not what release-please will propose.** Six breaking commits + against a manifest version of 0.1.0 yields **0.2.0** under `bump-minor-pre-major`, on a crate that + has never been published -- so 0.1.0 would be skipped entirely. SH-3.4.1 decides whether to accept + that or force 0.1.0; this item follows it rather than asserting a version of its own. **The gate on [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md) PT-5.3 is void as of 2026-09-02** -- that decision was reversed and the tool is never published to a registry, so there is no gate to lift and no bullet to edit. The tool's GitHub binaries never waited on this. diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index ae6e5ffe..80497824 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -1,7 +1,5 @@ # Checklist: windows-topology-sys -No pending work. - The `MMT-*` plan -- the MachineMemoryTopology reshape that gated PR #56 -- is complete and archived in [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md) under `Moved 2026-09-03`, together with the M1-M4 enumeration plan that preceded it. Cite item IDs (`MMT-1.1`, `M4+.1`, `M5+.4`, ...) against that file. @@ -9,8 +7,29 @@ enumeration plan that preceded it. Cite item IDs (`MMT-1.1`, `M4+.1`, `M5+.4`, . Decisions live in [DESIGN-NOTES.md](DESIGN-NOTES.md), which is the authority for current behaviour; the archived checklist records what was *done*, not what is *true now*. -Plan status is tracked in [PLANS.md](PLANS.md) and [COMPLETED-PLANS.md](COMPLETED-PLANS.md). New work -against this crate reopens a milestone here and adds a row back to `PLANS.md`. +## M6: the record walks' bounds discipline + +Opened 2026-09-03 by the PR #56 diff review (`SH-3.1.1`), which found an out-of-bounds read in +`cpu_set.rs` and, next to it, the same defect class unguarded in `walk.rs`. The `cpu_set.rs` half is +already fixed and shipped in that PR; this milestone is the sibling it exposed. + +- [ ] **M6.1** -- **`walk::decode` trusts the kernel's `Size` without bounding it against the + buffer.** Its loop guard is `while offset < length`, which proves only that **one** byte is in + range, and it then reads `Relationship` and `Size` -- two 4-byte fields at offsets 0 and 4 -- and + advances by `size` with **no** `offset + size <= length` check at any point. The backing buffer is + `vec![0_u64; length.div_ceil(8)]`, i.e. exactly `length` bytes whenever `length % 8 == 0`, so a + record declaring a `Size` that overruns the buffer walks the loop straight past the allocation. + **This is the same defect the review found in `cpu_set.rs`**, where the `Type` read at offset 4 sat + outside the guard that covered it. That one was witnessed deterministically: with the buffer placed + flush against a `PAGE_NOACCESS` page, the original ordering raised `0xC0000005` and the fixed + ordering returned cleanly. `walk.rs` was **unchanged on that branch**, so it was left out of the PR + rather than silently widening a merge -- not because it is less real. + **Do not stop at the loop guard.** `decode_body` is the larger half: it reads each relationship's + trailing array using counts taken from the record with no bound against the buffer at all, so + guarding only the outer loop would give false confidence. Both halves, or neither. + Verify the same way rather than by reasoning: a guard-page harness that faults before the fix and + returns after it. A test alone cannot witness this -- the decoded output is identical either way, + which is exactly why it survived review until someone read the guard against the offsets. ## Deferred, and why diff --git a/crates/windows-topology-sys/PLANS.md b/crates/windows-topology-sys/PLANS.md index 3563afa9..86098929 100644 --- a/crates/windows-topology-sys/PLANS.md +++ b/crates/windows-topology-sys/PLANS.md @@ -1,9 +1,7 @@ # Plans: windows-topology-sys -No plans in progress. - | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| +| [CHECKLIST.md](CHECKLIST.md) | in progress | **M6: the record walks' bounds discipline.** Opened by the PR #56 diff review, which found an out-of-bounds read in `cpu_set.rs` -- the `Type` field is at offset 4, but the loop guard proved only four bytes, so a trailing record declaring a `Size` of 1..=7 put the read past an exactly-sized allocation. That half is fixed and shipped in PR #56. `walk::decode` is the sibling it exposed: its guard is `while offset < length` (one byte), it never checks `offset + size <= length` at all, and `decode_body` trusts each relationship's trailing-array counts unbounded. Left out of PR #56 because `walk.rs` was unchanged there. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | -Completed plans are in [COMPLETED-PLANS.md](COMPLETED-PLANS.md). New work against this crate adds a -row back here and reopens [CHECKLIST.md](CHECKLIST.md). +Completed plans are in [COMPLETED-PLANS.md](COMPLETED-PLANS.md). diff --git a/crates/windows-topology-sys/src/cpu_set.rs b/crates/windows-topology-sys/src/cpu_set.rs index 0be0d9b3..7c2a6793 100644 --- a/crates/windows-topology-sys/src/cpu_set.rs +++ b/crates/windows-topology-sys/src/cpu_set.rs @@ -250,12 +250,23 @@ unsafe fn decode(base: *const u8, length: u32) -> Vec { if size == 0 || offset + size > length { break; } + // `Type` sits at offset 4, so even reading the discriminant needs more + // than the four bytes the loop guard proved. A record shorter than the + // full struct is skipped rather than inspected: the backing buffer is + // `length` bytes exactly when `length % 8 == 0`, so a trailing record + // declaring a `Size` of 1..=7 would put the `Type` read past the + // allocation. Checking the length after reading the field it protects + // is the bug this ordering exists to prevent. + if size < size_of::() { + offset += size; + continue; + } - // SAFETY: `size` bytes from `record` are in range, and this record is at - // least a full `SYSTEM_CPU_SET_INFORMATION`, so every field below is - // within it. + // SAFETY: `size` bytes from `record` are in range, and the check above + // proved this record is at least a full `SYSTEM_CPU_SET_INFORMATION`, + // so every field below is within it. let kind = unsafe { read_at::(record, TYPE_OFFSET) }; - if kind == CpuSetInformation && size >= size_of::() { + if kind == CpuSetInformation { // SAFETY: as above; each offset is computed from the generated type. let all_flags = unsafe { read_at::(record, field!(Anonymous1)) }; records.push(CpuSet { diff --git a/crates/windows-topology-sys/src/cpu_set/tests.rs b/crates/windows-topology-sys/src/cpu_set/tests.rs index 35bfdfb1..4b90c9e4 100644 --- a/crates/windows-topology-sys/src/cpu_set/tests.rs +++ b/crates/windows-topology-sys/src/cpu_set/tests.rs @@ -260,3 +260,44 @@ fn the_availability_flags_are_all_clear_on_this_host() { .collect::>() ); } + +/// A record whose declared `Size` is smaller than the struct is skipped rather +/// than inspected. This is a **memory-safety** guard, not a decoding one: the +/// two behave identically here (an undersized record is not decoded either +/// way), but `Type` sits at offset 4, so reading it needs eight bytes when the +/// loop guard has only proved four. The backing buffer is exactly `length` +/// bytes when `length % 8 == 0`, so a trailing record declaring `Size` in +/// `1..=7` used to put that read past the allocation. +#[test] +fn an_undersized_record_is_skipped_without_reading_its_type() { + // Two four-byte records in an eight-byte buffer: the walk reaches offset 4, + // where reading `Type` would span bytes 8..12 -- past the allocation. + let mut storage = vec![0_u64; 1]; + let base = storage.as_mut_ptr().cast::(); + // SAFETY: both writes are inside the single `u64` of `storage`. + unsafe { + base.cast::().write_unaligned(4); + base.add(4).cast::().write_unaligned(4); + } + + let decoded = decode_all(&storage, 8); + + assert!( + decoded.is_empty(), + "a record too small to hold the struct decodes nothing: {decoded:?}" + ); +} + +/// The walk stops at a record that runs past the declared length rather than +/// reading into whatever follows it. +#[test] +fn a_record_overrunning_the_buffer_stops_the_walk() { + let full = size_of::() as u32; + let mut storage = encode(&[record(7, 0, 0, 0, 0, 0)]); + let base = storage.as_mut_ptr().cast::(); + // Declare a size one byte longer than the buffer actually holds. + // SAFETY: `Size` is the first field, inside the allocation. + unsafe { base.cast::().write_unaligned(full + 1) }; + + assert!(decode_all(&storage, full).is_empty()); +} \ No newline at end of file From 0ff242df85efedb5350f06d73f5266301bb55a14 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 20:03:58 -0400 Subject: [PATCH 281/361] docs: close SH-3.1.1 and assign the draft promotion as SH-3.1.2 The review and description half is done: the PR description and title were rewritten against the diff (both dated from 54 commits ago and omitted the topology reshape and two new crates), and the review found a real out-of-bounds read, one queued sibling defect, and two release-please surprises. The promotion half is the engineer's by decision, so it becomes its own item rather than staying an unowned clause -- which is the gap SH-3.1.1 itself named. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 55cb2ebe..22972395 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -247,8 +247,28 @@ that previously stood in the way are gone: by the time anyone read it, and it stated "54 commits" against a branch now **221 commits** ahead. Its surviving instruction is **SH-3.1.1** below, which is the part that was never done. -- [ ] **SH-3.1.1** -- **Review the PR as a diff rather than as a memory of having written it, then - mark it ready.** 221 commits across the topology crate, the queue crate and the probes is far more +- [x] **SH-3.1.1** -- **Review the PR as a diff rather than as a memory of having written it, then + mark it ready.** + **Done 2026-09-03 for the review and the description; the promotion is SH-3.1.2, which is the + engineer''s.** The item was right that nobody owned "who decides it is ready", so it is now assigned + rather than left implicit. + The description was rewritten against the diff and the title with it: both dated from 2026-08-31 at + 54 commits, against a branch now 282, and neither mentioned the topology reshape -- the largest + change in the branch -- or the two new crates beside the queue crate. It now states the 17 breaking + changes, the four crates release-please will bump, and what ships knowingly unfinished. + **The review found a real out-of-bounds read** in `cpu_set::decode` (`Type` is at offset 4; the + guard proved four bytes; the full-struct check sat one line *after* the read), fixed in `be64f17` + and witnessed with a guard page -- `0xC0000005` before, clean after. A test could not have caught it: + the decoded output is identical either way. It also found the same defect class unguarded in + `walk::decode`, which is unchanged on this branch and so is queued as `M6` in + [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) rather than + folded into a merging PR. Two release-please surprises became SH-3.4.1 and SH-3.4.2. + +- [ ] **SH-3.1.2** -- **The engineer takes PR #56 out of draft**, after reading the diff. Assigned by + decision on 2026-09-03 rather than left unowned, which is the gap SH-3.1.1 named. A draft cannot be + merged, so this gates SH-3.4 whether or not anything else is outstanding. + Not blocking on SH-3.2/SH-3.3 by necessity -- those can run against an open PR -- but they were + deliberately left unrun at promotion time, so do not read "ready" as "gated". 221 commits across the topology crate, the queue crate and the probes is far more than fits in a session's recollection, and the branch contains at least one deliberate breaking change plus several documented reversals of earlier conclusions -- D-18 amended and then superseded, PT-5.3 reversed, SH-14.3 absorbed, and a crate's version scheme changed from semver to From bd9c573c019823a2afd77499205a634babb7caaf Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 20:12:17 -0400 Subject: [PATCH 282/361] docs: record the dependency-containment analysis for PR #56 The branch adds no third-party dependency and changes no external dependency version: the only additions to Cargo.lock are the two new workspace-local crates. Both new publishable crates cost exactly one external dependency (windows-sys, default-features off, named features), and everything heavier -- serde, serde_json, windows-core -- is optional and off by default. cargo publish --dry-run is clean at the branch tip for the four crates that publish, which settles that windows-ioring-sys' versionless path dev-dependencies are stripped rather than blocking, and that nothing pins the two crates about to reach 0.2.0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 22972395..b6da62c0 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -202,6 +202,17 @@ order is the authority and the numbers are only names. `RUSTDOCFLAGS`. - [ ] **SH-2.3** -- Dry-run both publishes (`cargo publish --dry-run`) from the merge commit, and read + **Run at the branch tip on 2026-09-03 and clean for four crates** -- `windows-waitable-queues`, + `windows-topology-sys`, `windows-file-watcher`, `windows-ioring-sys`. Still open because this item + asks for the **merge commit**, which does not exist yet; the branch-tip run is evidence that nothing + structural blocks it, not a substitute. + What the run settled, so it is not re-litigated: `windows-ioring-sys` publishes while holding + *versionless* path dev-dependencies on `windows-topology-sys` and `windows-guard-alloc`. Those are + stripped from the published manifest, which is exactly what makes them legal -- a version pin there + would fail, naming a crate that is unpublished (`guard-alloc`) or not yet at that version. And no + crate anywhere pins `windows-topology-sys` or `windows-waitable-queues` to a version, so the 0.2.0 + bumps cannot break resolution. The branch adds **no third-party dependency** and changes **no + external dependency version**: the only additions to `Cargo.lock` are the two new workspace crates. the packaged file list rather than only the exit code. A crate that builds in a workspace can still fail to package -- excluded files, a path dependency without a version, a README that is not in the package. From ee0d9f3964c98aa35819d7f9a1037a59aafb6a4d Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 20:38:43 -0400 Subject: [PATCH 283/361] docs(topology): rule on the two record walks (D-24), and replan M6 The two decoders were each internally coherent and mutually opposite, which is restatement drift: one rule -- how a Size-chained record list is walked -- stated twice and differing, with nothing detecting it. The asymmetry ran the wrong way, since the unchecked walk is the one with a u16 count multiplying a 16-byte stride. The ruling: panic is out (a malformed record is not evidence that we are inconsistent, and it leaves the caller nowhere to go); the walk is shared because careful traversal is simply correct, not defensive; this is not a trust boundary, so no validation pass is added and D-16's distrust is clarified as being about correlation between sources rather than structural validity; and incoherence is recorded in the returned data, as D-18 does for attribute conflicts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 57 +++++++++++------- crates/windows-topology-sys/DESIGN-NOTES.md | 66 +++++++++++++++++++++ crates/windows-topology-sys/PLANS.md | 2 +- 3 files changed, 101 insertions(+), 24 deletions(-) diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 80497824..ec638109 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -7,29 +7,40 @@ enumeration plan that preceded it. Cite item IDs (`MMT-1.1`, `M4+.1`, `M5+.4`, . Decisions live in [DESIGN-NOTES.md](DESIGN-NOTES.md), which is the authority for current behaviour; the archived checklist records what was *done*, not what is *true now*. -## M6: the record walks' bounds discipline - -Opened 2026-09-03 by the PR #56 diff review (`SH-3.1.1`), which found an out-of-bounds read in -`cpu_set.rs` and, next to it, the same defect class unguarded in `walk.rs`. The `cpu_set.rs` half is -already fixed and shipped in that PR; this milestone is the sibling it exposed. - -- [ ] **M6.1** -- **`walk::decode` trusts the kernel's `Size` without bounding it against the - buffer.** Its loop guard is `while offset < length`, which proves only that **one** byte is in - range, and it then reads `Relationship` and `Size` -- two 4-byte fields at offsets 0 and 4 -- and - advances by `size` with **no** `offset + size <= length` check at any point. The backing buffer is - `vec![0_u64; length.div_ceil(8)]`, i.e. exactly `length` bytes whenever `length % 8 == 0`, so a - record declaring a `Size` that overruns the buffer walks the loop straight past the allocation. - **This is the same defect the review found in `cpu_set.rs`**, where the `Type` read at offset 4 sat - outside the guard that covered it. That one was witnessed deterministically: with the buffer placed - flush against a `PAGE_NOACCESS` page, the original ordering raised `0xC0000005` and the fixed - ordering returned cleanly. `walk.rs` was **unchanged on that branch**, so it was left out of the PR - rather than silently widening a merge -- not because it is less real. - **Do not stop at the loop guard.** `decode_body` is the larger half: it reads each relationship's - trailing array using counts taken from the record with no bound against the buffer at all, so - guarding only the outer loop would give false confidence. Both halves, or neither. - Verify the same way rather than by reasoning: a guard-page harness that faults before the fix and - returns after it. A test alone cannot witness this -- the decoded output is identical either way, - which is exactly why it survived review until someone read the guard against the offsets. +## M6: one record walk, per D-24 + +Opened 2026-09-03 by the PR #56 diff review (`SH-3.1.1`), which found the crate''s two record +decoders internally coherent and mutually opposite. [D-24](DESIGN-NOTES.md#d-24) is the ruling this +milestone implements: **one shared walk, no panic, incoherence recorded in the returned data, and no +trust boundary** -- the OS is trusted for structural validity, and the careful walk is simply how +variable-length records are traversed correctly. + +- [ ] **M6.1** -- **A shared, self-bounding record walk.** New private module: an iterator over a + `Size`-chained record list, parameterised by the offset of the `Size` field and the minimum record + size, yielding a **record view bounded by its own `Size`**. The view''s read accessor returns + nothing when the read would leave the record, so a trailing array cannot be read past the record + that declares it -- the `GroupCount` amplification closes *by construction*, not by a separate + check. Built first and unused; `walk.rs` and `cpu_set.rs` adopt it in M6.3/M6.4. + +- [ ] **M6.2** -- **Vocabulary for a record that did not fit, and somewhere for it to live.** A public + anomaly type carrying the [`Source`](src/observation.rs) that was being read, the byte offset, and + what was wrong, plus a new `MachineMemoryTopology` field to carry them. Breaking (the struct has + public fields and is deliberately hand-constructible, so it does not take `#[non_exhaustive]`), + which is free on this branch. `serde(default)` so an existing description still deserializes. + +- [ ] **M6.3** -- **Port `cpu_set::decode` to the shared walk.** Its existing checks become the shared + ones; its silent `break` becomes a recorded anomaly. Behaviour for well-formed input is unchanged, + which its five malformed-input tests should confirm without being rewritten. + +- [ ] **M6.4** -- **Port `walk::decode` to the shared walk, and delete the `assert!`.** This is the + item with the actual defect in it: a zero `Size` currently panics, `offset + size` is never checked + against the buffer, and `read_group_affinities` reads `GroupCount` x 16 bytes unbounded. All three + resolve into the shared walk. Add the malformed-input tests this file has never had. + Verify the amplification is closed the way `cpu_set`''s was -- a guard-page harness, since the + decoded output is identical either way and no ordinary test can witness it. + +- [ ] **M6.5** -- **Surface the anomalies through `discover()`**, and state the policy where a reader + will meet it: the module docs of both walks, which currently say opposite things about trust. ## Deferred, and why diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index ffdad4f4..731e9a0b 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -47,6 +47,7 @@ additional CPU and memory cost; do not solve the consumer's architecture for the | D-21 | **This crate publishes a *refined view of what the platform publishes* -- it is not shaped by the planner.** The model was originally expected to couple tightly to the solver, which is why its reshape was planned against the planner's requirements; the **adapter** between the platform data model and the planner relieves that tension, and the engineer's clarification makes the refinement the crate's whole job. The scope test is therefore "is this a refinement of what Windows reports?", never "does the planner need it?" -- and a planner requirement with **no platform correspondence is the adapter's problem**, not a gap here. Two consequences: the reshape (M2-M5) is **self-justified** and no longer waits on a planner, and `MMT-1.3` stops gating it, because what a consumer *does* with an unobserved fact is not a question about a refined view of platform data. The model owes only that the absence be representable and distinguishable, which is [D-13](#d-13) and `M2+.5`. `EP-D-1`..`EP-D-3` survive as **evidence** the shape is right rather than as its justification -- a shape that answers a real caller's questions is better validated than one invented in the abstract. | | D-22 | **The whole-object [`Provenance`] survives per-relation provenance, because it is not an aggregate of it.** `M3+.3` planned to supersede it, arguing that an object-level scalar "can only be the minimum ... or the maximum, which is dishonest". That premise is wrong: `Provenance` records **how the object was obtained** -- `discover()` stamps `Measured`, deserialization is capped at `Restored`, hand construction defaults to `Synthetic` -- which is a fact about the *construction act*, not a roll-up of anything. No per-relation value can express it, and `windows-placement-probe` depends on exactly it: `Record::is_trustworthy` gates on `is_measured()` to decide whether a measurement counts. The two are **orthogonal and both kept**: the object says how the collection happened, a relation says which source reported it. They also compose usefully -- a `Measured` topology with a hand-inserted `Synthetic` relation is precisely the mixed case `M3+.3` was groping at, and per-relation provenance is what makes it visible rather than a reason to delete the object-level fact. | | D-23 | **`SYSTEM_CPU_SET_INFORMATION`'s `AllFlags` byte is measured to be **constant zero** on Windows 11 25H2 (10.0.26200.9168, AMD64), even in the state it is documented to describe.** Established by experiment, not inference: `SetProcessDefaultCpuSets` was called successfully, `GetProcessDefaultCpuSets` confirmed the allocation stuck (`[0x100, 0x101]`), and `AllFlags` still read `0x00` for **every** processor -- under a `NULL` process handle, the `GetCurrentProcess()` pseudo-handle, and a real `OpenProcess` handle alike. So `parked`, `allocated`, `allocated_to_target_process` and `real_time` carry **no information** on this build, and a consumer reading `false` is reading a byte the kernel did not populate rather than a fact about the machine. Two consequences: the bit *positions* can be neither confirmed nor falsified from an all-zero byte, so they stand on the SDK's declared bitfield order alone; and **no behaviour may depend on these fields** -- which is why `M4+.2` ships them as values with no judgement over them, after a `usable()` helper written against them refused every processor on this machine. | +| D-24 | **The record walks are shared, they never panic, and a structurally incoherent record is an observation recorded in the returned data.** The engineer's ruling, settling a divergence in which the crate's two decoders -- `cpu_set::decode` and `walk::decode` -- were each internally coherent and mutually opposite: one bounded every read against the buffer and stopped on a bad `Size`, the other proved one byte, `assert!`ed on a zero `Size`, and read a `u16` `GroupCount` times a 16-byte stride (up to **1,048,560 bytes**) with no bound at all. Three parts. **Panic is not an option**: a malformed structure is not evidence that *we* reached an inconsistent state, and taking the caller down over it leaves them nowhere to go. **The careful walk is simply the correct way to traverse variable-length records** -- bounds are how you know where a record ends -- so it is shared rather than restated, which is [CONTRACT INTEGRITY](../../.github/copilot-instructions.md) applied to the one rule the two sites had drifted on. And **this is not a trust boundary**: the OS is trusted for structural validity, and [D-16](#d-16)'s distrust was always about *correlation between sources*, never about whether a data structure is well-formed -- so there is no validation pass, no untrusted-input posture, and no re-verification of what the kernel just wrote. What incoherence there is gets **recorded**, in the same spirit as [D-18](#d-18)'s attribute conflicts: the topology carries what was observed, including the observation that a record did not fit. | ## D-12: provenance, and why the default points at distrust @@ -687,3 +688,68 @@ Linux, and that finding was sound on its own terms -- but it was a conclusion ab D-20 is a ruling about the crate's *scope*: this crate does not go below the Win32 topology APIs, so a fact only firmware reports is not one it carries at all. The field is deleted, and the capability the Linux comparison vindicated is knowingly given up. + +## D-24: one record walk, no panic, and incoherence as an observation + +The crate reads two variable-length record chains from Windows -- `GetLogicalProcessorInformationEx` +in `walk.rs` and `GetSystemCpuSetInformation` in `cpu_set.rs`. They share their scaffolding almost +exactly: the same two-call sizing against `ERROR_INSUFFICIENT_BUFFER`, the same `vec![0_u64; ...]` +backing chosen for 8-byte alignment (`cpu_set.rs` cites `walk.rs` for it), the same `read_at` +helper with the same safety wording. + +They then disagreed completely about how to walk what they had read, and neither said why: + +| | `cpu_set::decode` | `walk::decode` | +|---|---|---| +| loop guard proves | the `Size` field is readable | one byte | +| `Size` against the buffer | stops when it overruns | unchecked | +| a zero `Size` | stops, returns what it has | **`assert!` -- panics** | +| trailing array | none exist | `GroupCount` x 16 bytes, unchecked | +| malformed-input tests | five | none | + +Neither file was careless. Each was coherent across its code, its comments and its tests -- they were +two opposite designs sitting in one crate, which is exactly the restatement drift the repository's +CONTRACT INTEGRITY rule exists to catch: one rule, stated twice, differing, with nothing detecting it. + +The asymmetry ran the wrong way. `walk.rs` had strictly *more* surface and strictly *less* checking: +`GroupCount` is a `u16` read out of the buffer that multiplies a 16-byte stride, so a maximal value +reads **1,048,560 bytes** past the record. `cpu_set.rs` has no trailing array at all, and it was the +one bounding its reads. + +### The ruling + +**Panic is not an option.** A record that does not fit is not evidence that *this crate* has reached +an internally inconsistent state, which is the only thing a panic should mean. Killing the caller +over a malformed byte leaves them with no move: they cannot catch it meaningfully, cannot degrade, +and cannot report anything more useful than that we gave up. The `assert!` goes. + +**The careful walk is not defensiveness -- it is how you traverse variable-length records correctly.** +Bounds are how the walk knows where a record ends; they are load-bearing for *decoding*, not a guard +bolted on against a hostile buffer. That reframing is what makes sharing obvious: there is one +correct way to walk a `Size`-chained record list, so it is written once and both decoders use it, +rather than each site re-deriving it and drifting. + +**This is not a trust boundary, and no validation pass is added.** The operating system is trusted +for the structural validity of a buffer it just wrote. [D-16](#d-16)'s distrust has been misread as +supporting the opposite -- it does not: that decision is about *correlation between sources*, about +whether two observers agree on what they saw, and says nothing about whether a data structure is +well-formed. So there is no untrusted-input posture here, no verify-then-decode two-pass, and no +re-checking of the kernel's own output for its own sake. + +**Structural incoherence is recorded, not swallowed and not thrown.** This is [D-18](#d-18)'s +instinct applied one layer down: the topology already carries what each source *said*, including +where sources disagree, precisely because destroying a disagreement destroys the only thing a second +observer is for. A record that does not fit its buffer is the same kind of fact -- something the +enumeration observed -- so it travels back with the data rather than vanishing into an early `break`. +A consumer that does not care is unaffected; one debugging a strange machine gets the byte offset and +the reason instead of a silently short list. + +### What follows + +The shared walk yields records **bounded by their own `Size`**, so a read that would leave the record +returns nothing rather than reaching past it. That closes the `GroupCount` amplification *by +construction* rather than by a separate check -- the trailing array simply cannot be read beyond the +record that declares it, because the walk never hands out the bytes. + +Recorded as `M6` in [CHECKLIST.md](CHECKLIST.md), which is where the work is queued; this decision is +the thing that work derives from. diff --git a/crates/windows-topology-sys/PLANS.md b/crates/windows-topology-sys/PLANS.md index 86098929..d6a27e6f 100644 --- a/crates/windows-topology-sys/PLANS.md +++ b/crates/windows-topology-sys/PLANS.md @@ -2,6 +2,6 @@ | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| -| [CHECKLIST.md](CHECKLIST.md) | in progress | **M6: the record walks' bounds discipline.** Opened by the PR #56 diff review, which found an out-of-bounds read in `cpu_set.rs` -- the `Type` field is at offset 4, but the loop guard proved only four bytes, so a trailing record declaring a `Size` of 1..=7 put the read past an exactly-sized allocation. That half is fixed and shipped in PR #56. `walk::decode` is the sibling it exposed: its guard is `while offset < length` (one byte), it never checks `offset + size <= length` at all, and `decode_body` trusts each relationship's trailing-array counts unbounded. Left out of PR #56 because `walk.rs` was unchanged there. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | +| [CHECKLIST.md](CHECKLIST.md) | in progress | **M6: one record walk, per [D-24](DESIGN-NOTES.md#d-24).** The PR #56 diff review found the crate's two record decoders internally coherent and mutually opposite: `cpu_set` bounded every read and stopped on a bad `Size`; `walk` proved one byte, `assert!`ed on a zero `Size`, and read `GroupCount` x 16 bytes unbounded (up to 1,048,560). The ruling: one shared self-bounding walk, never panic, incoherence recorded in the returned data, and no trust boundary -- the OS is trusted for structural validity, and careful walking is just correct traversal. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | Completed plans are in [COMPLETED-PLANS.md](COMPLETED-PLANS.md). From b4fad3bfbde825fc4b1eb800a78d3f07136c52d3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 20:54:27 -0400 Subject: [PATCH 284/361] feat(topology)!: one shared record walk, and no panic on a malformed record (M6) Implements D-24. The crate's two Win32 record decoders shared their scaffolding almost exactly and then disagreed completely about how to walk what they had read, with nothing detecting the disagreement -- one bounded every read and stopped on a bad `Size`, the other proved a single byte, `assert!`ed on a zero `Size`, and read a `u16` `GroupCount` times a 16-byte stride with no bound at all. The traversal now lives once, in `records`, and both decoders use it: - **Nothing panics.** `walk::decode`'s `assert!(size > 0)` is deleted. A malformed record is not evidence that this crate reached an inconsistent state, and taking the caller's process down over one leaves them nowhere to go. - **A record view cannot read past itself.** Reads are bounded by the record's own declared `Size`, so a trailing array sized by a count from inside that record cannot reach beyond it. This closes the `GroupCount` amplification by construction rather than by a separate check. - **Incoherence is recorded, not swallowed.** New `EnumerationAnomaly` / `AnomalyKind`, carried on a new `MachineMemoryTopology::enumeration_anomalies` field, so a truncated enumeration is distinguishable from a small machine. Empty on every healthy machine, and `serde(default)` so existing descriptions still deserialize. This is deliberately **not** a trust boundary and adds no validation pass: the OS is trusted for the structural validity of a buffer it just wrote. The bounds are here because they are how a walk knows where a record ends. D-16's distrust is clarified in D-24 as being about correlation between sources, never about whether a data structure is well-formed. A measurement changed the design. The obvious minimum record size for the relationship walk -- `size_of::()` -- is wrong: the struct is 80 bytes because its union is as large as `GROUP_RELATIONSHIP` (72), while a real processor-core record is 8 + 40 = 48. Using it would have rejected every processor, cache and NUMA record on every machine. The minimum is the 8-byte header; each body bounds its own reads. Verified rather than argued: with a 48-byte record flush against a PAGE_NOACCESS page and `GroupCount = 65535`, the unbounded read raises 0xC0000005 and the record-bounded one returns cleanly. `walk.rs` also gains the five malformed-input tests it never had, including the zero-`Size` case that used to panic, plus a live-system test asserting a healthy machine reports no anomalies. Breaking: `MachineMemoryTopology` gains a public field and `Relations` gains `anomalies`. The type stays hand-constructible by design, so it does not take `#[non_exhaustive]`. The five checklist items land together because they are one coupled change: M6.1 produces anomalies so it needs M6.2's type, neither is warning-free until M6.3/M6.4 give them a consumer, and M6.4 changes the signature M6.5 surfaces. Recorded as such rather than split into a fiction of five commits. Completed items: M6.1, M6.2, M6.3, M6.4, M6.5 Completed item: M6.1: A shared, self-bounding record walk. Completed item: M6.2: Vocabulary for a record that did not fit, and somewhere for it to live. Completed item: M6.3: Port cpu_set::decode to the shared walk. Completed item: M6.4: Port walk::decode to the shared walk, and delete the assert!. Completed item: M6.5: Surface the anomalies through discover(), and state the policy in both module docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/CHECKLIST.md | 25 +- crates/windows-topology-sys/src/anomaly.rs | 123 ++++++ crates/windows-topology-sys/src/cpu_set.rs | 138 +++--- .../windows-topology-sys/src/cpu_set/tests.rs | 19 +- .../src/granularity/tests.rs | 1 + crates/windows-topology-sys/src/lib.rs | 5 + crates/windows-topology-sys/src/records.rs | 215 +++++++++ .../windows-topology-sys/src/records/tests.rs | 124 ++++++ crates/windows-topology-sys/src/relation.rs | 7 +- crates/windows-topology-sys/src/topology.rs | 25 +- .../src/topology/tests.rs | 13 + crates/windows-topology-sys/src/walk.rs | 413 +++++++++++------- crates/windows-topology-sys/src/walk/tests.rs | 132 +++++- 13 files changed, 982 insertions(+), 258 deletions(-) create mode 100644 crates/windows-topology-sys/src/anomaly.rs create mode 100644 crates/windows-topology-sys/src/records.rs create mode 100644 crates/windows-topology-sys/src/records/tests.rs diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index ec638109..922339bd 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -15,31 +15,46 @@ milestone implements: **one shared walk, no panic, incoherence recorded in the r trust boundary** -- the OS is trusted for structural validity, and the careful walk is simply how variable-length records are traversed correctly. -- [ ] **M6.1** -- **A shared, self-bounding record walk.** New private module: an iterator over a +**All five landed in one commit, and the split was wrong.** M6.1 produces anomalies, so it cannot +compile without M6.2''s type; neither can be warning-free until M6.3/M6.4 give them a consumer; and +M6.4 changes `enumerate`''s signature, which is what M6.5 surfaces. They are one coupled change and +are recorded as such rather than teased into a fiction of five commits. + +**One measurement changed the design.** The obvious minimum record size for the relationship walk is +`size_of::()` -- and it is **wrong**. That struct is 80 +bytes because its union is as large as `GROUP_RELATIONSHIP` (72), while a real processor-core record +is 8 + 40 = 48. Using it would have rejected every processor, cache and NUMA record on every machine. +The minimum is the 8-byte header, and each body bounds its own reads instead. + +**The amplification is closed by construction and witnessed, not argued.** With a 48-byte record +flush against a `PAGE_NOACCESS` page and `GroupCount = 65535`, the unbounded read raises `0xC0000005` +and the record-bounded one returns cleanly. + +- [x] **M6.1** -- **A shared, self-bounding record walk.** New private module: an iterator over a `Size`-chained record list, parameterised by the offset of the `Size` field and the minimum record size, yielding a **record view bounded by its own `Size`**. The view''s read accessor returns nothing when the read would leave the record, so a trailing array cannot be read past the record that declares it -- the `GroupCount` amplification closes *by construction*, not by a separate check. Built first and unused; `walk.rs` and `cpu_set.rs` adopt it in M6.3/M6.4. -- [ ] **M6.2** -- **Vocabulary for a record that did not fit, and somewhere for it to live.** A public +- [x] **M6.2** -- **Vocabulary for a record that did not fit, and somewhere for it to live.** A public anomaly type carrying the [`Source`](src/observation.rs) that was being read, the byte offset, and what was wrong, plus a new `MachineMemoryTopology` field to carry them. Breaking (the struct has public fields and is deliberately hand-constructible, so it does not take `#[non_exhaustive]`), which is free on this branch. `serde(default)` so an existing description still deserializes. -- [ ] **M6.3** -- **Port `cpu_set::decode` to the shared walk.** Its existing checks become the shared +- [x] **M6.3** -- **Port `cpu_set::decode` to the shared walk.** Its existing checks become the shared ones; its silent `break` becomes a recorded anomaly. Behaviour for well-formed input is unchanged, which its five malformed-input tests should confirm without being rewritten. -- [ ] **M6.4** -- **Port `walk::decode` to the shared walk, and delete the `assert!`.** This is the +- [x] **M6.4** -- **Port `walk::decode` to the shared walk, and delete the `assert!`.** This is the item with the actual defect in it: a zero `Size` currently panics, `offset + size` is never checked against the buffer, and `read_group_affinities` reads `GroupCount` x 16 bytes unbounded. All three resolve into the shared walk. Add the malformed-input tests this file has never had. Verify the amplification is closed the way `cpu_set`''s was -- a guard-page harness, since the decoded output is identical either way and no ordinary test can witness it. -- [ ] **M6.5** -- **Surface the anomalies through `discover()`**, and state the policy where a reader +- [x] **M6.5** -- **Surface the anomalies through `discover()`**, and state the policy where a reader will meet it: the module docs of both walks, which currently say opposite things about trust. ## Deferred, and why diff --git a/crates/windows-topology-sys/src/anomaly.rs b/crates/windows-topology-sys/src/anomaly.rs new file mode 100644 index 00000000..92a8913a --- /dev/null +++ b/crates/windows-topology-sys/src/anomaly.rs @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Mike Grier +//! What an enumeration observed about the shape of what it was reading. + +use crate::observation::Source; + +/// A record that did not fit the buffer that contained it. +/// +/// Per [D-24](../DESIGN-NOTES.md#d-24) a structurally incoherent record is +/// **recorded rather than thrown or swallowed**. It is not evidence that this +/// crate reached an inconsistent state, so it is not a panic; and dropping it +/// silently would leave a consumer with a short list and no way to tell a +/// truncated enumeration from a small machine. +/// +/// This is [`crate::AttributeObservation`]'s instinct one layer down: the +/// topology carries what was observed, including the observation that the +/// bytes did not describe what they claimed to. +/// +/// None of these are expected. Windows does not produce them, and a +/// `MachineMemoryTopology` from [`crate::MachineMemoryTopology::discover`] on a +/// healthy machine carries none. They exist so that a machine which *is* +/// misbehaving -- a defective hypervisor, a driver corrupting a buffer -- is +/// diagnosable from the returned data rather than from a debugger. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct EnumerationAnomaly { + /// Which enumeration was being read. + pub source: Source, + /// The byte offset within that enumeration's buffer where it stopped. + pub offset: usize, + /// What was wrong there. + pub kind: AnomalyKind, +} + +/// What made a record undecodable. +#[derive(Clone, Debug, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[non_exhaustive] +pub enum AnomalyKind { + /// The record declared a length too small to hold its own fixed fields. + /// + /// A `Size` of zero is this case, and it is the one that would otherwise + /// loop forever: the walk advances by `Size`. + Undersized { + /// The length the record declared. + declared: usize, + /// The smallest length that could hold the record's fixed fields. + minimum: usize, + }, + /// The record declared a length longer than the bytes remaining. + OverrunsBuffer { + /// The length the record declared. + declared: usize, + /// The bytes actually left in the buffer. + remaining: usize, + }, + /// Bytes were left over that cannot hold another record's length field. + TrailingBytes { + /// How many bytes were left. + remaining: usize, + }, + /// A record's trailing array declared more entries than the record held. + /// + /// The entries that did fit are decoded and kept; this records that the + /// count claimed more. + TruncatedArray { + /// The entry count the record declared. + declared: usize, + /// How many were actually read before the record ended. + decoded: usize, + }, +} + +impl EnumerationAnomaly { + pub(crate) fn undersized( + source: Source, + offset: usize, + declared: usize, + minimum: usize, + ) -> Self { + Self { + source, + offset, + kind: AnomalyKind::Undersized { declared, minimum }, + } + } + + pub(crate) fn overruns( + source: Source, + offset: usize, + declared: usize, + remaining: usize, + ) -> Self { + Self { + source, + offset, + kind: AnomalyKind::OverrunsBuffer { + declared, + remaining, + }, + } + } + + pub(crate) fn trailing_bytes(source: Source, offset: usize, remaining: usize) -> Self { + Self { + source, + offset, + kind: AnomalyKind::TrailingBytes { remaining }, + } + } + + pub(crate) fn truncated_array( + source: Source, + offset: usize, + declared: usize, + decoded: usize, + ) -> Self { + Self { + source, + offset, + kind: AnomalyKind::TruncatedArray { declared, decoded }, + } + } +} diff --git a/crates/windows-topology-sys/src/cpu_set.rs b/crates/windows-topology-sys/src/cpu_set.rs index 7c2a6793..4873d35c 100644 --- a/crates/windows-topology-sys/src/cpu_set.rs +++ b/crates/windows-topology-sys/src/cpu_set.rs @@ -28,7 +28,27 @@ //! Merging them here would silently pick a winner and destroy the disagreement, //! which is the one thing a second observer is *for*. The records come back as //! what they are; deciding what to do when they differ is tracked separately. +//! +//! ## How the records are walked +//! +//! Through [`crate::records`], which both of this crate's enumerations share. +//! Per [D-24](../DESIGN-NOTES.md#d-24) the operating system is **trusted** for +//! the structural validity of a buffer it just wrote -- this is not a trust +//! boundary and there is no validation pass. The walk bounds its reads because +//! bounds are how it knows where a record ends, which is a decoding +//! requirement rather than a defence. +//! +//! Two consequences a reader should not have to infer: nothing here **panics** +//! over the shape of the buffer, because a malformed record is not evidence +//! that this crate reached an inconsistent state; and a record that cannot be +//! decoded is **recorded** in +//! [`MachineMemoryTopology::enumeration_anomalies`](crate::MachineMemoryTopology::enumeration_anomalies) +//! rather than silently dropped, so a short list is distinguishable from a +//! small machine. +use crate::EnumerationAnomaly; +use crate::observation::Source; +use crate::records::RecordWalk; use std::io; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; @@ -145,7 +165,7 @@ mod flags { /// /// Returns any error from `GetSystemCpuSetInformation` other than the expected /// sizing failure. -pub(crate) fn enumerate() -> io::Result> { +pub(crate) fn enumerate() -> io::Result<(Vec, Option)> { let mut length: u32 = 0; // SAFETY: a null buffer with a zero length and a valid out-pointer, which is // the documented sizing call. A null process handle names this process. @@ -161,14 +181,14 @@ pub(crate) fn enumerate() -> io::Result> { if probe != 0 { // Succeeding on the sizing call would mean zero bytes were needed, so // there is nothing to report. - return Ok(Vec::new()); + return Ok((Vec::new(), None)); } let error = io::Error::last_os_error(); if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { return Err(error); } if length == 0 { - return Ok(Vec::new()); + return Ok((Vec::new(), None)); } // `u64`-backed storage for the same reason the relationship walk uses it: @@ -204,23 +224,13 @@ const SIZE_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Siz const TYPE_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Type); const UNION_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Anonymous); -/// Read a `T` from `base + offset` without assuming alignment. -/// -/// # Safety -/// -/// `base + offset` must address at least `size_of::()` initialized bytes. -unsafe fn read_at(base: *const u8, offset: usize) -> T { - // SAFETY: forwarded from the caller. - unsafe { base.add(offset).cast::().read_unaligned() } -} - /// Walk `length` bytes of consecutive records. /// /// # Safety /// /// `base` must address `length` initialized bytes laid out as consecutive /// `SYSTEM_CPU_SET_INFORMATION` records. -unsafe fn decode(base: *const u8, length: u32) -> Vec { +unsafe fn decode(base: *const u8, length: u32) -> (Vec, Option) { // Offsets within the `CpuSet` arm of the record's union, computed from the // generated types so a binding change moves them rather than silently // shifting what is read. @@ -236,64 +246,56 @@ unsafe fn decode(base: *const u8, length: u32) -> Vec { }; } - let mut records = Vec::new(); - let mut offset = 0_usize; - let length = length as usize; - - while offset + SIZE_OFFSET + size_of::() <= length { - let record = unsafe { base.add(offset) }; - // SAFETY: the bound above proved `Size` itself is in range. - let size = unsafe { read_at::(record, SIZE_OFFSET) } as usize; - // A zero or oversized `Size` would loop forever or read past the end. - // Windows does not produce either, and trusting it anyway is how a - // hostile or corrupt buffer becomes a hang instead of a stop. - if size == 0 || offset + size > length { - break; - } - // `Type` sits at offset 4, so even reading the discriminant needs more - // than the four bytes the loop guard proved. A record shorter than the - // full struct is skipped rather than inspected: the backing buffer is - // `length` bytes exactly when `length % 8 == 0`, so a trailing record - // declaring a `Size` of 1..=7 would put the `Type` read past the - // allocation. Checking the length after reading the field it protects - // is the bug this ordering exists to prevent. - if size < size_of::() { - offset += size; - continue; - } + // SAFETY: forwarded from this function's own contract. + let mut walk = unsafe { + RecordWalk::new( + base, + length, + SIZE_OFFSET, + size_of::(), + Source::CpuSets, + ) + }; - // SAFETY: `size` bytes from `record` are in range, and the check above - // proved this record is at least a full `SYSTEM_CPU_SET_INFORMATION`, - // so every field below is within it. - let kind = unsafe { read_at::(record, TYPE_OFFSET) }; - if kind == CpuSetInformation { - // SAFETY: as above; each offset is computed from the generated type. - let all_flags = unsafe { read_at::(record, field!(Anonymous1)) }; - records.push(CpuSet { - id: unsafe { read_at::(record, field!(Id)) }, - group: unsafe { read_at::(record, field!(Group)) }, - logical_processor_index: unsafe { - read_at::(record, field!(LogicalProcessorIndex)) - }, - core_index: unsafe { read_at::(record, field!(CoreIndex)) }, - last_level_cache_index: unsafe { - read_at::(record, field!(LastLevelCacheIndex)) - }, - numa_node_index: unsafe { read_at::(record, field!(NumaNodeIndex)) }, - efficiency_class: unsafe { read_at::(record, field!(EfficiencyClass)) }, - parked: all_flags & flags::PARKED != 0, - allocated: all_flags & flags::ALLOCATED != 0, - allocated_to_target_process: all_flags & flags::ALLOCATED_TO_TARGET_PROCESS != 0, - real_time: all_flags & flags::REAL_TIME != 0, - scheduling_class: unsafe { read_at::(record, field!(Anonymous2)) }, - allocation_tag: unsafe { read_at::(record, field!(AllocationTag)) }, - }); + let mut records = Vec::new(); + for record in &mut walk { + // Every read below is bounded by the record's own `Size`, and the walk + // has already established that `Size` covers a full + // `SYSTEM_CPU_SET_INFORMATION` -- so none of these can fail. They are + // written as options rather than asserted because the alternative to a + // `None` that skips a record is a panic, and per D-24 this crate does + // not panic over the shape of someone else's buffer. + // SAFETY: the walk yielded a record addressing `size` initialized bytes. + let decoded = unsafe { + (|| { + if record.read::(TYPE_OFFSET)? != CpuSetInformation { + return None; + } + let all_flags = record.read::(field!(Anonymous1))?; + Some(CpuSet { + id: record.read(field!(Id))?, + group: record.read(field!(Group))?, + logical_processor_index: record.read(field!(LogicalProcessorIndex))?, + core_index: record.read(field!(CoreIndex))?, + last_level_cache_index: record.read(field!(LastLevelCacheIndex))?, + numa_node_index: record.read(field!(NumaNodeIndex))?, + efficiency_class: record.read(field!(EfficiencyClass))?, + parked: all_flags & flags::PARKED != 0, + allocated: all_flags & flags::ALLOCATED != 0, + allocated_to_target_process: all_flags & flags::ALLOCATED_TO_TARGET_PROCESS + != 0, + real_time: all_flags & flags::REAL_TIME != 0, + scheduling_class: record.read(field!(Anonymous2))?, + allocation_tag: record.read(field!(AllocationTag))?, + }) + })() + }; + if let Some(cpu_set) = decoded { + records.push(cpu_set); } - - offset += size; } - records + (records, walk.anomaly()) } #[cfg(test)] diff --git a/crates/windows-topology-sys/src/cpu_set/tests.rs b/crates/windows-topology-sys/src/cpu_set/tests.rs index 4b90c9e4..5782ced2 100644 --- a/crates/windows-topology-sys/src/cpu_set/tests.rs +++ b/crates/windows-topology-sys/src/cpu_set/tests.rs @@ -47,6 +47,13 @@ fn record( } fn decode_all(storage: &[u64], length: u32) -> Vec { + decode_with_anomaly(storage, length).0 +} + +fn decode_with_anomaly( + storage: &[u64], + length: u32, +) -> (Vec, Option) { // SAFETY: `storage` holds `length` initialized bytes of consecutive records. unsafe { decode(storage.as_ptr().cast::(), length) } } @@ -172,7 +179,11 @@ fn enumerating_the_running_system_agrees_with_itself() { // The only test that touches the real API. It cannot assert a machine's // shape, so it asserts internal consistency instead: ids are unique, and // every record names a group and processor number that could exist. - let records = enumerate().expect("enumerating cpu sets on a live system"); + let (records, anomaly) = enumerate().expect("enumerating cpu sets on a live system"); + assert_eq!( + anomaly, None, + "a healthy machine reports no malformed record" + ); assert!( !records.is_empty(), "a running Windows system reports at least one cpu set" @@ -213,7 +224,7 @@ fn windows_llc_grouping_is_not_the_derived_partitioning_cache() { // does not fail on a machine with a different shape: wherever both are // known, Windows's LLC grouping is never finer than the derived one, since // the last level is at or outside whatever level first divides the machine. - let records = enumerate().expect("cpu sets"); + let (records, _) = enumerate().expect("cpu sets"); let topo = crate::MachineMemoryTopology::discover().expect("discover"); let mut llc: Vec = records.iter().map(|r| r.last_level_cache_index).collect(); @@ -241,7 +252,7 @@ fn windows_llc_grouping_is_not_the_derived_partitioning_cache() { /// every bit reads zero. #[test] fn the_availability_flags_are_all_clear_on_this_host() { - let Ok(sets) = super::enumerate() else { + let Ok((sets, _)) = super::enumerate() else { return; }; if sets.is_empty() { @@ -300,4 +311,4 @@ fn a_record_overrunning_the_buffer_stops_the_walk() { unsafe { base.cast::().write_unaligned(full + 1) }; assert!(decode_all(&storage, full).is_empty()); -} \ No newline at end of file +} diff --git a/crates/windows-topology-sys/src/granularity/tests.rs b/crates/windows-topology-sys/src/granularity/tests.rs index f4450fee..80774a30 100644 --- a/crates/windows-topology-sys/src/granularity/tests.rs +++ b/crates/windows-topology-sys/src/granularity/tests.rs @@ -69,6 +69,7 @@ fn topology(processor_count: u8, domains: Vec) -> MachineMemoryTopology domains, cpu_sets: None, provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), } } diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 321bae0a..1499280f 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -61,6 +61,8 @@ #![warn(missing_docs)] #[cfg(windows)] +mod anomaly; + mod cpu_set; #[cfg(windows)] mod domain; @@ -74,6 +76,8 @@ mod observed; mod processor_set; /// Where a topology's content came from. mod provenance; + +mod records; #[cfg(windows)] mod relation; #[cfg(windows)] @@ -82,6 +86,7 @@ mod topology; mod walk; #[cfg(windows)] +pub use anomaly::{AnomalyKind, EnumerationAnomaly}; pub use cpu_set::CpuSet; #[cfg(windows)] pub use domain::{AttributeValue, Domain, DomainKind, Processor, ProcessorFacts, ProcessorId}; diff --git a/crates/windows-topology-sys/src/records.rs b/crates/windows-topology-sys/src/records.rs new file mode 100644 index 00000000..9d960681 --- /dev/null +++ b/crates/windows-topology-sys/src/records.rs @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Mike Grier +//! Walking a `Size`-chained record list. +//! +//! Both of this crate's Win32 enumerations return the same shape: a buffer of +//! consecutive, variable-length records, each declaring its own byte length in +//! a `Size` field at a fixed offset. `GetLogicalProcessorInformationEx` and +//! `GetSystemCpuSetInformation` differ in where that field sits and in what +//! the rest of the record holds, and in nothing else about the traversal. +//! +//! So the traversal is written once, here. Per +//! [D-24](../DESIGN-NOTES.md#d-24) this is **not** a trust boundary and not a +//! validation pass: the operating system is trusted for the structural +//! validity of a buffer it just wrote. Bounds appear because they are how a +//! walk knows where a record ends -- they are load-bearing for decoding, not a +//! guard against a hostile kernel. +//! +//! Two properties follow from that, and they are the reason this module +//! exists rather than each decoder open-coding the loop: +//! +//! - **A record view cannot read past itself.** [`Record::read`] is bounded by +//! the record's own `Size`, so a trailing array sized by a count *from* the +//! record cannot reach beyond it however large that count claims to be. +//! - **Nothing panics, and nothing is silently dropped.** A record that does +//! not fit ends the walk and is reported through [`RecordWalk::anomaly`], +//! for the caller to record alongside the data it did decode. + +use crate::EnumerationAnomaly; +use crate::observation::Source; + +/// One record, bounded by the `Size` it declared. +/// +/// Reads through this type cannot leave the record, which is what keeps a +/// trailing array honest: its length comes from inside the record, and the +/// bytes it would span are checked against the record's own extent. +#[derive(Clone, Copy)] +pub(crate) struct Record { + base: *const u8, + size: usize, + offset: usize, +} + +impl Record { + /// This record's byte offset within the buffer, for reporting where an + /// anomaly was found. + pub(crate) fn offset(self) -> usize { + self.offset + } + + /// Read a `T` at `offset` within this record. + /// + /// Returns `None` when the read would leave the record, which is the + /// bound that makes a count-driven trailing array safe to follow. + /// + /// # Safety + /// + /// The record must address `self.size` initialized bytes, which + /// [`RecordWalk`] establishes before yielding it. + pub(crate) unsafe fn read(self, offset: usize) -> Option { + let end = offset.checked_add(size_of::())?; + if end > self.size { + return None; + } + // SAFETY: the bound above proves `[offset, offset + size_of::())` + // lies within the record, whose bytes the caller guaranteed are + // initialized. `read_unaligned` because a record's fields are laid out + // by the API, not by Rust. + Some(unsafe { self.base.add(offset).cast::().read_unaligned() }) + } + + /// Read up to `count` consecutive `T` starting at `offset`, stopping at + /// the record's end. + /// + /// The second element of the pair is `false` when the record was too short + /// to hold all `count` entries -- the caller decides whether a short read + /// is worth recording, since for some records a count of zero legitimately + /// means one legacy entry. + /// + /// # Safety + /// + /// As [`Record::read`]. + pub(crate) unsafe fn read_array(self, offset: usize, count: usize) -> (Vec, bool) { + let mut out = Vec::with_capacity(count.min(self.size / size_of::().max(1))); + for index in 0..count { + let Some(at) = index + .checked_mul(size_of::()) + .and_then(|o| o.checked_add(offset)) + else { + return (out, false); + }; + // SAFETY: forwarded from the caller; the read is bounded by the + // record and yields `None` rather than reaching past it. + match unsafe { self.read::(at) } { + Some(value) => out.push(value), + None => return (out, false), + } + } + (out, true) + } +} + +/// An iterator over a `Size`-chained record list. +/// +/// Yields each record that fits, then stops. When it stopped because a record +/// did not fit, [`RecordWalk::anomaly`] says so; when it stopped because the +/// buffer ran out cleanly, that is `None`. +pub(crate) struct RecordWalk { + base: *const u8, + length: usize, + offset: usize, + size_offset: usize, + minimum: usize, + source: Source, + anomaly: Option, +} + +impl RecordWalk { + /// # Safety + /// + /// `base` must address `length` initialized bytes. + pub(crate) unsafe fn new( + base: *const u8, + length: u32, + size_offset: usize, + minimum: usize, + source: Source, + ) -> Self { + Self { + base, + length: length as usize, + offset: 0, + size_offset, + minimum, + source, + anomaly: None, + } + } + + /// The record that ended the walk early, if one did. + pub(crate) fn anomaly(&self) -> Option { + self.anomaly.clone() + } +} + +impl Iterator for RecordWalk { + type Item = Record; + + fn next(&mut self) -> Option { + if self.anomaly.is_some() { + return None; + } + // A record must at least carry its own `Size` field to be walkable at + // all. Running out here is the ordinary end of the buffer, not an + // anomaly, when nothing is left over. + let header_end = self.offset + self.size_offset + size_of::(); + if header_end > self.length { + if self.offset < self.length { + self.anomaly = Some(EnumerationAnomaly::trailing_bytes( + self.source, + self.offset, + self.length - self.offset, + )); + } + return None; + } + + // SAFETY: the bound above proves the `Size` field is within the buffer + // the caller guaranteed is initialized. + let size = unsafe { + self.base + .add(self.offset + self.size_offset) + .cast::() + .read_unaligned() + } as usize; + + if size < self.minimum { + self.anomaly = Some(EnumerationAnomaly::undersized( + self.source, + self.offset, + size, + self.minimum, + )); + return None; + } + let Some(end) = self.offset.checked_add(size) else { + self.anomaly = Some(EnumerationAnomaly::overruns( + self.source, + self.offset, + size, + self.length - self.offset, + )); + return None; + }; + if end > self.length { + self.anomaly = Some(EnumerationAnomaly::overruns( + self.source, + self.offset, + size, + self.length - self.offset, + )); + return None; + } + + // SAFETY: `[offset, offset + size)` is within the buffer. + let record = Record { + base: unsafe { self.base.add(self.offset) }, + size, + offset: self.offset, + }; + self.offset = end; + Some(record) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-topology-sys/src/records/tests.rs b/crates/windows-topology-sys/src/records/tests.rs new file mode 100644 index 00000000..9d02c0cf --- /dev/null +++ b/crates/windows-topology-sys/src/records/tests.rs @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Mike Grier +use super::*; + +/// A record chain with `Size` at offset 0, like `SYSTEM_CPU_SET_INFORMATION`. +const SIZE_AT_0: usize = 0; +const MIN: usize = 8; + +/// Build a buffer of records, each `size` bytes, `Size` written at offset 0. +fn chain(sizes: &[u32]) -> Vec { + let total: u32 = sizes.iter().sum(); + let mut storage = vec![0_u64; (total as usize).div_ceil(8).max(1)]; + let base = storage.as_mut_ptr().cast::(); + let mut offset = 0usize; + for &size in sizes { + // SAFETY: `storage` was sized to hold every record end to end. + unsafe { base.add(offset).cast::().write_unaligned(size) }; + offset += size as usize; + } + storage +} + +fn walk(storage: &[u64], length: u32) -> RecordWalk { + // SAFETY: `storage` holds `length` initialized bytes. + unsafe { + RecordWalk::new( + storage.as_ptr().cast(), + length, + SIZE_AT_0, + MIN, + Source::CpuSets, + ) + } +} + +#[test] +fn an_empty_buffer_yields_nothing_and_no_anomaly() { + let storage = chain(&[]); + let mut w = walk(&storage, 0); + assert!(w.next().is_none()); + assert_eq!(w.anomaly(), None); +} + +#[test] +fn well_formed_records_are_walked_in_order_with_no_anomaly() { + let storage = chain(&[8, 16, 8]); + let mut w = walk(&storage, 32); + let sizes: Vec<_> = (&mut w).map(|r| r.size).collect(); + assert_eq!(sizes, vec![8, 16, 8]); + assert_eq!(w.anomaly(), None); +} + +#[test] +fn a_zero_size_record_is_reported_rather_than_looping_or_panicking() { + // The case that used to `assert!` in `walk.rs`. + let storage = chain(&[8]); + let base = storage.as_ptr().cast::().cast_mut(); + // SAFETY: writing the first record's `Size` field, inside the buffer. + unsafe { base.cast::().write_unaligned(0) }; + let mut w = walk(&storage, 8); + + assert!(w.next().is_none(), "a zero-size record yields nothing"); + assert_eq!( + w.anomaly(), + Some(EnumerationAnomaly::undersized(Source::CpuSets, 0, 0, MIN)), + ); +} + +#[test] +fn a_record_overrunning_the_buffer_is_reported_and_earlier_ones_survive() { + let storage = chain(&[8, 8]); + let base = storage.as_ptr().cast::().cast_mut(); + // Second record claims more than the buffer holds. + // SAFETY: offset 8 is the second record's `Size`, inside the buffer. + unsafe { base.add(8).cast::().write_unaligned(4096) }; + let mut w = walk(&storage, 16); + + assert_eq!((&mut w).count(), 1, "the first record still decodes"); + assert_eq!( + w.anomaly(), + Some(EnumerationAnomaly::overruns(Source::CpuSets, 8, 4096, 8)), + ); +} + +#[test] +fn leftover_bytes_too_short_for_a_header_are_reported() { + let storage = chain(&[8]); + let mut w = walk(&storage, 10); + assert_eq!((&mut w).count(), 1); + assert_eq!( + w.anomaly(), + Some(EnumerationAnomaly::trailing_bytes(Source::CpuSets, 8, 2)), + ); +} + +#[test] +fn a_read_that_would_leave_the_record_yields_none() { + let storage = chain(&[8]); + let mut w = walk(&storage, 8); + let record = w.next().expect("one record"); + + // SAFETY: the record addresses its own 8 initialized bytes. + unsafe { + assert!(record.read::(0).is_some(), "inside"); + assert!(record.read::(4).is_some(), "flush against the end"); + assert!(record.read::(5).is_none(), "one byte over"); + assert!(record.read::(4).is_none(), "spans the end"); + assert!(record.read::(usize::MAX).is_none(), "cannot overflow"); + } +} + +#[test] +fn a_trailing_array_cannot_be_read_past_its_own_record() { + // The `GroupCount` amplification, in miniature: the count claims far more + // entries than the record can hold, and the walk hands back only what fit. + let storage = chain(&[16]); + let mut w = walk(&storage, 16); + let record = w.next().expect("one record"); + + // SAFETY: the record addresses its own 16 initialized bytes. + let (entries, complete) = unsafe { record.read_array::(8, usize::from(u16::MAX)) }; + + assert_eq!(entries.len(), 2, "only the two that fit after offset 8"); + assert!(!complete, "and the walk says the count claimed more"); +} diff --git a/crates/windows-topology-sys/src/relation.rs b/crates/windows-topology-sys/src/relation.rs index 16f58346..d1eb5008 100644 --- a/crates/windows-topology-sys/src/relation.rs +++ b/crates/windows-topology-sys/src/relation.rs @@ -135,6 +135,9 @@ pub struct Relations { pub numa_nodes: Vec, /// Every processor group. pub groups: Vec, + /// Records the walk could not decode. Empty on a healthy machine; see + /// [`crate::MachineMemoryTopology::enumeration_anomalies`]. + pub anomalies: Vec, } fn core_from(body: walk::ProcessorBody) -> CoreRelation { @@ -194,7 +197,8 @@ fn groups_from(body: walk::GroupBody) -> Vec { /// call. pub fn discover() -> io::Result { let mut relations = Relations::default(); - for record in walk::enumerate()? { + let (walk_records, walk_anomalies) = walk::enumerate()?; + for record in walk_records { match record { Record::ProcessorCore(body) => relations.cores.push(core_from(body)), Record::ProcessorPackage(body) => relations.packages.push(package_from(body)), @@ -206,6 +210,7 @@ pub fn discover() -> io::Result { Record::Unknown(_) => {} } } + relations.anomalies = walk_anomalies; Ok(relations) } diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 176c7bdb..dc1bfe19 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -4,6 +4,7 @@ use std::collections::BTreeMap; use std::io; +use crate::EnumerationAnomaly; use crate::cpu_set::CpuSet; use crate::domain::{Domain, DomainKind, Processor, ProcessorFacts, ProcessorId}; use crate::observation::{AttributeObservation, Observation, ProcessorAttribute, Source}; @@ -86,6 +87,23 @@ pub struct MachineMemoryTopology { ) )] pub provenance: Provenance, + /// Records the enumeration could not decode, if any. + /// + /// **Empty on every healthy machine**, and empty for a hand-built or + /// deserialized topology, which asked nothing. A non-empty list means a + /// buffer Windows returned did not describe what it claimed to -- a + /// defective hypervisor, a driver corrupting memory -- and it carries the + /// byte offset and the reason so that is diagnosable from the data rather + /// than from a debugger. + /// + /// Per [D-24](../DESIGN-NOTES.md#d-24) this is *recorded* rather than + /// thrown: a malformed record is not evidence that this crate reached an + /// inconsistent state, so it must not panic, and dropping it silently would + /// leave a consumer unable to tell a truncated enumeration from a small + /// machine. Whatever decoded before the anomaly is still present in the + /// fields above and is still correct. + #[cfg_attr(feature = "serde", serde(default))] + pub enumeration_anomalies: Vec, } impl MachineMemoryTopology { @@ -104,7 +122,8 @@ impl MachineMemoryTopology { // The walk's per-processor claims, recorded before the fold so both // sources' claims about one processor sit side by side (D-18). topology.record_walk_attributes(); - let cpu_sets = crate::cpu_set::enumerate()?; + let (cpu_sets, cpu_set_anomaly) = crate::cpu_set::enumerate()?; + topology.enumeration_anomalies.extend(cpu_set_anomaly); // Folded into the relation set, and *also* kept verbatim. Not a // contradiction: D-19's unified view is presented **in addition to** // the individual per-source ones, so a caller wanting what CPU Sets @@ -383,6 +402,10 @@ impl MachineMemoryTopology { // machine -- so if this ever gains a second caller, that caller // does not silently inherit an assertion it has not earned. provenance: Provenance::Synthetic, + // Carried, not re-derived: whatever the walk could not decode is a + // fact about the enumeration that produced these relations, and a + // pure transform is the wrong place to lose it. + enumeration_anomalies: relations.anomalies, } } diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 3b30670a..d4196385 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -78,6 +78,7 @@ fn synthetic() -> MachineMemoryTopology { // Named rather than defaulted, so this fixture states what it is. The // helper is called `synthetic` and now says so in the value too. provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), } } @@ -380,6 +381,7 @@ mod serde_tests { domains: vec![core_domain(0, &[0, 1, 2, 3], 0)], cpu_sets: None, provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; topology.fold_in_cpu_sets(&[ @@ -425,6 +427,7 @@ mod serde_tests { domains: Vec::new(), cpu_sets: None, provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; topology.fold_in_cpu_sets(&[cpu_set(0, 0, 0, 2), cpu_set(1, 0, 0, 2)]); @@ -458,6 +461,7 @@ mod serde_tests { domains: vec![core_domain(7, &[0, 1], 0)], cpu_sets: None, provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; topology.fold_in_cpu_sets(&[cpu_set(0, 3, 0, 0), cpu_set(1, 3, 0, 0)]); @@ -491,6 +495,7 @@ mod serde_tests { domains: vec![memory], cpu_sets: None, provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; topology.fold_in_cpu_sets(&[cpu_set(0, 5, 0, 0), cpu_set(1, 5, 0, 0)]); @@ -615,6 +620,7 @@ mod serde_tests { cpu_sets: None, processor_attributes: Vec::new(), provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), }; topology.record_walk_attributes(); // CPU Sets disagrees about processor 0 and agrees about processor 1. @@ -660,6 +666,7 @@ mod serde_tests { cpu_sets: None, processor_attributes: Vec::new(), provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), }; topology.record_walk_attributes(); topology.fold_in_cpu_sets(&[ @@ -689,6 +696,7 @@ mod serde_tests { cpu_sets: None, processor_attributes: Vec::new(), provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), }; assert!(topology.processor_attributes.is_empty()); assert!(topology.attribute_conflicts().is_empty()); @@ -945,6 +953,7 @@ fn heterogeneous_relations() -> (crate::relation::Relations, Vec) { active_processor_count: 2, active_processors: ProcessorSet::from_group_mask(0, 0b11), }], + anomalies: Vec::new(), }; let domains = vec![ @@ -1030,6 +1039,7 @@ fn an_offline_processor_reports_no_capacity_even_when_a_core_claims_it() { // Only processor 0 is online. active_processors: ProcessorSet::from_group_mask(0, 0b01), }], + anomalies: Vec::new(), }; let domains = vec![Domain { kind: DomainKind::Core { @@ -1100,6 +1110,7 @@ fn split_l1_machine(cores: u32, last_level: u8) -> MachineMemoryTopology { domains, cpu_sets: None, provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), } } @@ -1117,6 +1128,7 @@ fn cache_levels_are_empty_when_no_cache_is_reported() { domains: Vec::new(), cpu_sets: None, provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; assert!(topo.cache_levels().is_empty()); @@ -1353,6 +1365,7 @@ fn machine_of(count: u8, domains: Vec) -> MachineMemoryTopology { cpu_sets: None, processor_attributes: Vec::new(), provenance: Provenance::Synthetic, + enumeration_anomalies: Vec::new(), } } diff --git a/crates/windows-topology-sys/src/walk.rs b/crates/windows-topology-sys/src/walk.rs index c98ae159..70d33667 100644 --- a/crates/windows-topology-sys/src/walk.rs +++ b/crates/windows-topology-sys/src/walk.rs @@ -19,7 +19,27 @@ //! //! Everything `unsafe` in this crate is here. Every function this module //! exposes to the rest of the crate is safe. +//! +//! ## How the records are walked +//! +//! Through [`crate::records`], which both of this crate's enumerations share. +//! Per [D-24](../DESIGN-NOTES.md#d-24) the operating system is **trusted** for +//! the structural validity of a buffer it just wrote -- this is not a trust +//! boundary and there is no validation pass. The walk bounds its reads because +//! bounds are how it knows where a record ends, which is a decoding +//! requirement rather than a defence. +//! +//! Two consequences a reader should not have to infer: nothing here **panics** +//! over the shape of the buffer, because a malformed record is not evidence +//! that this crate reached an inconsistent state; and a record that cannot be +//! decoded is **recorded** in +//! [`MachineMemoryTopology::enumeration_anomalies`](crate::MachineMemoryTopology::enumeration_anomalies) +//! rather than silently dropped, so a short list is distinguishable from a +//! small machine. +use crate::EnumerationAnomaly; +use crate::observation::Source; +use crate::records::{Record as RawRecord, RecordWalk}; use std::io; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; @@ -105,7 +125,7 @@ pub(crate) enum Record { /// # Errors /// /// Returns any error from `GetLogicalProcessorInformationEx`. -pub(crate) fn enumerate() -> io::Result> { +pub(crate) fn enumerate() -> io::Result<(Vec, Vec)> { let mut length: u32 = 0; // SAFETY: a null buffer and a valid `length` out-pointer. Documented to // fail with `ERROR_INSUFFICIENT_BUFFER` and report the required size in @@ -116,7 +136,7 @@ pub(crate) fn enumerate() -> io::Result> { if probe != 0 { // Documented to fail on the sizing call; succeeding would mean zero // bytes were needed, i.e. nothing to report. - return Ok(Vec::new()); + return Ok((Vec::new(), Vec::new())); } let error = io::Error::last_os_error(); if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { @@ -152,16 +172,6 @@ const SIZE_OFFSET: usize = core::mem::offset_of!(SYSTEM_LOGICAL_PROCESSOR_INFORM const UNION_OFFSET: usize = core::mem::offset_of!(SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX, Anonymous); -/// Read a `T` from `base + offset`, without assuming alignment. -/// -/// # Safety -/// -/// `base + offset` must address at least `size_of::()` initialized bytes. -unsafe fn read_at(base: *const u8, offset: usize) -> T { - // SAFETY: forwarded from the caller. - unsafe { base.add(offset).cast::().read_unaligned() } -} - /// Read `count` consecutive `GROUP_AFFINITY` entries starting at `base`, /// trusting `count` rather than any type-declared array length (see the /// module's own documentation). @@ -170,18 +180,24 @@ unsafe fn read_at(base: *const u8, offset: usize) -> T { /// /// `base` must address at least `count` consecutive, initialized /// `GROUP_AFFINITY` values. -unsafe fn read_group_affinities(base: *const u8, count: u16) -> Vec { - (0..u32::from(count)) - .map(|i| { - let offset = i as usize * size_of::(); - // SAFETY: forwarded from the caller; `i < count`. - let raw: GROUP_AFFINITY = unsafe { read_at(base, offset) }; - GroupAffinity { - group: raw.Group, - mask: raw.Mask, - } +unsafe fn read_group_affinities( + record: RawRecord, + at: usize, + count: u16, +) -> (Vec, bool) { + // SAFETY: forwarded from the caller. The read is bounded by the record's + // own `Size`, so a `count` larger than the record can hold yields only the + // entries that fit -- which is what closes the amplification this `u16` + // used to have over a 16-byte stride (D-24). + let (raw, complete) = unsafe { record.read_array::(at, usize::from(count)) }; + let affinities = raw + .into_iter() + .map(|entry| GroupAffinity { + group: entry.Group, + mask: entry.Mask, }) - .collect() + .collect(); + (affinities, complete) } /// As [`read_group_affinities`], for `CACHE_RELATIONSHIP`/ @@ -198,10 +214,14 @@ unsafe fn read_group_affinities(base: *const u8, count: u16) -> Vec Vec { +unsafe fn read_legacy_group_affinities( + record: RawRecord, + at: usize, + group_count: u16, +) -> (Vec, bool) { let count = group_count.max(1); // SAFETY: forwarded from the caller. - unsafe { read_group_affinities(base, count) } + unsafe { read_group_affinities(record, at, count) } } /// # Safety @@ -210,105 +230,159 @@ unsafe fn read_legacy_group_affinities(base: *const u8, group_count: u16) -> Vec /// `GetLogicalProcessorInformationEx`: zero or more consecutive /// `SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX` records whose `Size` fields sum /// to `length`. -unsafe fn decode(buffer: *const u8, length: u32) -> Vec { +unsafe fn decode(buffer: *const u8, length: u32) -> (Vec, Vec) { + // The minimum is the fixed header -- `Relationship` and `Size` -- and + // deliberately **not** `size_of::()`. + // That struct is 80 bytes because its union is as large as its largest arm + // (`GROUP_RELATIONSHIP`, 72), while a real processor-core record is 8 + 40 = + // 48. Using the struct size would reject every processor, cache and NUMA + // record on every machine. Measured, not assumed. + // + // Each body then bounds its own reads against the record's own `Size`, so + // a record too short for the body it claims yields no body rather than + // reading into its neighbour. + // SAFETY: forwarded from this function's own contract. + let mut walk = unsafe { + RecordWalk::new( + buffer, + length, + SIZE_OFFSET, + UNION_OFFSET, + Source::RelationshipWalk, + ) + }; + let mut records = Vec::new(); - let mut offset: usize = 0; - let length = length as usize; - while offset < length { - // SAFETY: `offset < length`, and the caller's contract guarantees a - // full record header lives at this offset. - let record_base = unsafe { buffer.add(offset) }; - // SAFETY: `record_base` addresses a full record header. - let relationship: LOGICAL_PROCESSOR_RELATIONSHIP = - unsafe { read_at(record_base, RELATIONSHIP_OFFSET) }; - // SAFETY: as above. - let size: u32 = unsafe { read_at(record_base, SIZE_OFFSET) }; - assert!( - size > 0, - "GetLogicalProcessorInformationEx reported a zero-size record" - ); - // SAFETY: the union starts within this record, which the caller's - // contract guarantees is `size` bytes of initialized data. - let union_base = unsafe { record_base.add(UNION_OFFSET) }; - // SAFETY: `union_base` addresses the union body of a record whose - // `Relationship` field is `relationship`, and whose `Size` accounts - // for whatever trailing array that relationship's body declares. - records.push(unsafe { decode_body(relationship, union_base) }); - offset += size as usize; + let mut anomalies = Vec::new(); + for raw in &mut walk { + // SAFETY: the walk proved the header is within the record. + let Some(relationship) = + (unsafe { raw.read::(RELATIONSHIP_OFFSET) }) + else { + continue; + }; + // SAFETY: `raw` addresses `raw.size()` initialized bytes. + let (record, truncated) = unsafe { decode_body(relationship, raw) }; + if let Some(anomaly) = truncated { + anomalies.push(anomaly); + } + if let Some(record) = record { + records.push(record); + } } - records + anomalies.extend(walk.anomaly()); + (records, anomalies) } - +/// Decode the body a record's `Relationship` field claims it holds. +/// +/// Returns the record, plus an anomaly when a trailing array declared more +/// entries than the record could hold. A body that does not fit at all yields +/// `None` rather than a partial record read from a neighbour's bytes. +/// /// # Safety /// -/// `union_base` must address the union body of a -/// `SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX` record whose `Relationship` -/// field is `relationship`, with enough trailing bytes for whatever variable -/// array that relationship's body declares. +/// `record` must address the bytes of its own declared `Size`, which +/// [`RecordWalk`] establishes before yielding it. unsafe fn decode_body( relationship: LOGICAL_PROCESSOR_RELATIONSHIP, - union_base: *const u8, -) -> Record { + record: RawRecord, +) -> (Option, Option) { // windows-sys names these relationship constants in mixed case, not // SCREAMING_CASE; that is not this crate's naming to change. #[allow(non_upper_case_globals)] match relationship { - // SAFETY: forwarded from the caller. - RelationProcessorCore => Record::ProcessorCore(unsafe { read_processor_body(union_base) }), - RelationProcessorPackage => { + RelationProcessorCore + | RelationProcessorPackage + | RelationProcessorDie + | RelationProcessorModule => { // SAFETY: forwarded from the caller. - Record::ProcessorPackage(unsafe { read_processor_body(union_base) }) + let (body, anomaly) = unsafe { read_processor_body(record) }; + let wrap = match relationship { + RelationProcessorCore => Record::ProcessorCore, + RelationProcessorPackage => Record::ProcessorPackage, + RelationProcessorDie => Record::ProcessorDie, + _ => Record::ProcessorModule, + }; + (body.map(wrap), anomaly) } - RelationProcessorDie => Record::ProcessorDie(unsafe { read_processor_body(union_base) }), - RelationProcessorModule => { + RelationCache => { // SAFETY: forwarded from the caller. - Record::ProcessorModule(unsafe { read_processor_body(union_base) }) + let (body, anomaly) = unsafe { read_cache_body(record) }; + (body.map(Record::Cache), anomaly) } - // SAFETY: forwarded from the caller. - RelationCache => Record::Cache(unsafe { read_cache_body(union_base) }), - // SAFETY: forwarded from the caller. RelationNumaNode | RelationNumaNodeEx => { - Record::NumaNode(unsafe { read_numa_body(union_base) }) + // SAFETY: forwarded from the caller. + let (body, anomaly) = unsafe { read_numa_body(record) }; + (body.map(Record::NumaNode), anomaly) } - // SAFETY: forwarded from the caller. - RelationGroup => Record::Group(unsafe { read_group_body(union_base) }), - other => Record::Unknown(other), + RelationGroup => { + // SAFETY: forwarded from the caller. + let (body, anomaly) = unsafe { read_group_body(record) }; + (body.map(Record::Group), anomaly) + } + other => (Some(Record::Unknown(other)), None), } } +/// Offset of `field` within a relationship body, from the start of the record. +macro_rules! body { + ($ty:ty, $field:ident) => { + UNION_OFFSET + core::mem::offset_of!($ty, $field) + }; +} + /// # Safety /// -/// `base` must address a `PROCESSOR_RELATIONSHIP` whose trailing `GroupMask` -/// array has at least `GroupCount` initialized entries. -unsafe fn read_processor_body(base: *const u8) -> ProcessorBody { - // SAFETY: forwarded from the caller. - let flags: u8 = unsafe { read_at(base, core::mem::offset_of!(PROCESSOR_RELATIONSHIP, Flags)) }; - // SAFETY: forwarded from the caller. - let efficiency_class: u8 = unsafe { - read_at( - base, - core::mem::offset_of!(PROCESSOR_RELATIONSHIP, EfficiencyClass), - ) +/// `record` must address the bytes of its own declared `Size`. +unsafe fn read_processor_body( + record: RawRecord, +) -> (Option, Option) { + // SAFETY: forwarded from the caller; every read is bounded by the record. + let Some((flags, efficiency_class, group_count)) = (unsafe { + (|| { + Some(( + record.read::(body!(PROCESSOR_RELATIONSHIP, Flags))?, + record.read::(body!(PROCESSOR_RELATIONSHIP, EfficiencyClass))?, + record.read::(body!(PROCESSOR_RELATIONSHIP, GroupCount))?, + )) + })() + }) else { + return (None, None); }; // SAFETY: forwarded from the caller. - let group_count: u16 = unsafe { - read_at( - base, - core::mem::offset_of!(PROCESSOR_RELATIONSHIP, GroupCount), - ) - }; - // SAFETY: forwarded from the caller; `group_count` names the true length. - let group_masks = unsafe { + let (group_masks, complete) = unsafe { read_group_affinities( - base.add(core::mem::offset_of!(PROCESSOR_RELATIONSHIP, GroupMask)), + record, + body!(PROCESSOR_RELATIONSHIP, GroupMask), group_count, ) }; - ProcessorBody { - flags, - efficiency_class, - group_masks, - } + let anomaly = truncation(record, complete, group_count, group_masks.len()); + ( + Some(ProcessorBody { + flags, + efficiency_class, + group_masks, + }), + anomaly, + ) +} + +/// The anomaly for a trailing array that claimed more than the record held. +fn truncation( + record: RawRecord, + complete: bool, + declared: u16, + decoded: usize, +) -> Option { + (!complete).then(|| { + EnumerationAnomaly::truncated_array( + Source::RelationshipWalk, + record.offset(), + usize::from(declared), + decoded, + ) + }) } /// # Safety @@ -318,42 +392,38 @@ unsafe fn read_processor_body(base: *const u8) -> ProcessorBody { /// initialized entries -- pre-Windows-20H2 records report `GroupCount == 0` /// but still have exactly one legacy `GroupMask` entry there (see /// [`read_legacy_group_affinities`]). -unsafe fn read_cache_body(base: *const u8) -> CacheBody { - // SAFETY: forwarded from the caller. - let level: u8 = unsafe { read_at(base, core::mem::offset_of!(CACHE_RELATIONSHIP, Level)) }; - // SAFETY: forwarded from the caller. - let associativity: u8 = unsafe { - read_at( - base, - core::mem::offset_of!(CACHE_RELATIONSHIP, Associativity), - ) +unsafe fn read_cache_body(record: RawRecord) -> (Option, Option) { + // SAFETY: forwarded from the caller; every read is bounded by the record. + let Some((level, associativity, line_size, cache_size, cache_type, group_count)) = (unsafe { + (|| { + Some(( + record.read::(body!(CACHE_RELATIONSHIP, Level))?, + record.read::(body!(CACHE_RELATIONSHIP, Associativity))?, + record.read::(body!(CACHE_RELATIONSHIP, LineSize))?, + record.read::(body!(CACHE_RELATIONSHIP, CacheSize))?, + record.read::(body!(CACHE_RELATIONSHIP, Type))?, + record.read::(body!(CACHE_RELATIONSHIP, GroupCount))?, + )) + })() + }) else { + return (None, None); }; // SAFETY: forwarded from the caller. - let line_size: u16 = - unsafe { read_at(base, core::mem::offset_of!(CACHE_RELATIONSHIP, LineSize)) }; - // SAFETY: forwarded from the caller. - let cache_size: u32 = - unsafe { read_at(base, core::mem::offset_of!(CACHE_RELATIONSHIP, CacheSize)) }; - // SAFETY: forwarded from the caller. - let cache_type: i32 = unsafe { read_at(base, core::mem::offset_of!(CACHE_RELATIONSHIP, Type)) }; - // SAFETY: forwarded from the caller. - let group_count: u16 = - unsafe { read_at(base, core::mem::offset_of!(CACHE_RELATIONSHIP, GroupCount)) }; - // SAFETY: forwarded from the caller; `group_count` names the true length. - let group_masks = unsafe { - read_legacy_group_affinities( - base.add(core::mem::offset_of!(CACHE_RELATIONSHIP, Anonymous)), - group_count, - ) + let (group_masks, complete) = unsafe { + read_legacy_group_affinities(record, body!(CACHE_RELATIONSHIP, Anonymous), group_count) }; - CacheBody { - level, - associativity, - line_size, - cache_size, - cache_type, - group_masks, - } + let anomaly = truncation(record, complete, group_count.max(1), group_masks.len()); + ( + Some(CacheBody { + level, + associativity, + line_size, + cache_size, + cache_type, + group_masks, + }), + anomaly, + ) } /// # Safety @@ -363,61 +433,64 @@ unsafe fn read_cache_body(base: *const u8) -> CacheBody { /// initialized entries -- pre-Windows-20H2 records report `GroupCount == 0` /// but still have exactly one legacy `GroupMask` entry there (see /// [`read_legacy_group_affinities`]). -unsafe fn read_numa_body(base: *const u8) -> NumaNodeBody { - // SAFETY: forwarded from the caller. - let node_number: u32 = unsafe { - read_at( - base, - core::mem::offset_of!(NUMA_NODE_RELATIONSHIP, NodeNumber), - ) +unsafe fn read_numa_body(record: RawRecord) -> (Option, Option) { + // SAFETY: forwarded from the caller; every read is bounded by the record. + let Some((node_number, group_count)) = (unsafe { + (|| { + Some(( + record.read::(body!(NUMA_NODE_RELATIONSHIP, NodeNumber))?, + record.read::(body!(NUMA_NODE_RELATIONSHIP, GroupCount))?, + )) + })() + }) else { + return (None, None); }; // SAFETY: forwarded from the caller. - let group_count: u16 = unsafe { - read_at( - base, - core::mem::offset_of!(NUMA_NODE_RELATIONSHIP, GroupCount), - ) - }; - // SAFETY: forwarded from the caller; `group_count` names the true length. - let group_masks = unsafe { + let (group_masks, complete) = unsafe { read_legacy_group_affinities( - base.add(core::mem::offset_of!(NUMA_NODE_RELATIONSHIP, Anonymous)), + record, + body!(NUMA_NODE_RELATIONSHIP, Anonymous), group_count, ) }; - NumaNodeBody { - node_number, - group_masks, - } + let anomaly = truncation(record, complete, group_count.max(1), group_masks.len()); + ( + Some(NumaNodeBody { + node_number, + group_masks, + }), + anomaly, + ) } /// # Safety /// -/// `base` must address a `GROUP_RELATIONSHIP` whose trailing `GroupInfo` -/// array has at least `ActiveGroupCount` initialized entries. -unsafe fn read_group_body(base: *const u8) -> GroupBody { - // SAFETY: forwarded from the caller. - let active_group_count: u16 = unsafe { - read_at( - base, - core::mem::offset_of!(GROUP_RELATIONSHIP, ActiveGroupCount), +/// `record` must address the bytes of its own declared `Size`. +unsafe fn read_group_body(record: RawRecord) -> (Option, Option) { + // SAFETY: forwarded from the caller; the read is bounded by the record. + let Some(active_group_count) = + (unsafe { record.read::(body!(GROUP_RELATIONSHIP, ActiveGroupCount)) }) + else { + return (None, None); + }; + // SAFETY: forwarded from the caller; bounded by the record, so an + // `ActiveGroupCount` larger than the record can hold yields only what fits. + let (raw, complete) = unsafe { + record.read_array::( + body!(GROUP_RELATIONSHIP, GroupInfo), + usize::from(active_group_count), ) }; - // SAFETY: forwarded from the caller. - let info_base = unsafe { base.add(core::mem::offset_of!(GROUP_RELATIONSHIP, GroupInfo)) }; - let group_info = (0..u32::from(active_group_count)) - .map(|i| { - let offset = i as usize * size_of::(); - // SAFETY: forwarded from the caller; `i < active_group_count`. - let raw: PROCESSOR_GROUP_INFO = unsafe { read_at(info_base, offset) }; - GroupInfo { - maximum_processor_count: raw.MaximumProcessorCount, - active_processor_count: raw.ActiveProcessorCount, - active_processor_mask: raw.ActiveProcessorMask, - } + let group_info: Vec<_> = raw + .into_iter() + .map(|entry| GroupInfo { + maximum_processor_count: entry.MaximumProcessorCount, + active_processor_count: entry.ActiveProcessorCount, + active_processor_mask: entry.ActiveProcessorMask, }) .collect(); - GroupBody { group_info } + let anomaly = truncation(record, complete, active_group_count, group_info.len()); + (Some(GroupBody { group_info }), anomaly) } /// What a cache holds, converted from Windows's raw `PROCESSOR_CACHE_TYPE`. diff --git a/crates/windows-topology-sys/src/walk/tests.rs b/crates/windows-topology-sys/src/walk/tests.rs index f8486b54..6ffe5ea8 100644 --- a/crates/windows-topology-sys/src/walk/tests.rs +++ b/crates/windows-topology-sys/src/walk/tests.rs @@ -1,6 +1,19 @@ // Copyright (c) 2026 Mike Grier use super::*; +/// The records a buffer decodes to, discarding anomalies -- most tests are +/// about well-formed input and assert the anomaly list separately. +fn decode_records(base: *const u8, length: u32) -> Vec { + // SAFETY: the caller passes a buffer of `length` initialized bytes. + unsafe { decode(base, length) }.0 +} + +/// What the walk observed about the shape of a buffer. +fn decode_anomalies(base: *const u8, length: u32) -> Vec { + // SAFETY: the caller passes a buffer of `length` initialized bytes. + unsafe { decode(base, length) }.1 +} + /// Write `value` at `offset` in `buf`, ignoring alignment -- exactly what the /// production walk must also tolerate, since real Windows buffers offer no /// alignment guarantee for a mid-buffer record. @@ -195,7 +208,7 @@ fn unknown_record(relationship: i32) -> Vec { fn an_empty_buffer_decodes_to_no_records() { // SAFETY: length 0 requires no initialized bytes, so a null/dangling // pointer is never dereferenced. - let decoded = unsafe { decode(std::ptr::null(), 0) }; + let decoded = decode_records(std::ptr::null(), 0); assert!(decoded.is_empty()); } @@ -204,7 +217,7 @@ fn decodes_a_processor_core_with_a_single_group_mask() { let record = processor_record(RelationProcessorCore, 0x1, 3, &[(0, 0b101)]); // SAFETY: `record` is a well-formed single record whose `Size` equals its // own length. - let decoded = unsafe { decode(record.as_ptr(), record.len() as u32) }; + let decoded = decode_records(record.as_ptr(), record.len() as u32); assert_eq!(decoded.len(), 1); let Record::ProcessorCore(body) = &decoded[0] else { panic!("expected ProcessorCore") @@ -224,7 +237,7 @@ fn reads_past_the_types_declared_array_length_when_group_count_exceeds_one() { // truncating or panicking. let record = processor_record(RelationProcessorCore, 0, 0, &[(0, 0b1), (5, 0b1010)]); // SAFETY: as above. - let decoded = unsafe { decode(record.as_ptr(), record.len() as u32) }; + let decoded = decode_records(record.as_ptr(), record.len() as u32); let Record::ProcessorCore(body) = &decoded[0] else { panic!("expected ProcessorCore") }; @@ -252,7 +265,7 @@ fn the_walk_advances_by_each_records_own_size_across_differing_record_sizes() { buf.extend_from_slice(&numa); // SAFETY: two well-formed, back-to-back records whose `Size` fields sum // to `buf.len()`. - let decoded = unsafe { decode(buf.as_ptr(), buf.len() as u32) }; + let decoded = decode_records(buf.as_ptr(), buf.len() as u32); assert_eq!( decoded.len(), 2, @@ -270,7 +283,7 @@ fn the_walk_advances_by_each_records_own_size_across_differing_record_sizes() { fn decodes_a_cache_relationship_and_maps_its_type() { let record = cache_record(3, 0xFF, 64, 32 * 1024 * 1024, CacheUnified, &[(0, 0xFF)]); // SAFETY: a well-formed single record. - let decoded = unsafe { decode(record.as_ptr(), record.len() as u32) }; + let decoded = decode_records(record.as_ptr(), record.len() as u32); let Record::Cache(body) = &decoded[0] else { panic!("expected Cache") }; @@ -304,7 +317,7 @@ fn a_cache_relationship_reporting_group_count_zero_still_reads_its_legacy_group_ // SAFETY: a well-formed single record; only its GroupCount field was // overwritten to simulate the legacy layout, its one trailing // GROUP_AFFINITY entry is left intact. - let decoded = unsafe { decode(record.as_ptr(), record.len() as u32) }; + let decoded = decode_records(record.as_ptr(), record.len() as u32); let Record::Cache(body) = &decoded[0] else { panic!("expected Cache") }; @@ -324,7 +337,7 @@ fn a_numa_node_relationship_reporting_group_count_zero_still_reads_its_legacy_gr 0_u16, ); // SAFETY: as above, only GroupCount was overwritten. - let decoded = unsafe { decode(record.as_ptr(), record.len() as u32) }; + let decoded = decode_records(record.as_ptr(), record.len() as u32); let Record::NumaNode(body) = &decoded[0] else { panic!("expected NumaNode") }; @@ -340,7 +353,7 @@ fn a_numa_node_relationship_reporting_group_count_zero_still_reads_its_legacy_gr fn decodes_a_group_relationship_with_multiple_groups() { let record = group_record(&[(64, 64, usize::MAX), (32, 16, 0xFFFF)]); // SAFETY: a well-formed single record. - let decoded = unsafe { decode(record.as_ptr(), record.len() as u32) }; + let decoded = decode_records(record.as_ptr(), record.len() as u32); let Record::Group(body) = &decoded[0] else { panic!("expected Group") }; @@ -354,6 +367,107 @@ fn decodes_a_group_relationship_with_multiple_groups() { fn an_unrecognised_relationship_is_carried_rather_than_dropped() { let record = unknown_record(999); // SAFETY: a well-formed single record with no variable-length body. - let decoded = unsafe { decode(record.as_ptr(), record.len() as u32) }; + let decoded = decode_records(record.as_ptr(), record.len() as u32); assert!(matches!(decoded[0], Record::Unknown(999))); } + +// The malformed-input cases this file never had. Per D-24 none of them may +// panic, none may read past the buffer, and each is reported rather than +// silently dropped. + +#[test] +fn a_zero_size_record_is_reported_rather_than_panicking() { + // This is the case that used to hit `assert!(size > 0)` and take the + // caller's process with it. + let mut record = processor_record(RelationProcessorCore, 0, 0, &[(0, 0b1)]); + record[SIZE_OFFSET..SIZE_OFFSET + 4].copy_from_slice(&0_u32.to_le_bytes()); + + let anomalies = decode_anomalies(record.as_ptr(), record.len() as u32); + + assert_eq!( + anomalies, + vec![crate::EnumerationAnomaly::undersized( + Source::RelationshipWalk, + 0, + 0, + UNION_OFFSET + )] + ); +} + +#[test] +fn a_record_overrunning_the_buffer_stops_the_walk_and_keeps_what_decoded() { + let mut first = processor_record(RelationProcessorCore, 0, 0, &[(0, 0b1)]); + let first_len = first.len(); + let mut second = processor_record(RelationProcessorCore, 0, 0, &[(0, 0b10)]); + // The second record claims far more than the buffer holds. + second[SIZE_OFFSET..SIZE_OFFSET + 4].copy_from_slice(&4096_u32.to_le_bytes()); + first.extend_from_slice(&second); + + let (records, anomalies) = ( + decode_records(first.as_ptr(), first.len() as u32), + decode_anomalies(first.as_ptr(), first.len() as u32), + ); + + assert_eq!(records.len(), 1, "the first record still decodes"); + assert_eq!(anomalies.len(), 1); + assert_eq!(anomalies[0].offset, first_len); + assert!(matches!( + anomalies[0].kind, + crate::AnomalyKind::OverrunsBuffer { declared: 4096, .. } + )); +} + +#[test] +fn a_group_count_larger_than_the_record_reads_only_what_fits() { + // The amplification: `GroupCount` is a `u16` multiplying a 16-byte stride, + // so an unbounded read here would reach 1,048,560 bytes past the record. + // The record's own `Size` is the bound, so only the entries inside it are + // read and the overclaim is recorded. + let mut record = processor_record(RelationProcessorCore, 0, 0, &[(0, 0b1)]); + let count_at = UNION_OFFSET + core::mem::offset_of!(PROCESSOR_RELATIONSHIP, GroupCount); + record[count_at..count_at + 2].copy_from_slice(&u16::MAX.to_le_bytes()); + + let (records, anomalies) = ( + decode_records(record.as_ptr(), record.len() as u32), + decode_anomalies(record.as_ptr(), record.len() as u32), + ); + + let Record::ProcessorCore(body) = &records[0] else { + panic!("a processor core record"); + }; + assert_eq!( + body.group_masks.len(), + 1, + "only the one entry the record actually holds" + ); + assert_eq!(anomalies.len(), 1); + assert!(matches!( + anomalies[0].kind, + crate::AnomalyKind::TruncatedArray { + declared: 65535, + decoded: 1 + } + )); +} + +#[test] +fn a_record_too_short_for_its_body_yields_no_record_rather_than_a_neighbours_bytes() { + // Declares a processor relationship but is only long enough for the header. + let mut record = processor_record(RelationProcessorCore, 0, 0, &[(0, 0b1)]); + record[SIZE_OFFSET..SIZE_OFFSET + 4].copy_from_slice(&(UNION_OFFSET as u32).to_le_bytes()); + record.truncate(UNION_OFFSET); + + let records = decode_records(record.as_ptr(), record.len() as u32); + + assert!( + records.is_empty(), + "no body fits, so no record is invented: {records:?}" + ); +} + +#[test] +fn a_healthy_machine_reports_no_anomalies() { + let (_, anomalies) = enumerate().expect("enumerating the running system"); + assert_eq!(anomalies, Vec::new()); +} From e221bd00e70e58fb7dab8f9f40aca7ee5eed7ec8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 21:33:54 -0400 Subject: [PATCH 285/361] test(waitable-queues): repair three stale sabotage patterns (SH-3.3) The pre-publish sabotage sweep came back 36 of 39. The three failures were not survivors -- they were `MANIFEST STALE`, meaning the patterns no longer matched and those sabotages **were not run at all**. Three guards on the crate about to become public were silently unverified. This branch's own work moved the code out from under them: - `slotwise_mpsc frees a slot one short of the next lap` -- broke when positions widened to 64 bits and the expression gained an `as Position` cast. - `slotwise_mpsc accepts a capacity of one` -- broke when `WRAPPING_MAX_CAPACITY` was renamed `MAX_ADMISSIBLE_CAPACITY`. - `reserving_mpsc: reserve does not check for room` -- broke when the no-room path grew its stale-`word` retry, turning a three-line block into twelve and giving `has_room_beyond_reservations` a second call site in `push`. The repaired pattern anchors on the comment unique to `reserve`, so the two sites cannot be confused. Each repair was re-run on its own and confirmed **caught** before the full sweep, so the fix restored the check rather than merely restoring the match. The full sweep is now 39 of 39 as declared: 37 caught, and both `CONTROL` entries survived as they are supposed to. Baseline green, sources restored, exit 0. Completed item: SH-3.3: Run the windows-waitable-queues sabotage sweep on a clean tree and confirm every entry still behaves as declared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 20 ++++++++++++++++++- crates/windows-waitable-queues/sabotage.json | 21 +++++++++++++------- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index b6da62c0..501ff400 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -299,10 +299,28 @@ that previously stood in the way are gone: and the in-scope test suites including doctests. Release-mode warnings differ from debug ones, which is why the milestone discipline names both. -- [ ] **SH-3.3** -- Run the `windows-waitable-queues` sabotage sweep on a clean tree and confirm every +- [x] **SH-3.3** -- Run the `windows-waitable-queues` sabotage sweep on a clean tree and confirm every entry still behaves as declared. It is the crate about to become public and the sweep is what has caught its real defects -- including a lost wakeup that only surfaced because a *baseline* run hung once in an otherwise green suite. + **Done 2026-09-03: 39 of 39 behave as declared** -- 37 caught, and the two `CONTROL` entries + survived as they are supposed to, which is the manifest checking itself. Baseline green, sources + restored, exit 0. + **But the first run was 36 of 39, and the three failures were the interesting part.** They were not + survivors: they came back `MANIFEST STALE`, meaning the patterns no longer matched and those + sabotages **were not run at all**. Three guards on the crate about to be published were silently + unverified, and a green sweep summary would never have said so -- the tool reports staleness + precisely because a sabotage that does not apply proves nothing. + **This branch''s own work caused the drift**, which is why it had to be caught here rather than + assumed: `slotwise_mpsc frees a slot one short of the next lap` broke when positions widened to 64 + bits and the expression gained an `as Position` cast; `slotwise_mpsc accepts a capacity of one` + broke when `WRAPPING_MAX_CAPACITY` was renamed `MAX_ADMISSIBLE_CAPACITY`; and `reserving_mpsc: + reserve does not check for room` broke when the no-room path grew its stale-`word` retry, turning a + three-line block into twelve and giving `has_room_beyond_reservations` a second call site in + `push`. The repaired pattern anchors on the comment that is unique to `reserve`, so the two sites + cannot be confused. + Each repair was re-run individually and **caught** before the full sweep, so the fix restored the + check rather than merely restoring the match. - [ ] **SH-3.4** -- Merge to `main`, and confirm release-please raises a release PR proposing **0.2.0** for the topology crate. If it proposes 0.1.1, the breaking-change marker did not take and diff --git a/crates/windows-waitable-queues/sabotage.json b/crates/windows-waitable-queues/sabotage.json index 7d4f612e..53fbdd8b 100644 --- a/crates/windows-waitable-queues/sabotage.json +++ b/crates/windows-waitable-queues/sabotage.json @@ -298,10 +298,10 @@ "expect": "caught", "why": "The sequence protocol's whole arithmetic in one line. A slot freed at `pos + capacity - 1` is never equal to the position that next claims it, so every producer reads a negative difference and reports Full for ever: the queue works for exactly one lap and then wedges. An off-by-one here is invisible to any test that never wraps, which is why the wrap tests run a thousand rounds through four slots.", "find": [ - " position.wrapping_add(self.shared.capacity)," + " position.wrapping_add(self.shared.capacity as Position)," ], "replace": [ - " position.wrapping_add(self.shared.capacity - 1)," + " position.wrapping_add(self.shared.capacity as Position - 1)," ] }, { @@ -324,13 +324,13 @@ "find": [ "const BOUNDS: Bounds = Bounds {", " min: 2,", - " max: WRAPPING_MAX_CAPACITY,", + " max: MAX_ADMISSIBLE_CAPACITY,", "};" ], "replace": [ "const BOUNDS: Bounds = Bounds {", " min: 1,", - " max: WRAPPING_MAX_CAPACITY,", + " max: MAX_ADMISSIBLE_CAPACITY,", "};" ] }, @@ -354,12 +354,19 @@ "why": "A reservation handed out over a full queue is a promise that cannot be kept, which is worse than a refusal: the caller has already been told it may proceed. Reserve is the CHEAP place to fail -- no work has started -- and removing the check moves that failure to the one place the design exists to keep it away from.", "find": [ " if !self.shared.has_room_beyond_reservations(position, reserved) {", + " // Provisional for the reason `push`'s matching check is: a", + " // stale `word` and a freshly-read `head` need not describe the", + " // same instant, and once `head` passes a stale `position` the", + " // subtraction wraps and an empty queue refuses a reservation.", + " let current = self.shared.claim.0.load(Ordering::Relaxed);", + " if current != word {", + " word = current;", + " continue;", + " }", " return None;", " }" ], - "replace": [ - "" - ] + "replace": [] }, { "name": "reserving_mpsc: redeeming does not release the reservation", From 8ab48c764c4587332de0c1b298eaa7f9204361a9 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 21:45:07 -0400 Subject: [PATCH 286/361] docs: correct the predicted release-please bumps for PR #56 The earlier table named four crates and assumed any conventional commit bumps a patch. Verified: only feat, fix and breaking changes trigger a release, so the six crates carrying only test/docs/refactor commits get no release at all -- and two more crates do: windows-thread-ambient-sys (three fix commits scoped to other crates that changed its src/) and the example harness. The harness bump matters because its runtime requirement on windows-file-watcher is ^0.1.3, which 0.2.0 does not satisfy. The cargo-workspace plugin rewrites it; confirmed against this repository's own release history (8b37f9f, 5f7f6af) rather than assumed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 501ff400..6b8320a0 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -332,10 +332,27 @@ that previously stood in the way are gone: | Crate | From | Expect | Driven by | |---|---|---|---| - | `windows-topology-sys` | 0.1.0 | **0.2.0** | 7 breaking commits | - | `windows-waitable-queues` | 0.1.0 | **0.2.0** | 6 breaking commits -- but see SH-3.4.1 | - | `windows-file-watcher` | 0.1.3 | **0.2.0** | the reopen-by-id removal | - | `windows-ioring-sys` | 0.2.0 | **0.3.0** | path attribution only -- see SH-3.4.2 | + | `windows-topology-sys` | 0.1.0 | **0.2.0** | 9 breaking | + | `windows-waitable-queues` | 0.1.0 | **0.2.0** | 6 breaking -- but see SH-3.4.1 | + | `windows-ioring-sys` | 0.2.0 | **0.3.0** | 2 breaking by path attribution -- see SH-3.4.2 | + | `windows-file-watcher` | 0.1.3 | **0.2.0** | 1 breaking, the reopen-by-id removal | + | `windows-thread-ambient-sys` | 0.2.0 | **0.2.1** | 3 `fix:` commits scoped to other crates that changed its `src/` | + | `windows-file-watcher-example-test-harness` | 0.1.2 | **0.1.3** | not its own commits -- the `cargo-workspace` plugin, below | + + **The other six crates get no release at all**, and that is correct rather than a gap: + `wtf-string`, `windows-threadpool-sys`, `windows-overlapped-io-sys`, + `windows-impersonation-token-sys`, `windows-file-enumeration-sys` and + `windows-namespace-request-sys` have only `test:`, `docs:` and `refactor:` commits since their last + tags. Only `feat`, `fix` and breaking changes trigger a release; the rest are changelog-only. An + earlier version of this item assumed any conventional commit bumps a patch, which is wrong. + + **The harness bump is the `cargo-workspace` plugin, and it is verified rather than assumed.** The + harness has a *runtime* dependency `windows-file-watcher = "0.1.3"`, which `^0.1.3` does **not** + satisfy once that crate reaches 0.2.0 -- so something must rewrite it or the published manifest is + broken. The plugin does exactly that, as this repository''s own history shows: `8b37f9f` and + `5f7f6af` (both `chore: release main`) each bumped the harness *and* rewrote its + `windows-file-watcher` requirement in the same commit. Confirm it happened again rather than + trusting it; it is the one dependency edge in the workspace that a bump can actually break. - [ ] **SH-3.4.1** -- **Decide `windows-waitable-queues`' first published version before the release PR merges.** The crate is not on crates.io, sits at 0.1.0 in the manifest, and carries six `!` From 4965b4c29082c0c4bee7526257497cd5bbac9c2e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 22:02:45 -0400 Subject: [PATCH 287/361] docs: record how to keep a cross-crate breaking commit path-clean (SH-3.4.2) A naive two-way split -- rename in the owning crate, then fix the consumer in a chore commit -- produces a commit that does not compile, because the consumer's example still names the old type. The version that compiles at every step is three commits: add the new name as an alias (additive), move the consumer, then delete the alias (breaking, and touching only the owning crate). Also records that this has already shipped once: ioring's changelog carries two guard-alloc entries by the same path-attribution mechanism. And that the cheap correction for the current instance is a Release-As footer on a commit touching only ioring paths, which release-please applies per package by path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 6b8320a0..98cf02ca 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -370,6 +370,31 @@ that previously stood in the way are gone: consumers who experience none. **The general lesson outlives this instance**: a breaking commit that incidentally edits a second crate's files bumps that crate as breaking too, so either keep such commits path-clean or expect to correct the bump. + + **This is not hypothetical -- it has already shipped.** `windows-ioring-sys`'s existing CHANGELOG + carries two `**guard-alloc:**` entries (`983afbc`, `36ecd8a`), which landed there because those + commits touched `crates/windows-ioring-sys/tests/registration.rs`; guard-alloc is a dev-dependency, + so exercising it meant editing ioring's tests. Same mechanism, one release earlier, unnoticed. + + **Splitting the commit is the right shape, and a naive two-way split is wrong.** The obvious fix -- + "rename in topology, then update the ioring example in a `chore:` commit" -- produces a commit that + **does not compile**: `examples/ring_copy/plan.rs` does `use windows_topology_sys::{..., Topology}` + and `pub fn build_plan(topology: &Topology, ...)`, so a topology-only rename breaks it until the + follow-up lands. CI would not catch it (it builds the PR head and main's tip, not each commit), but + `git bisect` across that range would. + The version that compiles at every step is **three** commits, the deprecation dance: (1) add the new + name with the old one kept as an alias -- additive, `feat(topology)`, not breaking; (2) move the + consumer to the new name -- `chore(ioring)`, no novelty; (3) delete the alias -- `feat(topology)!`, + touching only topology paths. Adopt that shape for a cross-crate rename from now on. + + **Do not rewrite these two commits.** They are 15 and 44 deep in a pushed branch with an open PR; + `git rebase -i` is forbidden by this repository's own terminal rules; and retrofitting the alias + step would mean fabricating a deprecation that never happened, for one changelog line. + + **The cheap correct fix, if the bump is worth correcting:** a `Release-As: 0.2.1` footer on a commit + that touches **only** `crates/windows-ioring-sys/`. Verified against release-please's manifest-mode + documentation that the footer is applied **per package, by the paths the commit touches**, so it + pins ioring without disturbing the other five bumps. Decide between that and simply accepting 0.3.0. **The gate this used to hold over SH-2.2 is lifted** -- that item is closed, having had nothing left to do once the pins were deleted rather than maintained. **No longer carries a pin hazard.** An earlier version of this item warned that the PR must not be From e378bc7495ea27332087a52b03840e6e9bd5b9e4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 22:11:13 -0400 Subject: [PATCH 288/361] chore: detect cross-crate release poisoning at commit time release-please attributes a commit to a crate by the paths it touches, not by the (scope) in the subject. A release-triggering commit that incidentally edits a second released crate's files therefore gives that crate a changelog entry and a bump it did not earn -- which has already shipped: ioring's CHANGELOG carries two guard-alloc entries. Adds tools/check-commit-scope.ps1 and wires it into the pre-commit gate, so an unattended session runs the check rather than being expected to remember the rule. It flags rather than blocks, because path data cannot separate an earned bump from an unearned one: measured on this branch, nine release-triggering commits span more than one released crate and seven of them genuinely changed both crates' source. The check is two-pronged. Testing the first version against the commit that actually shipped the defect found it clean -- guard-alloc is publish=false, so that commit touches only one RELEASED crate and a spans-more-than-one test misses it. It now also flags a commit whose scope names a crate other than the one it will be attributed to. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 19 +++ CHECKLIST-ship-topology-and-queues.md | 15 +++ tools/check-commit-scope.ps1 | 174 ++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 tools/check-commit-scope.ps1 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bef0a68a..7fe6b98c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -155,6 +155,25 @@ edits (`tpu_replace_in_file` / `tpu_edit_file`), not only to PowerShell/shell. any hit is a blocking violation — move those tests into a sibling `tests.rs` first. See the full gate in [instructions/global.rust.instructions.md](instructions/global.rust.instructions.md). +- **Release-scope pre-commit gate:** before committing anything typed `feat`, `fix`, + or marked `!`, run + `.\tools\check-commit-scope.ps1 -Staged -Type ''` + and act on what it reports. **release-please attributes a commit to a crate by the + PATHS it touches, not by the `(scope)` in the subject line**, so a release-triggering + commit that incidentally edits a second released crate's files gives that crate a + changelog entry and a version bump it did not earn. Measured on this repository: nine + such commits on one branch, and it has already **shipped** -- `windows-ioring-sys`' + CHANGELOG carries two `**guard-alloc:**` entries for exactly this reason. + The script **flags, it does not decide**, because path data cannot separate the two + cases and both occur here. When the crates genuinely changed together, leave it -- the + bump is earned and splitting would produce a commit that does not compile. When the + sibling is only a **ride-along** (its example, test, or docs followed a rename), move + that part into its own `chore():` commit: `chore` triggers no release, so the + sibling gets nothing. For a cross-crate **rename**, the three-commit form is the one + that keeps every commit compiling -- add the new name as an alias (`feat`, additive), + move the consumer (`chore`), then delete the alias (`feat!`, owning crate only). + Never split a genuinely coupled change merely to satisfy the check: a commit that does + not build is a worse defect than a changelog line that overstates a bump. - **Commit every file `cargo fmt` reformats, even outside your task's scope.** `cargo fmt` rewrites *all* files in the formatted scope, not just the ones you edited — so a run can clean up a pre-existing formatting drift in a file your diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 98cf02ca..8a494d01 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -395,6 +395,21 @@ that previously stood in the way are gone: that touches **only** `crates/windows-ioring-sys/`. Verified against release-please's manifest-mode documentation that the footer is applied **per package, by the paths the commit touches**, so it pins ioring without disturbing the other five bumps. Decide between that and simply accepting 0.3.0. + + **Measured, because the rule had to be affordable before it could be recommended.** Nine + release-triggering commits on this branch span more than one *released* crate -- and only two of + those are the ioring case. **Seven genuinely changed both crates'' source**, so a blanket + "one crate per commit" rule would have forced non-compiling commits seven times to fix a problem + that existed twice. That is why the standing rule flags rather than blocks. + The measurement also **found a hole in the first version of the check**. `983afbc` + (`feat(guard-alloc)`) touched only *one* released crate -- ioring -- because guard-alloc is + `publish = false`; a "spans more than one crate" test misses it entirely, yet it is the commit that + actually shipped a wrong entry. The rule is therefore two-pronged: flag a commit that spans several + released crates, **and** one whose `(scope)` names a crate other than the one it will be attributed + to. + Enforced going forward by [tools/check-commit-scope.ps1](tools/check-commit-scope.ps1), wired into + the pre-commit gate in [.github/copilot-instructions.md](.github/copilot-instructions.md) so an + unattended session runs it rather than being expected to remember the rule. **The gate this used to hold over SH-2.2 is lifted** -- that item is closed, having had nothing left to do once the pins were deleted rather than maintained. **No longer carries a pin hazard.** An earlier version of this item warned that the PR must not be diff --git a/tools/check-commit-scope.ps1 b/tools/check-commit-scope.ps1 new file mode 100644 index 00000000..ea20d50b --- /dev/null +++ b/tools/check-commit-scope.ps1 @@ -0,0 +1,174 @@ +# Copyright (c) 2026 Mike Grier +<# +.SYNOPSIS + Flags commits that would give a released crate a version bump it did not earn. + +.DESCRIPTION + release-please attributes a commit to a package by the PATHS it touches, not + by the (scope) in its subject line. So a release-triggering commit -- feat, + fix, or anything marked `!` -- that incidentally edits a second released + crate's files gives that crate a changelog entry and a version bump for work + that did not change it. + + This has already shipped in this repository twice: windows-ioring-sys' + CHANGELOG carries two **guard-alloc:** entries, because those commits touched + ioring's tests; and two `feat(topology)!` commits would have taken ioring to a + breaking 0.3.0 over an example and one doc-comment line. + + This script FLAGS, it does not decide. It cannot: a doc-comment-only edit to + `src/lib.rs` is indistinguishable from a real one by path alone, and the + measured history shows 7 of 9 cross-crate commits were legitimately coupled. + The judgement is yours; the point is that you make it deliberately rather than + discovering it in a release PR. + +.PARAMETER Range + A git revision range to audit, e.g. 'origin/main..HEAD'. Default when neither + -Range nor -Staged is given. + +.PARAMETER Staged + Check what is currently staged instead of history. Requires -Type. + +.PARAMETER Type + The Conventional Commits type you are about to use, e.g. 'feat', 'fix!', + 'chore'. Only meaningful with -Staged. + +.EXAMPLE + .\tools\check-commit-scope.ps1 + .\tools\check-commit-scope.ps1 -Range 'origin/main..HEAD' + .\tools\check-commit-scope.ps1 -Staged -Type 'feat!' +#> +[CmdletBinding()] +param( + [string] $Range, + [switch] $Staged, + [string] $Type +) + +$ErrorActionPreference = 'Stop' +$repo = Split-Path $PSScriptRoot -Parent + +# The crates release-please actually versions. A `publish = false` crate cannot +# be poisoned, because it is never released -- so it is not a finding. +$manifestPath = Join-Path $repo '.release-please-manifest.json' +if (-not (Test-Path $manifestPath)) { throw "No .release-please-manifest.json at $manifestPath" } +$released = (Get-Content $manifestPath -Raw | ConvertFrom-Json).PSObject.Properties.Name | + ForEach-Object { Split-Path $_ -Leaf } + +function Get-ReleasedCrates([string[]] $paths) { + $paths | + Where-Object { $_ -match '^crates/[^/]+/' } | + ForEach-Object { ($_ -split '/')[1] } | + Sort-Object -Unique | + Where-Object { $_ -in $released } +} + +# A scope maps to a crate by convention: `topology` -> `windows-topology-sys`, +# `wtf-string` -> `wtf-string`. Derived from the crate names rather than a table, +# so a new crate needs no edit here. +function Resolve-Scope([string] $scope, [string[]] $candidates) { + if (-not $scope) { return $null } + foreach ($form in @($scope, "windows-$scope", "windows-$scope-sys", "$scope-sys")) { + if ($form -in $candidates) { return $form } + } + return $null +} + +function Get-Scope([string] $subject) { + if ($subject -match '^[a-z]+\(([^)]+)\)!?:') { return $matches[1] } + return $null +} + +function Test-Triggering([string] $subject) { + # Only feat, fix and breaking changes trigger a release. docs/test/refactor/ + # chore are changelog-only, so they cannot poison anything. + $subject -match '^(feat|fix)(\([^)]+\))?!?:' -or $subject -match '^[a-z]+(\([^)]+\))?!:' +} + +function Test-Breaking([string] $subject) { $subject -match '^[a-z]+(\([^)]+\))?!:' } + +$findings = @() + +if ($Staged) { + if (-not $Type) { throw '-Staged requires -Type (the Conventional Commits type you intend to use).' } + $subject = "${Type}: staged" + if (-not (Test-Triggering $subject)) { + Write-Host "'$Type' does not trigger a release, so it cannot poison a sibling crate. Nothing to check." -ForegroundColor Green + exit 0 + } + $paths = @(git --no-pager diff --cached --name-only) + $crates = @(Get-ReleasedCrates $paths) + if ($crates.Count -gt 1) { + $findings += [pscustomobject]@{ Sha = '(staged)'; Subject = "$Type ..."; Crates = $crates; Breaking = (Test-Breaking $subject); Paths = $paths } + } +} else { + if (-not $Range) { $Range = 'origin/main..HEAD' } + foreach ($line in (git --no-pager log $Range --format='%h|%s')) { + $sha, $subject = $line -split '\|', 2 + if (-not (Test-Triggering $subject)) { continue } + $paths = @(git --no-pager show $sha --name-only --format='') + $crates = @(Get-ReleasedCrates $paths) + if ($crates.Count -eq 0) { continue } + # Two distinct symptoms of one disease: + # - the commit spans several released crates, so the siblings get bumps; or + # - its scope names a crate that is NOT the one it will be attributed to, + # which is how `feat(guard-alloc)` put two entries in ioring's changelog. + $scope = Get-Scope $subject + $allCrates = @($paths | Where-Object { $_ -match '^crates/[^/]+/' } | ForEach-Object { ($_ -split '/')[1] } | Sort-Object -Unique) + $scopeCrate = Resolve-Scope $scope $allCrates + $misScoped = ($null -ne $scopeCrate) -and ($scopeCrate -notin $crates) + if ($crates.Count -gt 1 -or $misScoped) { + $findings += [pscustomobject]@{ Sha = $sha; Subject = $subject; Crates = $crates; Breaking = (Test-Breaking $subject); Paths = $paths; ScopeCrate = $scopeCrate; MisScoped = $misScoped } + } + } +} + +if (-not $findings) { + Write-Host 'No release-triggering commit spans more than one released crate.' -ForegroundColor Green + exit 0 +} + +Write-Host '' +Write-Host "$($findings.Count) release-triggering change(s) span more than one released crate." -ForegroundColor Yellow +Write-Host 'Each of these crates will get a changelog entry and a version bump from it.' +Write-Host '' + +foreach ($f in $findings) { + $mark = if ($f.Breaking) { 'BREAKING' } else { 'release ' } + Write-Host ("[{0}] {1} {2}" -f $mark, $f.Sha, $f.Subject) + if ($f.MisScoped) { + Write-Host (" scope names '{0}', which is NOT released by this commit -- the entry will be filed under the crate(s) below, mislabelled." -f $f.ScopeCrate) -ForegroundColor Yellow + } + foreach ($crate in $f.Crates) { + $srcFiles = @($f.Paths | Where-Object { $_ -like "crates/$crate/src/*" }) + $other = @($f.Paths | Where-Object { $_ -like "crates/$crate/*" -and $_ -notlike "crates/$crate/src/*" }) + $note = if ($srcFiles.Count -eq 0) { + 'NO src change -- almost certainly a ride-along' + } elseif ($srcFiles.Count -eq 1) { + 'ONE src file -- check whether it is a real change or a doc comment' + } else { + "$($srcFiles.Count) src files" + } + Write-Host (" {0,-42} {1}" -f $crate, $note) + if ($srcFiles.Count -le 1 -and $srcFiles.Count -gt 0) { + Write-Host (" {0}" -f $srcFiles[0]) -ForegroundColor DarkGray + } + if ($srcFiles.Count -eq 0 -and $other.Count -gt 0) { + Write-Host (" {0}{1}" -f $other[0], $(if ($other.Count -gt 1) { " (+$($other.Count - 1) more)" } else { '' })) -ForegroundColor DarkGray + } + } + Write-Host '' +} + +Write-Host 'This flags; it does not decide. Two shapes, and they need opposite answers:' -ForegroundColor Cyan +Write-Host ' GENUINELY COUPLED -- both crates behaviour changed. Leave it. The bump is earned,' +Write-Host ' and splitting would produce a commit that does not compile.' +Write-Host '' +Write-Host ' RIDE-ALONG -- the second crate only followed a rename, or its example/test/docs' +Write-Host ' moved. Put that part in its own `chore():` commit; chore triggers no' +Write-Host ' release, so the sibling gets nothing. For a rename, the three-commit form keeps' +Write-Host ' every commit compiling: add the new name as an alias (feat, additive), move the' +Write-Host ' consumer (chore), then delete the alias (feat!, owning crate only).' +Write-Host '' +Write-Host ' Already committed and not worth rewriting? Correct it at release time with a' +Write-Host ' `Release-As: x.y.z` footer on a commit touching only that crate.' +exit 1 \ No newline at end of file From cdce13b390fa4ae77a4f7fb6fa86db35f22f5e51 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 22:19:19 -0400 Subject: [PATCH 289/361] docs(ioring): pin the next release to 0.2.1, and record what that obliges release-please attributes a commit to a crate by the paths it touches, not by the (scope) in its subject. Two `topology`-scoped breaking commits edited files under this crate -- `b9e0c35` touched only `examples/ring_copy/`, and `36e397d` touched the example plus one doc-comment heading in `src/lib.rs` -- so it would propose 0.3.0 for a crate whose public surface did not change at all. Across the whole branch no public item signature here changed; the only other `src/` edits are comments and a test-only helper. A 0.3.0 would announce breaking changes under a heading consumers are trained to act on, and send them looking for a migration that does not exist. So the version is forced instead. The pin is a promise about this crate's surface, not a formatting preference, so D-46 records the obligation it creates: no breaking change may enter windows-ioring-sys between now and the release of 0.2.1. If one becomes necessary, the pin is removed and the crate takes its minor bump -- the break is never absorbed underneath a version that says there isn't one. Shipping a compatible-looking version over an incompatible surface would be worse than the overstated 0.3.0 this avoids. Recurrence is guarded by tools/check-commit-scope.ps1, wired into the pre-commit gate. This crate has been bitten before: the **guard-alloc:** entries in its CHANGELOG are there because those commits touched tests/registration.rs. Release-As: 0.2.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-ioring-sys/DESIGN-NOTES.md | 56 +++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/windows-ioring-sys/DESIGN-NOTES.md b/crates/windows-ioring-sys/DESIGN-NOTES.md index d12038e1..01f50680 100644 --- a/crates/windows-ioring-sys/DESIGN-NOTES.md +++ b/crates/windows-ioring-sys/DESIGN-NOTES.md @@ -68,6 +68,7 @@ runs a continuation), this crate exposes the mechanism and documents the trade-o | D-43 | **Fixed in M18.6: `EventDelivery` hands out a `RingScope` -- every read-only part of `IoRing` plus batch construction, and no `&mut IoRing` -- because any `&mut IoRing` permits whole-value assignment, which let safe code replace the ring and silently stop delivery.** The defect, for the record: `EventDelivery::ring` returned `&Mutex`. Found by [M18.1's borrow-surface audit](#borrow-surface-audit-m181) and **measured, not argued**: `*delivery.ring().lock().unwrap() = IoRing::new(64, 64)?` compiles, and a probe recorded one completion delivered before the swap and **none** after it, despite four further operations being submitted and completed on the replacement. The mechanism is that the pool's wait holds its own duplicate of the *original* ring's completion event ([D-20](#d-20)); replacing the ring drops that ring and attaches nothing to the new one, so the armed wait can never be signalled again. **This is [D-35](#d-35)'s shape at a different layer** -- there, `&mut Vec` permitted `reserve` and reassignment where only byte writes were intended, and the fix was to narrow the returned type to `&mut [u8]`. Here the returned type permits replacing the whole ring where only submitting work was intended. Note the trap in the obvious fixes: a `Deref`/`DerefMut` newtype does **not** close it, because `*guard = ...` works through `DerefMut` just as well, and neither does a `with_ring(\|ring: &mut IoRing\| ...)` closure, for the same reason. Closing it meant never letting a `&mut IoRing` escape -- `RingScope` hands out a `Batch` instead -- which changed all nine call sites including the `epoch_log` example. The refusal is now enforced by a `compile_fail` doctest, itself verified by adding a `DerefMut` impl and watching that doctest fail, which is also the empirical proof of the claim above that a `Deref` newtype would not have closed the hole. **Severity was silent correctness, not unsoundness.** No use-after-free is reachable: the old ring runs down normally, the wait's duplicate handle stays valid, and completions on the replacement are still claimable. Delivery simply stops, which is the failure mode hardest to notice. The existing rustdoc warns against calling `completion_event` on the shared ring but says nothing about replacing it. | | D-44 | **A spike against the real kernel is a budgeted, first-class technique for every new Win32 surface this crate wraps -- not something that happens after a test fails mysteriously.** The full argument is in [Testing strategy](#testing-strategy-m185); the decision is that the budget is allocated *before* the wrapper is written. Two of the eight defects behind M15-M18 exist because a Win32 contract was assumed rather than measured: the completion event is edge-triggered ([D-19](#d-19)) and `BuildIoRingRegisterBuffers` reads its array when the operation *runs* ([D-32](#d-32)). No oracle, generator, allocator or mutation run supplies that knowledge, because each of them checks code against **our** stated contract -- and in both cases our stated contract was the thing that was wrong. What they detect is a *consequence*, and only on a path some test already walks: the guard allocator does turn D-32 into a hard `STATUS_ACCESS_VIOLATION`, measured in M17.4's calibration, but that is the crash after the mistake, not the knowledge that would have prevented it. A spike is also the only technique here that can be run *before* there is code to test. Two obligations follow, both learned the hard way and recorded in [design-sessions/spikes/README.md](design-sessions/spikes/README.md): a spike must carry a **control case**, because the first two drain-ordering spikes could not discriminate and would have returned confidently wrong answers; and it must be **kept**, as a standalone single-file program depending only on `windows-sys`, so that what it measures stays the operating system's behaviour rather than ours. | | D-45 | **A borrow-returning method must be audited on two questions, not one: what the returned value *permits*, and how long the *borrow* lasts. `RegisteredBuffers::get` therefore takes `&mut self`.** [M18.1's audit](#borrow-surface-audit-m181) asked only the first, of all nineteen items, and the second is where [D-36](#d-36)'s fix was still open: `get` checked `kernel_writes` at the instant of the call but returned a slice living as long as the borrow, and `Batch::read_registered` takes the registration by **shared** reference -- so safe code could take the borrow while the buffer was quiet, then submit a read into that same buffer and keep reading. Measured before being believed: a probe watched the bytes change from `0x11` to `0xEE` through the live slice while a fresh `get(0)` at that same instant correctly refused with `WouldBlock`. The guard worked; the borrow outlived it. **`&mut self` costs nothing real**, because no caller needs to read a buffer during the window it is refused -- while a read is in flight the bytes are indeterminate and only become meaningful once the completion is observed, so earlier or later is always available. That is not merely an argument: all ~40 read sites in this crate's tests, examples and the epoch-log sample already read at a quiescent point, and converting them needed nothing but `mut` on a local. The concession D-36 deliberately kept (reading a buffer whose own *write* is in flight, where the kernel only reads) is given up with it, and is likewise unused. The arena pattern survives, because a [`Token`] holds a [`RegisteredUse`] rather than a borrow of the registration, so quiet neighbours stay readable while operations are outstanding. Enforced by a `compile_fail` doctest, itself verified by reverting the signature and watching it fail, and paired with a `no_run` doctest asserting the neighbour case still compiles so the guard cannot become over-constraining unnoticed. `get_mut` never had the defect: `&mut self` already conflicted with the shared borrow. | +| D-46 | **This crate's next release is pinned to `0.2.1`, because the two breaking commits attributed to it broke nothing here.** release-please attributes a commit to a crate by the **paths it touches**, not by the `(scope)` in its subject. Two `topology`-scoped breaking commits edited this crate -- `b9e0c35` touched only `examples/ring_copy/`, and `36e397d` touched the example plus **one doc-comment heading** in `src/lib.rs` (`# Topology guidance` -> `# MachineMemoryTopology guidance`). Across the whole branch **no public item signature in this crate changed**, so a 0.3.0 announcing breaking changes would send consumers looking for a migration that does not exist. The pin is a `Release-As: 0.2.1` footer, which release-please applies per package by path. **The pin is a promise, and it constrains what may land here before the release**: it is only honest while this crate's public surface stays compatible, so no breaking change may enter `windows-ioring-sys` until 0.2.1 ships. If one becomes necessary, the pin is removed rather than the break being quietly absorbed -- changing our mind about a break *after* pinning is exactly the silent understatement the pin exists to prevent. | ## Durability on the ring @@ -825,3 +826,58 @@ absent. A model belongs here as an **oracle over observed sequences** assuming ([D-37](#d-37)): it works, and needs no SDK, but it is keyed by *image file name* and cargo rehashes test binaries on every meaningful rebuild -- so it would degrade silently to instrumenting nothing. + +## D-46: the next release is pinned to 0.2.1, and what that pin obliges + +release-please decides which crate a commit belongs to by the **paths it touches**, not by the +`(scope)` in its subject line. Two commits scoped to `topology` and marked breaking edited files +under `crates/windows-ioring-sys/`, so release-please counts two breaking changes *for this crate* +and would propose **0.3.0**. + +What those commits actually did here: + +| Commit | Changed in this crate | +|---|---| +| `b9e0c35` `feat(topology)!: remove Domain::id ...` | `examples/ring_copy/plan.rs`, `policy.rs` | +| `36e397d` `refactor(topology)!: rename Topology ...` | three example files, and **one line** of `src/lib.rs` | + +That one line is a doc-comment heading: + +``` +-//! # Topology guidance ++//! # MachineMemoryTopology guidance +``` + +They touched this crate because the `ring_copy` example *consumes* `windows-topology-sys`; renaming +`Topology` and removing `Domain::id` forced the example to follow. The break is real, and it belongs +to `windows-topology-sys`, which takes its own 0.2.0 for it. Across the entire branch **no public +item signature in this crate changed** -- the only other `src/` edits are comments and one test-only +helper. + +So a 0.3.0 here would announce breaking changes under a heading consumers are trained to act on, and +send them looking for a migration that does not exist. The release is pinned instead: + +``` +Release-As: 0.2.1 +``` + +applied on a commit touching only this crate's paths, which release-please evaluates per package by +path. + +### The pin is a promise, and it constrains what may land here + +A forced version is only honest while the claim behind it holds. `0.2.1` asserts that this crate's +public surface is compatible with `0.2.0`, so **no breaking change may enter `windows-ioring-sys` +between this pin and the release of 0.2.1**. + +If a break becomes necessary before then, the answer is to **remove the pin** and let the crate take +its minor bump -- never to let the break land underneath a version that says there isn't one. +Changing our mind about a break *after* pinning, and absorbing it quietly, is precisely the silent +understatement the pin was written to prevent: it would ship a compatible-looking version over an +incompatible surface, which is worse than the overstated 0.3.0 this decision set out to avoid. + +The general defect this is one instance of -- a release-triggering commit that incidentally edits a +second released crate's files -- is guarded going forward by +[tools/check-commit-scope.ps1](../../tools/check-commit-scope.ps1), wired into the pre-commit gate. +It has bitten this crate before: the `**guard-alloc:**` entries in [CHANGELOG.md](CHANGELOG.md) are +there because those commits touched `tests/registration.rs`. From 44171f0bb3b19de331f6b163d78baab0ba00b57c Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 22:22:43 -0400 Subject: [PATCH 290/361] chore: guard a Release-As pin against a later breaking change A forced version asserts that a crate's surface is compatible. That stays true only while nothing breaks underneath it, so the check now fails when a breaking commit lands in a crate pinned earlier in the range. The answer is to remove the pin and let the crate take its bump -- never to absorb the break under a version that says there is not one, which would ship a compatible-looking version over an incompatible surface. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/check-commit-scope.ps1 | 46 +++++++++++++++++++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/tools/check-commit-scope.ps1 b/tools/check-commit-scope.ps1 index ea20d50b..5eb8482c 100644 --- a/tools/check-commit-scope.ps1 +++ b/tools/check-commit-scope.ps1 @@ -122,11 +122,55 @@ if ($Staged) { } } +# A `Release-As:` pin asserts something about a crate's surface. It stays honest +# only while nothing breaks underneath it, so a breaking commit landing in a +# pinned crate AFTER the pin is a defect: it would ship a compatible-looking +# version over an incompatible surface, which is worse than the overstated bump +# the pin was written to avoid. Remove the pin rather than absorb the break. +$pinViolations = @() +if (-not $Staged) { + $pins = @{} + foreach ($sha in @(git --no-pager log $Range --format='%h' --reverse)) { + $body = git --no-pager log -1 --format='%B' $sha | Out-String + $touched = @(Get-ReleasedCrates @(git --no-pager show $sha --name-only --format='')) + if ($body -match '(?m)^Release-As:\s*(\S+)\s*$') { + $pinnedTo = $matches[1] + foreach ($crate in $touched) { $pins[$crate] = @{ Version = $pinnedTo; Sha = $sha } } + continue + } + $subject = git --no-pager log -1 --format='%s' $sha + if (-not (Test-Breaking $subject)) { continue } + foreach ($crate in $touched) { + if ($pins.ContainsKey($crate)) { + $pinViolations += [pscustomobject]@{ + Crate = $crate; Pin = $pins[$crate].Version; PinSha = $pins[$crate].Sha + Sha = $sha; Subject = $subject + } + } + } + } +} + +if ($pinViolations) { + Write-Host '' + Write-Host "$($pinViolations.Count) breaking change(s) landed in a crate whose version is PINNED." -ForegroundColor Red + foreach ($v in $pinViolations) { + Write-Host (" {0} pinned to {1} by {2}, then broken by {3} {4}" -f $v.Crate, $v.Pin, $v.PinSha, $v.Sha, $v.Subject) + } + Write-Host '' + Write-Host 'A pin asserts the surface is compatible. Remove the pin and let the crate take its' -ForegroundColor Cyan + Write-Host 'bump -- never absorb a break underneath a version that says there is not one.' + exit 1 +} + if (-not $findings) { Write-Host 'No release-triggering commit spans more than one released crate.' -ForegroundColor Green + if (-not $Staged) { + $pinLines = @(git --no-pager log $Range --format='%B' | Select-String '^Release-As:') + if ($pinLines) { Write-Host " ($($pinLines.Count) Release-As pin(s) in range, none broken since)" -ForegroundColor DarkGray } + } exit 0 } - Write-Host '' Write-Host "$($findings.Count) release-triggering change(s) span more than one released crate." -ForegroundColor Yellow Write-Host 'Each of these crates will get a changelog entry and a version bump from it.' From bf26ef06f94ef7916e390818a9fa039745d3309a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 22:23:29 -0400 Subject: [PATCH 291/361] docs: record the ioring 0.2.1 decision (SH-3.4.2) Closes SH-3.4.2 and corrects the predicted-bump table. The pin's obligation -- no breaking change in ioring before 0.2.1 ships -- is enforced by check-commit-scope.ps1 rather than left to memory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 8a494d01..e5473d92 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -334,7 +334,7 @@ that previously stood in the way are gone: |---|---|---|---| | `windows-topology-sys` | 0.1.0 | **0.2.0** | 9 breaking | | `windows-waitable-queues` | 0.1.0 | **0.2.0** | 6 breaking -- but see SH-3.4.1 | - | `windows-ioring-sys` | 0.2.0 | **0.3.0** | 2 breaking by path attribution -- see SH-3.4.2 | + | `windows-ioring-sys` | 0.2.0 | **0.2.1** | pinned by `Release-As` -- SH-3.4.2, [D-46](crates/windows-ioring-sys/DESIGN-NOTES.md#d-46) | | `windows-file-watcher` | 0.1.3 | **0.2.0** | 1 breaking, the reopen-by-id removal | | `windows-thread-ambient-sys` | 0.2.0 | **0.2.1** | 3 `fix:` commits scoped to other crates that changed its `src/` | | `windows-file-watcher-example-test-harness` | 0.1.2 | **0.1.3** | not its own commits -- the `cargo-workspace` plugin, below | @@ -361,7 +361,21 @@ that previously stood in the way are gone: break. Either accept 0.2.0 as the first version, or force the first release with `Release-As: 0.1.0`. Not a defect -- a naming decision that is cheap now and permanent afterwards. -- [ ] **SH-3.4.2** -- **Decide what to do about `windows-ioring-sys`' unearned breaking bump.** +- [x] **SH-3.4.2** -- **Decide what to do about `windows-ioring-sys`' unearned breaking bump.** + **DECIDED 2026-09-03: ioring ships as 0.2.1.** Pinned by a `Release-As: 0.2.1` footer on `cdce13b`, + a commit touching only `crates/windows-ioring-sys/`, which release-please applies per package by + path. Verified against its documentation that the footer works on any commit type -- including + `docs:` -- and overrides a breaking bump. Rationale recorded as + [D-46](crates/windows-ioring-sys/DESIGN-NOTES.md#d-46) in the crate that owns the consequence. + **The pin creates an obligation, and it is enforced rather than remembered**: a forced version + asserts the surface is compatible, so **no breaking change may enter `windows-ioring-sys` before + 0.2.1 ships**. If one becomes necessary the pin is removed and the crate takes its bump -- the + break is never absorbed under a version that says there is not one, which would ship a + compatible-looking version over an incompatible surface and is strictly worse than the overstated + 0.3.0 this avoids. [tools/check-commit-scope.ps1](tools/check-commit-scope.ps1) now fails when a + breaking commit lands in a crate pinned earlier in the range; sabotage-verified by injecting a + `feat(ioring)!` after the pin and confirming it named both commits. + Release-please attributes a commit by the **paths it touches**, not by its Conventional Commits scope. Two `feat(topology)!` commits (`b9e0c35`, `36e397d`) touched `crates/windows-ioring-sys/`, so it will take a breaking **0.3.0**. Its public API did not break: the only changes there were one From a1820e9657378e81803610e6f57895801a7bffe8 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 22:36:42 -0400 Subject: [PATCH 292/361] chore(waitable-queues): start the crate at 0.0.1 so its first release is 0.1.0 The crate has never been published and carries no release tag, so its manifest entry was never a record of a release -- only a starting point, and the starting point was wrong. Left at 0.1.0, its six breaking commits would have made the first published version 0.2.0, skipping 0.1.0 entirely. From 0.0.1, `bump-minor-pre-major` takes a breaking change to 0.1.0, which is what a first release should look like. Chosen over a `Release-As: 0.1.0` footer because the two situations differ. The ioring pin asserts something falsifiable -- "no break happened" -- which is why it needs a guard against a later break landing underneath it. Here nothing is asserted, so this corrects a starting point rather than overriding a computation. It is also robust where a fixed version is not: further breaking commits before release still yield 0.1.0. Not 0.0.0, which release-please special-cases -- the pre-major options stop applying and it jumps to 1.0.0 (googleapis/release-please#2087). Safe to lower: both consumers are versionless path dependencies on `publish = false` crates, so no version requirement anywhere can fail to resolve. Verified with cargo metadata, a clean workspace check, and `cargo publish --dry-run`. Completed item: SH-3.4.1: Decide windows-waitable-queues' first published version before the release PR merges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHECKLIST-ship-topology-and-queues.md | 23 ++++++++++++++++++++--- Cargo.lock | 2 +- crates/windows-waitable-queues/Cargo.toml | 8 +++++++- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 233f2746..ae13ae55 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -9,6 +9,6 @@ "crates/windows-thread-ambient-sys": "0.2.0", "crates/windows-threadpool-sys": "0.1.3", "crates/windows-topology-sys": "0.1.0", - "crates/windows-waitable-queues": "0.1.0", + "crates/windows-waitable-queues": "0.0.1", "crates/wtf-string": "0.1.0" } diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index e5473d92..7d510bda 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -333,7 +333,7 @@ that previously stood in the way are gone: | Crate | From | Expect | Driven by | |---|---|---|---| | `windows-topology-sys` | 0.1.0 | **0.2.0** | 9 breaking | - | `windows-waitable-queues` | 0.1.0 | **0.2.0** | 6 breaking -- but see SH-3.4.1 | + | `windows-waitable-queues` | 0.0.1 | **0.1.0** | 6 breaking, from a corrected starting point -- SH-3.4.1 | | `windows-ioring-sys` | 0.2.0 | **0.2.1** | pinned by `Release-As` -- SH-3.4.2, [D-46](crates/windows-ioring-sys/DESIGN-NOTES.md#d-46) | | `windows-file-watcher` | 0.1.3 | **0.2.0** | 1 breaking, the reopen-by-id removal | | `windows-thread-ambient-sys` | 0.2.0 | **0.2.1** | 3 `fix:` commits scoped to other crates that changed its `src/` | @@ -354,8 +354,25 @@ that previously stood in the way are gone: `windows-file-watcher` requirement in the same commit. Confirm it happened again rather than trusting it; it is the one dependency edge in the workspace that a bump can actually break. -- [ ] **SH-3.4.1** -- **Decide `windows-waitable-queues`' first published version before the release - PR merges.** The crate is not on crates.io, sits at 0.1.0 in the manifest, and carries six `!` +- [x] **SH-3.4.1** -- **Decide `windows-waitable-queues`' first published version before the release + PR merges.** + **DECIDED 2026-09-03: the crate starts at `0.0.1`, so its first published version is `0.1.0`.** + Not the `Release-As` route this item first proposed -- that was the wrong instrument, and the two + cases differ in a way worth stating. The ioring pin *asserts* something falsifiable ("no break + happened"), which is why it needs a guard. Here nothing is asserted: the crate has **no release + tag**, so its manifest entry was never a record of a release, only a starting point -- and the + starting point was simply wrong. Setting it to `0.0.1` corrects it rather than overriding it. + Three consequences, in order of importance: with `bump-minor-pre-major` a breaking change takes + `0.0.1` to **`0.1.0`**, which is what a first release should look like; it is **robust** where a + fixed `Release-As` is not, since further breaking commits before release still yield `0.1.0`; and + it needs no footer, no pin, and nothing to remember. + **`0.0.0` would have been the wrong value** -- release-please special-cases it and the pre-major + options stop applying, jumping to `1.0.0` + ([googleapis/release-please#2087](https://github.com/googleapis/release-please/issues/2087)). + Safe to lower: both consumers are versionless path dependencies on `publish = false` crates, so no + requirement anywhere can fail to resolve. Verified by `cargo metadata` and a clean + `cargo publish --dry-run`. + The crate is not on crates.io, sits at 0.1.0 in the manifest, and carries six `!` commits, so release-please will propose **0.2.0** and 0.1.0 will never exist. The `!` markers are honest about the branch's history but describe an API that was never published, so nothing can break. Either accept 0.2.0 as the first version, or force the first release with `Release-As: 0.1.0`. diff --git a/Cargo.lock b/Cargo.lock index 08f1680e..af811f7d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,7 +303,7 @@ dependencies = [ [[package]] name = "windows-waitable-queues" -version = "0.1.0" +version = "0.0.1" dependencies = [ "windows-sys", ] diff --git a/crates/windows-waitable-queues/Cargo.toml b/crates/windows-waitable-queues/Cargo.toml index 2ffecaea..991cd187 100644 --- a/crates/windows-waitable-queues/Cargo.toml +++ b/crates/windows-waitable-queues/Cargo.toml @@ -2,7 +2,13 @@ [package] name = "windows-waitable-queues" -version = "0.1.0" # x-release-please-version +# `0.0.x` on purpose: this crate has never been published, so this is a starting +# point rather than a record of a release. With `bump-minor-pre-major`, the six +# breaking commits behind it make the first published version `0.1.0` -- which is +# what a first release should look like. Leaving it at `0.1.0` would have made +# that first release `0.2.0`, skipping `0.1.0` entirely. Not `0.0.0`, which +# release-please special-cases into a jump to `1.0.0`. +version = "0.0.1" # x-release-please-version authors.workspace = true edition.workspace = true rust-version.workspace = true From 9668bd7bd23e4c97c9a67eee02f51ffeff0aead4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 22:46:12 -0400 Subject: [PATCH 293/361] docs: compile every published crate's README, and refresh the topology one Two problems, one of which the fix itself uncovered. **The topology README described a crate that no longer exists in that shape.** It predated the whole MMT reshape -- the largest change on this branch -- so it said nothing about the observation model (relations held as a set with a named `Source`, both observers kept rather than merged), `Observed`'s three states, `Granularity`/`proximity`, `cpu_sets`, `enumeration_anomalies`, or `Provenance`. It also described the absence of a distance model as "considered and declined for now", which understates D-20: not going below the Win32 topology APIs is a settled scope boundary, not a deferral. **Seven published crates carried README examples that nothing compiled.** Per CONTRACT INTEGRITY, prose that contains code must compile, or it can only rot. They are now included as `cfg(doctest)` doctests, matching the pattern the other crates already used. Wiring them immediately found a broken example in a **published** crate: `windows-overlapped-io-sys`' README calls `BlockingEndpoint::read`, which exists only under the non-default `fs` feature (`default = []`), so it had never compiled. The README already says the example needs `fs`; the doctest is gated to match rather than the example weakened, and docs.rs builds `all-features`, so the published documentation is the configuration that checks it. Also reverts an over-eager rename in `windows-ioring-sys`: `36e397d` rewrote its lib.rs heading `# Topology guidance` to `# MachineMemoryTopology guidance`, but that section is about topology as a subject -- sizing a domain by L3 cache -- and never mentions the type. Its own README kept the correct wording, so the two disagreed. This is the same over-eager cross-crate edit that gave ioring an unearned version bump; the version was corrected in cdce13b, and this corrects the prose it damaged. Verified: `cargo test --workspace --doc --all-features` green, clippy clean, fmt clean, and every relative link in every published README resolves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-file-enumeration-sys/src/lib.rs | 8 +++ .../src/lib.rs | 8 +++ crates/windows-ioring-sys/src/lib.rs | 10 ++- crates/windows-overlapped-io-sys/src/lib.rs | 15 +++++ crates/windows-threadpool-sys/src/lib.rs | 8 +++ crates/windows-topology-sys/README.md | 67 ++++++++++++++----- crates/windows-topology-sys/src/lib.rs | 8 +++ crates/wtf-string/src/lib.rs | 8 +++ 8 files changed, 116 insertions(+), 16 deletions(-) diff --git a/crates/windows-file-enumeration-sys/src/lib.rs b/crates/windows-file-enumeration-sys/src/lib.rs index 5886a497..7b4aadc0 100644 --- a/crates/windows-file-enumeration-sys/src/lib.rs +++ b/crates/windows-file-enumeration-sys/src/lib.rs @@ -163,3 +163,11 @@ pub use session::{ MINIMUM_COMPLETION_RING_CAPACITY, MINIMUM_SUBMISSION_CAPACITY, Receiver, Session, }; pub use timestamp::WindowsFileTimestamp; + +// The crate's markdown documentation is compiled as doctests, so an example that +// a contract change invalidates breaks the build instead of quietly teaching the +// old answer. `cfg(doctest)` means these items exist only while rustdoc collects +// tests, so they cost an ordinary build nothing. +#[cfg(all(doctest, windows))] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; diff --git a/crates/windows-impersonation-token-sys/src/lib.rs b/crates/windows-impersonation-token-sys/src/lib.rs index def3e480..bc577024 100644 --- a/crates/windows-impersonation-token-sys/src/lib.rs +++ b/crates/windows-impersonation-token-sys/src/lib.rs @@ -548,3 +548,11 @@ fn classify_thread_token_open_error(error: io::Error) -> ThreadTokenOpenError { #[cfg(test)] mod tests; + +// The crate's markdown documentation is compiled as doctests, so an example that +// a contract change invalidates breaks the build instead of quietly teaching the +// old answer. `cfg(doctest)` means these items exist only while rustdoc collects +// tests, so they cost an ordinary build nothing. +#[cfg(all(doctest, windows))] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; diff --git a/crates/windows-ioring-sys/src/lib.rs b/crates/windows-ioring-sys/src/lib.rs index 333815ae..50270702 100644 --- a/crates/windows-ioring-sys/src/lib.rs +++ b/crates/windows-ioring-sys/src/lib.rs @@ -111,7 +111,7 @@ //! construction is also the expensive one. "Durability on the ring" in //! `DESIGN-NOTES.md` has the full shape and the three ways to pay for it. //! -//! # MachineMemoryTopology guidance +//! # Topology guidance //! //! This crate does not partition anything for you (D-8 in `DESIGN-NOTES.md`): //! it makes a ring cheap and correct, makes its affinity explicit, and leaves @@ -192,3 +192,11 @@ pub use ring::InjectedFailure; pub use ring::{Completion, IoRing, Op, RingInfo}; #[cfg(windows)] pub use token::Token; + +// The crate's markdown documentation is compiled as doctests, so an example that +// a contract change invalidates breaks the build instead of quietly teaching the +// old answer. `cfg(doctest)` means these items exist only while rustdoc collects +// tests, so they cost an ordinary build nothing. +#[cfg(all(doctest, windows))] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; diff --git a/crates/windows-overlapped-io-sys/src/lib.rs b/crates/windows-overlapped-io-sys/src/lib.rs index ccf479ef..e3bda765 100644 --- a/crates/windows-overlapped-io-sys/src/lib.rs +++ b/crates/windows-overlapped-io-sys/src/lib.rs @@ -105,3 +105,18 @@ pub use socket::{AssociatedSocket, BlockingSocket, SocketIo}; #[cfg(windows)] pub use started::Started; + +// The crate's markdown documentation is compiled as doctests, so an example that +// a contract change invalidates breaks the build instead of quietly teaching the +// old answer. `cfg(doctest)` means these items exist only while rustdoc collects +// tests, so they cost an ordinary build nothing. +// Gated on `fs` because the README's example uses the `fs` adapter's +// `BlockingEndpoint::read`, which does not exist in the default feature set +// (`default = []`). Without the gate the example fails to compile for a reason +// the README already states -- so the gate matches the doctest to what the +// prose says it needs, rather than weakening the example. docs.rs builds with +// `all-features`, so the published documentation is the configuration that +// checks it. +#[cfg(all(doctest, windows, feature = "fs"))] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; diff --git a/crates/windows-threadpool-sys/src/lib.rs b/crates/windows-threadpool-sys/src/lib.rs index 9e010a70..8a5b00f6 100644 --- a/crates/windows-threadpool-sys/src/lib.rs +++ b/crates/windows-threadpool-sys/src/lib.rs @@ -147,3 +147,11 @@ pub mod timer; pub mod wait; #[cfg(windows)] pub mod work; + +// The crate's markdown documentation is compiled as doctests, so an example that +// a contract change invalidates breaks the build instead of quietly teaching the +// old answer. `cfg(doctest)` means these items exist only while rustdoc collects +// tests, so they cost an ordinary build nothing. +#[cfg(all(doctest, windows))] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; diff --git a/crates/windows-topology-sys/README.md b/crates/windows-topology-sys/README.md index 4a4c7481..e6e21038 100644 --- a/crates/windows-topology-sys/README.md +++ b/crates/windows-topology-sys/README.md @@ -1,6 +1,6 @@ # windows-topology-sys -Safe enumeration of Windows processor, cache, and memory topology. +A refined view of the processor, cache, and memory topology Windows publishes. **Windows only.** Every item is behind `cfg(windows)`; the crate builds to an empty shell on other platforms. @@ -38,6 +38,37 @@ be solved by walking the buffer correctly. This crate does that walk once, safely, and hands back owned records. +## What the model is + +**Observed connectivity, not a ladder of levels with optional rungs.** The +difference matters to a consumer: a level-shaped model has to invent a value +for a rung the platform did not report, and the invented value is +indistinguishable from a measured one. + +- **Relations are held as a set, and never reduced on insert.** Windows + describes processors through *two* APIs -- + `GetLogicalProcessorInformationEx` and `GetSystemCpuSetInformation` -- and + they are not the same API twice. Each relation carries an [`Observation`] + naming its [`Source`], so where the two disagree the disagreement survives + rather than being silently resolved in favour of whichever was read last. + [`MachineMemoryTopology::cpu_sets`] also keeps the CPU-set view verbatim. +- **[`Observed`] distinguishes three facts that a plain `Option` collapses + into two**: `Known`, `Absent` ("asked, and there is none"), and `NotObserved` + ("nobody asked"). With the `serde` feature these are three distinct + encodings, so a description round-trips the distinction instead of losing it. +- **[`Granularity`] orders the domain kinds**, and `minimal_shared` is the meet + -- so "how close are these two processors?" is answered over every kind and + any cache depth, rather than over one nominated level. `proximity` derives + the pairwise answer from an inclusion-ordered partitioning rather than + restating the rule. +- **[`MachineMemoryTopology::enumeration_anomalies`] records what could not be + decoded.** Empty on every healthy machine. A malformed record is neither a + panic nor a silent truncation, so a short list is distinguishable from a + small machine. +- **[`Provenance`] records how the object was obtained** -- discovered, + restored from a description, or hand-built -- which is a fact about the + construction, orthogonal to which source reported any given relation. + ## Scope **What this is:** safe enumeration ([`MachineMemoryTopology::discover`]), plus a plain-data @@ -45,26 +76,32 @@ description ([`MachineMemoryTopology`], [`Domain`]) that needs no Windows API to -- build one by hand, or (with the `serde` feature) deserialize one from JSON written for a machine you do not have. -**What this is not:** an opinionated topology model. It does not decide what -counts as a "locality domain worth partitioning by" -- by NUMA node, by -last-level cache, by package -- that is the consumer's call, because the -right answer depends on the workload. It is also not a partitioning policy, -and not a device topology: no NVMe controller, NIC, or GPU is a topology -participant here, and there is no HMAT-style attributed-distance model. Both -were considered and declined for now. +**What this is not:** a partitioning policy. It answers what the machine looks +like, never which boundary you should shard on -- that depends on the workload, +so it is the consumer's call. It is also not a device topology: no NVMe +controller, NIC, or GPU is a participant here. -See [DESIGN-NOTES.md](DESIGN-NOTES.md) for the full reasoning, including a -cross-check against Linux's topology model, D-9's full list of what was -declined and why, and D-8's note that the JSON schema is not covered by this -crate's semver contract. +**It does not go below the Win32 topology APIs.** If Windows does not report a +fact, this crate does not have it. That is a scope boundary rather than a +judgement about the fact: ACPI carries SLIT distances, no Win32 API surfaces +them, and reading firmware directly would be going below the boundary -- so +there is no attributed-distance model, and none is planned here. -Run `cargo run --example print_topology --features serde` to see the host's -own topology as JSON -- the shape a hand-written or synthetic description -takes. +See [DESIGN-NOTES.md](DESIGN-NOTES.md) for the full reasoning, including a +cross-check against Linux's topology model, D-9's list of what was declined and +why, D-20's scope ruling above, and D-8's note that the JSON schema is not +covered by this crate's semver contract. [`MachineMemoryTopology::discover`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.MachineMemoryTopology.html#method.discover [`MachineMemoryTopology`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.MachineMemoryTopology.html +[`MachineMemoryTopology::cpu_sets`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.MachineMemoryTopology.html#structfield.cpu_sets +[`MachineMemoryTopology::enumeration_anomalies`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.MachineMemoryTopology.html#structfield.enumeration_anomalies [`Domain`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.Domain.html +[`Observation`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/struct.Observation.html +[`Source`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/enum.Source.html +[`Observed`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/enum.Observed.html +[`Granularity`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/enum.Granularity.html +[`Provenance`]: https://docs.rs/windows-topology-sys/latest/windows_topology_sys/enum.Provenance.html ## License diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 1499280f..b8ff7c65 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -104,3 +104,11 @@ pub use relation::{ }; #[cfg(windows)] pub use topology::MachineMemoryTopology; + +// The crate's markdown documentation is compiled as doctests, so an example that +// a contract change invalidates breaks the build instead of quietly teaching the +// old answer. `cfg(doctest)` means these items exist only while rustdoc collects +// tests, so they cost an ordinary build nothing. +#[cfg(all(doctest, windows))] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; diff --git a/crates/wtf-string/src/lib.rs b/crates/wtf-string/src/lib.rs index 271dd655..16faa02c 100644 --- a/crates/wtf-string/src/lib.rs +++ b/crates/wtf-string/src/lib.rs @@ -139,3 +139,11 @@ mod string; pub use encoding::{Wtf8, Wtf16, WtfEncoding}; pub use string::{Wtf8Str, Wtf8String, Wtf16Str, Wtf16String, WtfStr, WtfString}; + +// The crate's markdown documentation is compiled as doctests, so an example that +// a contract change invalidates breaks the build instead of quietly teaching the +// old answer. `cfg(doctest)` means these items exist only while rustdoc collects +// tests, so they cost an ordinary build nothing. +#[cfg(all(doctest, windows))] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; From e00f1ea3dbacedb2da31d60d71f069b0b95313ed Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 23:02:00 -0400 Subject: [PATCH 294/361] fix(topology): qualify four intra-doc links that dangled from domain.rs The rustdoc CI job denies broken intra-doc links workspace-wide. domain.rs referenced MachineMemoryTopology::shard_set, ::discover, ::memory_domain_of and CpuSet::allocated_to_target_process without those types being in scope there, so all four dangled. Qualified with crate:: paths. Verified with the job's exact invocation, including its deny flags. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/src/domain.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index 446f04ac..2999b636 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -618,7 +618,7 @@ mod serde_impl { /// Everything this crate knows about one processor, with each absence named. /// -/// Produced by [`MachineMemoryTopology::shard_set`]. Deliberately **not** a +/// Produced by [`MachineMemoryTopology::shard_set`](crate::MachineMemoryTopology::shard_set). Deliberately **not** a /// second copy of [`Processor`]: that type is the platform's own record, while /// this is the assembled answer to "may this processor host work, and where /// does it allocate from" -- gathered from both Win32 sources plus the derived @@ -663,7 +663,7 @@ pub struct ProcessorFacts<'a> { /// /// [`Observed::NotObserved`] when the CPU-set enumeration was not /// consulted, which is any topology not produced by - /// [`MachineMemoryTopology::discover`]. Parked is **not** offline: the + /// [`MachineMemoryTopology::discover`](crate::MachineMemoryTopology::discover). Parked is **not** offline: the /// processor is active and the scheduler is merely avoiding it. pub parked: Observed, /// Whether this processor is allocated to *this* process. @@ -678,11 +678,11 @@ pub struct ProcessorFacts<'a> { /// /// It also does not mean what its name suggests -- allocation is the /// explicit `SetProcessDefaultCpuSets` kind, not "may we run here". Do not - /// branch on it; see [`CpuSet::allocated_to_target_process`]. + /// branch on it; see [`CpuSet::allocated_to_target_process`](crate::CpuSet::allocated_to_target_process). pub allocated_to_this_process: Observed, /// The memory domain this processor allocates from, or /// [`Observed::NotObserved`] for the **unplaced** case, which has no honest - /// fallback -- see [`MachineMemoryTopology::memory_domain_of`]. + /// fallback -- see [`MachineMemoryTopology::memory_domain_of`](crate::MachineMemoryTopology::memory_domain_of). pub memory_domain: Observed<&'a Domain>, } From b2e5603cf8796967486d7e73f7d282d14d95491b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 23:14:15 -0400 Subject: [PATCH 295/361] fix(topology): three defects from the PR #56 Copilot review **Cores keyed by `(group, core_index)`, not the index alone.** `SYSTEM_CPU_SET_INFORMATION::CoreIndex` is documented as relative to the record's `Group`, so on a multi-group host group 0 core 0 and group 1 core 0 are different cores that share an index. The fold merged them into one cross-group `Core` domain, corrupting membership and the efficiency class derived from it. Sabotage-verified: reverting the key yields a single `Core` whose processors span `groups: {0: 3, 1: 3}`. The observation label is now taken from the records rather than from the grouping key, so it stays the source's own group-relative index even though the key has to carry more than the label to be unique. **`outermost_partitioning_cache` no longer invents an outermost partition.** Two candidates can be *incomparable* rather than identical -- `{0,1}/{2,3}` and `{0,2}/{1,3}` each survive the disjointness filter, neither refines the other, and they are not the same partition. `max_by_key` answered with the higher-numbered level, which is exactly the level ordering M2+.2 forbids, in the one case where it changes the answer. The level tie-break now applies only where every maximal candidate describes the same partition; incomparable maxima return `None`, which per D-13 the caller already handles. **A record too short for its relationship body is now recorded.** `decode_body` returned `(None, None)`, silently dropping it -- which contradicts the D-24 contract the same milestone introduced, that an undecodable record is an observation rather than a silent truncation. It now reports `Undersized` with the relationship's own fixed-body minimum. The existing test asserted only that no record was emitted, which silent dropping satisfies just as well; that is why the defect survived. It now asserts the anomaly too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/src/records.rs | 8 ++ crates/windows-topology-sys/src/topology.rs | 64 ++++++++++-- .../src/topology/tests.rs | 97 +++++++++++++++++++ crates/windows-topology-sys/src/walk.rs | 37 ++++++- crates/windows-topology-sys/src/walk/tests.rs | 10 ++ 5 files changed, 206 insertions(+), 10 deletions(-) diff --git a/crates/windows-topology-sys/src/records.rs b/crates/windows-topology-sys/src/records.rs index 9d960681..58f2ff99 100644 --- a/crates/windows-topology-sys/src/records.rs +++ b/crates/windows-topology-sys/src/records.rs @@ -40,6 +40,14 @@ pub(crate) struct Record { } impl Record { + /// This record's declared length in bytes. + /// + /// Used when reporting a record too short for the body its relationship + /// names: the declared length is half of what makes that anomaly legible. + pub(crate) fn size(self) -> usize { + self.size + } + /// This record's byte offset within the buffer, for reporting where an /// anomaly was found. pub(crate) fn offset(self) -> usize { diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index dc1bfe19..734810e0 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -166,7 +166,16 @@ impl MachineMemoryTopology { /// the subject `M3+.1.4` covers rather than a reason to treat them as two /// relations. fn fold_in_cpu_sets(&mut self, cpu_sets: &[CpuSet]) { - let cores = Self::grouped_by(cpu_sets, |set| u32::from(set.core_index)); + // `CoreIndex` is documented as relative to the record's `Group`, so it + // is not unique across a multi-group machine: group 0 core 0 and group 1 + // core 0 are different cores that share an index. Keying on the index + // alone folded them into one cross-group `Core` domain, corrupting both + // core membership and the efficiency class derived from it. Raised in + // PR #56 review. `NumaNodeIndex` is *not* group-relative -- node numbers + // are machine-wide -- so it keys on itself. + let cores = Self::grouped_by(cpu_sets, |set| { + (u32::from(set.group) << 8) | u32::from(set.core_index) + }); let nodes = Self::grouped_by(cpu_sets, |set| u32::from(set.numa_node_index)); self.fold_memberships( @@ -188,6 +197,7 @@ impl MachineMemoryTopology { .max() .unwrap_or_default(), }, + |members| members.first().map_or(0, |set| u32::from(set.core_index)), ); self.fold_memberships( &nodes, @@ -195,8 +205,12 @@ impl MachineMemoryTopology { |_| DomainKind::Memory { memory_bytes: Observed::NotObserved, }, + |members| { + members + .first() + .map_or(0, |set| u32::from(set.numa_node_index)) + }, ); - // The attribute subject, which relation unification cannot reach: both // sources report an efficiency class for the same processor, and they // agree about the core while possibly disagreeing about this (D-18). @@ -295,14 +309,19 @@ impl MachineMemoryTopology { grouped: &BTreeMap>, is_kind: impl Fn(&DomainKind) -> bool, make_kind: impl Fn(&[&CpuSet]) -> DomainKind, + label_of: impl Fn(&[&CpuSet]) -> u32, ) { - for (&label, members) in grouped { + for members in grouped.values() { let mut processors = ProcessorSet::empty(); for set in members { processors.insert(set.group, set.logical_processor_index); } - let observation = Observation::new(Source::CpuSets, label); + // The label is the source's own, taken from the records rather than + // from the grouping key: those differ wherever the key has to carry + // more than the label to be unique, which is the group-relative + // `CoreIndex` case below. + let observation = Observation::new(Source::CpuSets, label_of(members)); match self .domains .iter_mut() @@ -621,23 +640,56 @@ impl MachineMemoryTopology { // than. Where two candidates describe the *same* partition -- identical // blocks under different levels, which is the ordinary case for an L1 // and L2 that split a machine the same way -- neither refines the other, - // so the tie is broken by taking the **higher level**. + // so the tie is broken by taking the **higher level**. Where they + // describe *different* partitions neither of which refines the other, + // there is no outermost one and the answer is `None`; see below. // // That tie-break reads the source's own labelling of one boundary and is // not the level ordering `M2+.2` forbids: distinct partitions are still // ordered by inclusion, and level decides only which of two names for // the identical partition is the outer one. - candidates + // Two candidates can be *incomparable* rather than identical: disjoint + // partitions `{0,1}/{2,3}` and `{0,2}/{1,3}` each survive the filter, + // neither refines the other, and they are not the same partition. There + // is then no outermost partitioning cache, and answering with the + // higher-numbered level would invent one -- reintroducing exactly the + // level ordering `M2+.2` forbids, in the one case where it changes the + // answer. `None` is the honest result, and per D-13 the caller already + // has to handle it. + let maximal: Vec<_> = candidates .iter() .filter(|candidate| { !candidates .iter() .any(|other| Self::refines(&candidate.1, &other.1)) }) + .collect(); + let first = maximal.first()?; + if !maximal + .iter() + .all(|other| Self::same_partition(&first.1, &other.1)) + { + return None; + } + maximal + .iter() .max_by_key(|(level, _)| *level) .map(|(level, blocks)| (*level, blocks.clone())) } + /// Whether two candidate partitions have exactly the same blocks. + /// + /// The identical case `refines` deliberately excludes: an L1 and an L2 that + /// split the machine the same way are one boundary under two names, which is + /// the ordinary case on real hardware and the only case the level tie-break + /// is allowed to decide. + fn same_partition(left: &[&Domain], right: &[&Domain]) -> bool { + left.len() == right.len() + && left + .iter() + .all(|block| right.iter().any(|o| o.processors == block.processors)) + } + /// Whether every block of `finer` sits inside some block of `coarser`, and /// the two are not the same partition. /// diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index d4196385..630b3954 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -1449,6 +1449,36 @@ fn the_usual_ordering_is_unchanged_where_the_two_rules_agree() { assert_eq!(blocks.len(), 2); } +#[test] +fn two_incomparable_partitions_have_no_outermost_one() { + // Raised in PR #56 review. The level tie-break is only allowed to decide + // between two names for the *same* boundary. Here L2 and L3 partition the + // same eight processors *differently* -- {0,1}/{2,3}/{4,5}/{6,7} against + // {0,2}/{1,3}/{4,6}/{5,7} -- so neither refines the other and they are not + // the same partition. There is no outermost partitioning cache, and + // answering "L3" because 3 > 2 would invent one, which is the level + // ordering M2+.2 forbids in the one case where it changes the answer. + let topo = machine_of( + 8, + vec![ + cache_at(2, 0, &[0, 1]), + cache_at(2, 1, &[2, 3]), + cache_at(2, 2, &[4, 5]), + cache_at(2, 3, &[6, 7]), + cache_at(3, 4, &[0, 2]), + cache_at(3, 5, &[1, 3]), + cache_at(3, 6, &[4, 6]), + cache_at(3, 7, &[5, 7]), + ], + ); + + assert_eq!( + topo.outermost_partitioning_cache().map(|(level, _)| level), + None, + "no partition is outermost, so none may be named" + ); +} + #[test] fn a_level_that_partitions_nothing_is_still_never_a_candidate() { // A fully shared cache is one block, so it cannot be the boundary however @@ -1650,3 +1680,70 @@ fn a_domain_covering_nothing_is_not_a_partition() { "a level whose only second domain covers nothing does not divide the machine" ); } + +#[test] +fn cpu_sets_in_different_groups_sharing_a_core_index_are_not_folded_together() { + // Raised in PR #56 review. `SYSTEM_CPU_SET_INFORMATION::CoreIndex` is + // documented as relative to the record's `Group`, so on a multi-group host + // group 0 core 0 and group 1 core 0 are *different* cores that share an + // index. Keying the fold on the index alone merged them into one + // cross-group `Core` domain, which corrupts both membership and the + // efficiency class derived from it. + let mut topology = MachineMemoryTopology::default(); + // Both groups have a core whose group-relative index is 0, each with two + // logical processors. That is the collision: four records, two cores. + let sets = vec![ + cpu_set_in(0, 0, 0, 0, 0), + cpu_set_in(0, 1, 0, 0, 0), + cpu_set_in(1, 0, 0, 0, 0), + cpu_set_in(1, 1, 0, 0, 0), + ]; + topology.fold_in_cpu_sets(&sets); + + let cores: Vec<_> = topology + .domains + .iter() + .filter(|d| matches!(d.kind, DomainKind::Core { .. })) + .collect(); + + assert_eq!( + cores.len(), + 2, + "one core per group, not one shared: {cores:?}" + ); + for core in &cores { + assert_eq!( + core.processors.len(), + 2, + "each core holds only its own group's processors: {core:?}" + ); + let groups: Vec = core.processors.iter().map(|(group, _)| group).collect(); + assert!( + groups.windows(2).all(|w| w[0] == w[1]), + "a core must not span groups: {groups:?}" + ); + } + // The label stays the source's own group-relative index, not the key. + let labels: Vec = cores + .iter() + .flat_map(|c| c.observations.iter().map(|o| o.label)) + .collect(); + assert!( + labels.iter().all(|&l| l == 0), + "both cores are index 0 in their own group: {labels:?}" + ); +} + +/// As [`cpu_set`], but places the record in a named group. +fn cpu_set_in( + group: u16, + index: u8, + core: u8, + node: u8, + efficiency_class: u8, +) -> crate::cpu_set::CpuSet { + crate::cpu_set::CpuSet { + group, + ..cpu_set(index, core, node, efficiency_class) + } +} diff --git a/crates/windows-topology-sys/src/walk.rs b/crates/windows-topology-sys/src/walk.rs index 70d33667..ef0e2552 100644 --- a/crates/windows-topology-sys/src/walk.rs +++ b/crates/windows-topology-sys/src/walk.rs @@ -347,7 +347,10 @@ unsafe fn read_processor_body( )) })() }) else { - return (None, None); + return ( + None, + body_too_short(record, body!(PROCESSOR_RELATIONSHIP, GroupMask)), + ); }; // SAFETY: forwarded from the caller. let (group_masks, complete) = unsafe { @@ -368,6 +371,23 @@ unsafe fn read_processor_body( ) } +/// A record whose declared `Size` covers the generic header but not the fixed +/// body of the relationship it names. +/// +/// Reported rather than dropped: per [D-24](../DESIGN-NOTES.md#d-24) a record +/// that cannot be decoded is an observation, and "too short for the body it +/// claims" is exactly that. `minimum` is the offset at which the relationship's +/// trailing array begins -- i.e. the smallest `Size` that could hold every +/// fixed field the body reader needs. +fn body_too_short(record: RawRecord, minimum: usize) -> Option { + Some(EnumerationAnomaly::undersized( + Source::RelationshipWalk, + record.offset(), + record.size(), + minimum, + )) +} + /// The anomaly for a trailing array that claimed more than the record held. fn truncation( record: RawRecord, @@ -406,7 +426,10 @@ unsafe fn read_cache_body(record: RawRecord) -> (Option, Option (Option, Option (Option, Option(body!(GROUP_RELATIONSHIP, ActiveGroupCount)) }) else { - return (None, None); + return ( + None, + body_too_short(record, body!(GROUP_RELATIONSHIP, GroupInfo)), + ); }; // SAFETY: forwarded from the caller; bounded by the record, so an // `ActiveGroupCount` larger than the record can hold yields only what fits. diff --git a/crates/windows-topology-sys/src/walk/tests.rs b/crates/windows-topology-sys/src/walk/tests.rs index 6ffe5ea8..9f603d25 100644 --- a/crates/windows-topology-sys/src/walk/tests.rs +++ b/crates/windows-topology-sys/src/walk/tests.rs @@ -459,11 +459,21 @@ fn a_record_too_short_for_its_body_yields_no_record_rather_than_a_neighbours_byt record.truncate(UNION_OFFSET); let records = decode_records(record.as_ptr(), record.len() as u32); + let anomalies = decode_anomalies(record.as_ptr(), record.len() as u32); assert!( records.is_empty(), "no body fits, so no record is invented: {records:?}" ); + // The half this test originally missed. Asserting only that no record was + // emitted is satisfied just as well by silently dropping it, which is what + // the code did -- and what D-24 says it must not do. + assert_eq!(anomalies.len(), 1, "the drop is recorded, not silent"); + assert!( + matches!(anomalies[0].kind, crate::AnomalyKind::Undersized { .. }), + "{:?}", + anomalies[0] + ); } #[test] From 5360035cd0b2bcea1da47aed9651f2b9363cf2b9 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 23:21:57 -0400 Subject: [PATCH 296/361] fix(topology): name the current process for CPU sets, and stop claiming non-Windows support Two more from the PR #56 Copilot review. GetSystemCpuSetInformation's Process argument is documented as the process used to compute AllocatedToTargetProcess; a null handle means no allocation check is made, not 'ask about the caller'. The code passed null under a comment claiming the opposite, so that flag could never have been set by this crate. Both calls now name GetCurrentProcess(). D-23's measurement stands -- it covered a real OpenProcess handle, and parked/allocated/real_time do not depend on the argument at all -- but it is amended, because a second and entirely separate reason for the same wrong answer was hiding behind the first. The crate also claimed to build to an empty shell on other platforms. It does not: mod cpu_set was ungated while using windows_sys, so the crate cannot compile off Windows at all, and CI never built it there. The gating is now consistent and the claim corrected to Windows-only. Records a load-sensitive flake in windows-ioring-sys' flush_barrier test, seen once in a full-workspace run and green in isolation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../UNRESOLVED-TEST-FAILURES.md | 16 +++++++++++++- crates/windows-topology-sys/Cargo.toml | 3 +++ crates/windows-topology-sys/DESIGN-NOTES.md | 19 ++++++++++++++++ crates/windows-topology-sys/README.md | 13 ++++++----- crates/windows-topology-sys/src/cpu_set.rs | 22 ++++++++++++++----- crates/windows-topology-sys/src/lib.rs | 14 ++++++++++-- 6 files changed, 73 insertions(+), 14 deletions(-) diff --git a/crates/windows-ioring-sys/UNRESOLVED-TEST-FAILURES.md b/crates/windows-ioring-sys/UNRESOLVED-TEST-FAILURES.md index abf45c5f..d47693d6 100644 --- a/crates/windows-ioring-sys/UNRESOLVED-TEST-FAILURES.md +++ b/crates/windows-ioring-sys/UNRESOLVED-TEST-FAILURES.md @@ -4,4 +4,18 @@ Pre-existing failures that do not block an unrelated commit, recorded per the re checklist-execution rules. When one is resolved, move its entry into a sibling [RESOLVED-TEST-FAILURES.md](RESOLVED-TEST-FAILURES.md) (append-only) rather than deleting it. -None currently. +## `flush_barrier::a_covering_flush_waits_for_preceding_writes_and_an_unordered_one_does_not` + +**Flaky under a full-workspace run, green in isolation.** Observed once during +`cargo test --workspace --all-features` on 2026-09-03 (2899 of 2900 passed); the +same test re-run on its own with `--test flush_barrier` passes. + +Not caused by the change that observed it, which touched +`windows-topology-sys` only. The test measures real I/O ordering, so it is +sensitive to load: a full workspace run has every other suite competing for the +disk, and the window this test asserts is a timing one. + +Recorded rather than fixed because the failure mode -- a load-sensitive +assertion in a real-I/O test -- needs a decision about whether the test should +be made load-independent or marked as serial, and that is not this change's +scope. It has not been seen to fail in CI. diff --git a/crates/windows-topology-sys/Cargo.toml b/crates/windows-topology-sys/Cargo.toml index 93a30f82..62f306dc 100644 --- a/crates/windows-topology-sys/Cargo.toml +++ b/crates/windows-topology-sys/Cargo.toml @@ -47,6 +47,9 @@ serde = ["dep:serde"] windows-sys = { version = "0.61.2", default-features = false, features = [ "Win32_Foundation", "Win32_System_SystemInformation", + # For `GetCurrentProcess`, which `cpu_set::enumerate` names explicitly so + # `AllocatedToTargetProcess` is computed against a process at all. + "Win32_System_Threading", ] } serde = { version = "1.0", features = ["derive"], optional = true } diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 731e9a0b..f9f92dc8 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -689,6 +689,25 @@ D-20 is a ruling about the crate's *scope*: this crate does not go below the Win fact only firmware reports is not one it carries at all. The field is deleted, and the capability the Linux comparison vindicated is knowingly given up. +### Amendment (PR #56 review): the shipping call passed a null process handle + +The measurement above stands -- it explicitly covered a real `OpenProcess` +handle, and `parked`, `allocated` and `real_time` do not depend on the process +argument at all, so an all-zero `AllFlags` is a fact about the build. + +But the review found that `cpu_set::enumerate` itself passed **null** for +`Process`, under a comment claiming a null handle "names this process". That +claim is wrong: Microsoft documents `Process` as the process used to compute +`AllocatedToTargetProcess`, so a null handle means **no allocation check is +made** rather than "ask about the caller". On a build that did populate the +byte, `allocated_to_target_process` would therefore have read `false` because of +how this crate called the API, not because of the machine -- a second, entirely +separate reason for the same wrong answer, hiding behind the first. + +The call now names `GetCurrentProcess()` explicitly. That changes nothing +observable here, which is the point: it makes the zero a fact about the build +rather than about the call. + ## D-24: one record walk, no panic, and incoherence as an observation The crate reads two variable-length record chains from Windows -- `GetLogicalProcessorInformationEx` diff --git a/crates/windows-topology-sys/README.md b/crates/windows-topology-sys/README.md index e6e21038..f47e5fcb 100644 --- a/crates/windows-topology-sys/README.md +++ b/crates/windows-topology-sys/README.md @@ -2,8 +2,10 @@ A refined view of the processor, cache, and memory topology Windows publishes. -**Windows only.** Every item is behind `cfg(windows)`; the crate builds to an -empty shell on other platforms. +**Windows only.** The crate does not build on other platforms, and is not +intended to: an earlier version of this line claimed it degraded to an empty +shell elsewhere, which was never true and was never built in CI (raised in +PR #56 review). ## Example @@ -72,9 +74,10 @@ indistinguishable from a measured one. ## Scope **What this is:** safe enumeration ([`MachineMemoryTopology::discover`]), plus a plain-data -description ([`MachineMemoryTopology`], [`Domain`]) that needs no Windows API to construct --- build one by hand, or (with the `serde` feature) deserialize one from JSON -written for a machine you do not have. +description ([`MachineMemoryTopology`], [`Domain`]) that needs no Windows API *call* to +construct -- build one by hand, or (with the `serde` feature) deserialize one +from JSON written for a machine you do not have. (A machine you do not have, +not a platform you are not on.) **What this is not:** a partitioning policy. It answers what the machine looks like, never which boundary you should shard on -- that depends on the workload, diff --git a/crates/windows-topology-sys/src/cpu_set.rs b/crates/windows-topology-sys/src/cpu_set.rs index 4873d35c..f2de4cc5 100644 --- a/crates/windows-topology-sys/src/cpu_set.rs +++ b/crates/windows-topology-sys/src/cpu_set.rs @@ -55,6 +55,7 @@ use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; use windows_sys::Win32::System::SystemInformation::{ CpuSetInformation, GetSystemCpuSetInformation, SYSTEM_CPU_SET_INFORMATION, }; +use windows_sys::Win32::System::Threading::GetCurrentProcess; /// One processor as the CPU-set API describes it. /// @@ -157,9 +158,17 @@ mod flags { /// Enumerate the CPU sets the current process can see. /// -/// A null process handle asks about the calling process. It makes **no -/// difference to the flags** on the build this was measured against -- see -/// [`CpuSet::allocated_to_target_process`] and D-23 in `DESIGN-NOTES.md`. +/// The current process is named explicitly, because `Process` is **not** +/// optional in the way a null handle suggests: Microsoft documents it as the +/// process used to compute `AllocatedToTargetProcess`, so passing null means no +/// allocation check is made rather than "ask about me". Raised in PR #56 +/// review, where an earlier comment here claimed the opposite. +/// +/// It makes no difference to the flags on the build this was measured against, +/// which read zero under a null handle, the pseudo-handle, and a real +/// `OpenProcess` handle alike -- see [`CpuSet::allocated_to_target_process`] and +/// D-23 in `DESIGN-NOTES.md`. Asking the documented question anyway is what +/// makes the zero a fact about the build rather than about the call. /// /// # Errors /// @@ -168,13 +177,14 @@ mod flags { pub(crate) fn enumerate() -> io::Result<(Vec, Option)> { let mut length: u32 = 0; // SAFETY: a null buffer with a zero length and a valid out-pointer, which is - // the documented sizing call. A null process handle names this process. + // the documented sizing call. `GetCurrentProcess` is a pseudo-handle needing + // no close, and is the documented way to ask about this process. let probe = unsafe { GetSystemCpuSetInformation( std::ptr::null_mut(), 0, &raw mut length, - std::ptr::null_mut(), + GetCurrentProcess(), 0, ) }; @@ -206,7 +216,7 @@ pub(crate) fn enumerate() -> io::Result<(Vec, Option buffer.cast(), length, &raw mut actual_length, - std::ptr::null_mut(), + GetCurrentProcess(), 0, ) }; diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index b8ff7c65..7a9a56d6 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -9,12 +9,18 @@ //! - **[`MachineMemoryTopology::discover`]** reads the running system's processor groups, //! cores, caches, and NUMA nodes safely, via //! [`GetLogicalProcessorInformationEx`][gpi]. -//! - **[`MachineMemoryTopology`]**, [`Domain`], and friends are plain data. They do not -//! need Windows to construct: build one by hand, or (with the `serde` +//! - **[`MachineMemoryTopology`]**, [`Domain`], and friends are plain data. They need no +//! Windows *API call* to construct: build one by hand, or (with the `serde` //! feature) deserialize one from JSON written for a machine you do not //! have. See [`examples/print_topology.rs`] for the shape a description //! takes. //! +//! That is a claim about not calling the platform, **not** about other +//! platforms: this crate is Windows-only and does not build elsewhere. An +//! earlier version of these docs said it degraded to an empty shell on other +//! targets, which was never true and never built in CI -- raised in PR #56 +//! review. +//! //! [gpi]: https://learn.microsoft.com/windows/win32/api/sysinfoapi/nf-sysinfoapi-getlogicalprocessorinformationex //! [`examples/print_topology.rs`]: https://github.com/MikeGrier/windows-threadpool-sys/blob/main/crates/windows-topology-sys/examples/print_topology.rs //! @@ -63,6 +69,7 @@ #[cfg(windows)] mod anomaly; +#[cfg(windows)] mod cpu_set; #[cfg(windows)] mod domain; @@ -77,6 +84,7 @@ mod processor_set; /// Where a topology's content came from. mod provenance; +#[cfg(windows)] mod records; #[cfg(windows)] mod relation; @@ -87,6 +95,7 @@ mod walk; #[cfg(windows)] pub use anomaly::{AnomalyKind, EnumerationAnomaly}; +#[cfg(windows)] pub use cpu_set::CpuSet; #[cfg(windows)] pub use domain::{AttributeValue, Domain, DomainKind, Processor, ProcessorFacts, ProcessorId}; @@ -95,6 +104,7 @@ pub use granularity::{Granularity, Proximity}; #[cfg(windows)] pub use observation::{AttributeObservation, Observation, ProcessorAttribute, Source}; pub use observed::Observed; +#[cfg(windows)] pub use processor_set::ProcessorSet; pub use provenance::Provenance; #[cfg(windows)] From 25f1c997229166efdb88ed0c37d7258ae11ae21f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 23:28:29 -0400 Subject: [PATCH 297/361] docs: state a tested Windows baseline for windows-topology-sys The Availability section claimed Vista / Server 2008 because GetLogicalProcessorInformationEx is documented that far back, but discover() also calls GetSystemCpuSetInformation -- documented from Windows 10 / Server 2016 and imported statically, so a down-level system fails to load the process rather than getting a poorer answer. Raised in PR #56 review. Per the engineer's ruling the floor is stated at Windows 11 / Server 2025: what is tested, rather than the oldest version the imports would technically permit, because an untested floor is a guess presented as a guarantee. Server 2025 is the server release built on the Windows 11 codebase -- Server 2022 is not, despite the adjacent numbering. Scoped to a crate this PR releases. The same review is queued for the other crates as SH-4.4, which also records the two traps found here: a statement about when an API appeared is not a baseline claim, and the Server pairing is 2025 rather than 2022. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 16 +++++++++++++ crates/windows-topology-sys/README.md | 7 ++++++ crates/windows-topology-sys/src/lib.rs | 23 +++++++++++++++---- .../src/slotwise_mpsc.rs | 2 +- crates/windows-waitable-queues/src/spsc.rs | 2 +- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 7d510bda..f4fc66e8 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -475,6 +475,22 @@ that previously stood in the way are gone: is no gate to lift and no bullet to edit. The tool's GitHub binaries never waited on this. Blocked by SH-1.1, and by M31.6 as well if SH-1.2 decided that it gates. +- [ ] **SH-4.4** -- **State the supported Windows baseline in the crates this PR did not release.** + `windows-topology-sys` was corrected in PR #56 (it claimed Vista / Server 2008 while statically + importing a Windows 10 API), and the engineer''s ruling is that a crate should claim **Windows 11 + and the matching server release** -- the floor that is *tested*, not the oldest the APIs might + work on. **Scoped deliberately to the releasing crates at the time**, so the rest are queued here + rather than changed under a PR that does not publish them. + To review, one crate at a time: `windows-threadpool-sys`, `windows-overlapped-io-sys`, + `windows-file-enumeration-sys`, `windows-impersonation-token-sys`, `windows-namespace-request-sys`, + `wtf-string`, and `windows-file-watcher`. + **Two traps found doing the first one.** A statement about *when an API appeared* is not a baseline + claim and must not be rewritten -- `windows-ioring-sys`'' "Windows 11 and Server 2022 added + `IoRing`" and `windows-file-watcher`''s "supported from Windows 10 version 1803 onward" are both + correct as API facts. And **Server 2022 is not the Windows 11 counterpart**: it is built on the + Windows 10 "Iron" codebase (build 20348), while Server 2025 shares Windows 11 24H2''s build 26100. + A crate claiming a Windows 11 floor pairs with **Server 2025**. + ## M5: verify from outside the workspace - [ ] **SH-5.1** -- In a scratch project **outside this repository**, depend on both crates from diff --git a/crates/windows-topology-sys/README.md b/crates/windows-topology-sys/README.md index f47e5fcb..5b0868db 100644 --- a/crates/windows-topology-sys/README.md +++ b/crates/windows-topology-sys/README.md @@ -2,6 +2,13 @@ A refined view of the processor, cache, and memory topology Windows publishes. +**Windows 11 / Windows Server 2025 and later.** That floor is what is *tested*, +not the oldest version the APIs might work on: `discover` calls +`GetSystemCpuSetInformation`, which is documented only from Windows 10 / +Server 2016 and is imported statically, and nothing below Windows 11 is +exercised here. (Server 2025 is the server release built on the Windows 11 +codebase; Server 2022 is not, despite the adjacent version numbers.) + **Windows only.** The crate does not build on other platforms, and is not intended to: an earlier version of this line claimed it degraded to an empty shell elsewhere, which was never true and was never built in CI (raised in diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 7a9a56d6..79b7493a 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -49,10 +49,25 @@ //! //! # Availability //! -//! `GetLogicalProcessorInformationEx` is documented back to Windows Vista / -//! Server 2008, so [`MachineMemoryTopology::discover`] works on every version this -//! repository's shared baseline supports; nothing here is gated on a runtime -//! capability probe the way `windows-ioring-sys` needs one. +//! **Windows 11 / Windows Server 2025 and later.** +//! +//! That is the floor this crate claims, and it is a claim about what is +//! *tested* rather than the oldest version the APIs might work on. +//! `GetLogicalProcessorInformationEx` is documented back to Vista, and an +//! earlier version of this section said so -- but [`MachineMemoryTopology::discover`] +//! also calls `GetSystemCpuSetInformation`, which is documented only from +//! Windows 10 / Server 2016, and this crate imports it statically. A down-level +//! system would therefore fail to *load* the process, not merely get a poorer +//! answer. Raised in PR #56 review. +//! +//! The floor is stated at Windows 11 / Server 2025 rather than at the older +//! version the imports would technically permit, because nothing below that is +//! tested here and an untested floor is a guess presented as a guarantee. +//! Server 2025 is the server release built on the Windows 11 codebase; Server +//! 2022 is not, despite the adjacent version numbers. +//! +//! Nothing here is gated on a runtime capability probe the way +//! `windows-ioring-sys` needs one. //! //! # The JSON schema is not semver-covered //! diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index f06fc795..ed6241a4 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -159,7 +159,7 @@ const BOUNDS: Bounds = Bounds { /// # Errors /// /// Returns [`CapacityError`] if `capacity` is zero, is not a power of two, is -/// less than two, or exceeds [`usize::MAX`] / 2. +/// less than two, or exceeds `2^(usize::BITS - 2)`. /// /// # Examples /// diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index e243ab61..d919125b 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -106,7 +106,7 @@ const BOUNDS: Bounds = Bounds { /// # Errors /// /// Returns [`CapacityError`] if `capacity` is zero, is not a power of two, or -/// exceeds [`usize::MAX`] / 2. +/// exceeds `2^(usize::BITS - 2)`. /// /// # Examples /// From 75e230ce7e78d542cc7bc70d5614c6cd0423b879 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 23:34:18 -0400 Subject: [PATCH 298/361] docs: queue four findings from the PR #56 review that need decisions SH-4.4 baseline sweep for the crates this PR does not release; SH-4.5 propagate enumeration incompleteness into the placement probe's trust state, which is a versioned-schema decision rather than a one-line guard; SH-4.6 rule on whether the experimental permit-claim feature may be published at all, where the reviewer is technically right that a public feature is observable API no documentation can exempt; SH-4.7 two permit_mpsc findings, one of which directly contradicts an earlier review round's conclusion about its memory ordering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 38 +++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index f4fc66e8..5c29b056 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -491,6 +491,44 @@ that previously stood in the way are gone: Windows 10 "Iron" codebase (build 20348), while Server 2025 shares Windows 11 24H2''s build 26100. A crate claiming a Windows 11 floor pairs with **Server 2025**. +- [ ] **SH-4.5** -- **Propagate enumeration incompleteness into the placement probe''s trust state.** + Raised in PR #56 review. `places_from_topology` ignores + `MachineMemoryTopology::enumeration_anomalies`, and `SubmissionRecord::is_fully_trusted` gates only + on `build.is_official()` and two `Provenance::Measured` checks -- which a `discover()` result + satisfies even when records were dropped. A truncated enumeration can therefore be filed as a + **trustworthy small machine**, which is the one thing a measurement tool must not do. + **Deliberately not rushed into PR #56**: the honest fix records the anomaly count *in the record* + and gates trust on it, and the record carries a **versioned schema** (see the `collapse the record + schema back to v1` commit), so this is a schema decision rather than a one-line guard. Downgrading + `Provenance` instead would be wrong -- per [D-22](crates/windows-topology-sys/DESIGN-NOTES.md#d-22) + provenance records how the object was *obtained*, not how complete it is. + The gap is newly reachable because `enumeration_anomalies` itself is new in this PR; before it, + there was nothing to propagate. + +- [ ] **SH-4.6** -- **Rule on whether `experimental-permit-claim` may be published at all.** + Raised in PR #56 review, and the reviewer is **technically right**: a public Cargo feature and its + `pub mod` are observable API even when non-default, so a consumer can enable the feature and depend + on `permit_mpsc`. Deleting it in a later compatible release would break that build, and no amount + of documentation exempts it from semver -- which is exactly what `SH-15.6` currently plans to do. + Three options, and this is the engineer''s call: commit to compatibility for the published feature; + move the experiment behind `#[doc(hidden)]` and a `cfg` that is not a Cargo feature, so it is not + observable API; or keep it out of the published crate entirely and measure it from a path + dependency. The disclosure in the module docs is honest but does not settle the semver question. + +- [ ] **SH-4.7** -- **Two `permit_mpsc` findings from the PR #56 review, one of which contradicts a + prior review.** Both are in the experimental module, so neither blocks the release, and both should + be settled before `SH-15.6` decides the module''s fate. + **Contract:** `Reservation::send` publishes unconditionally even when the consumer is already + dropped, so the caller gets no indication and the item is discarded at teardown -- where + `reserving_mpsc::Reservation::send` returns `Disconnected` with the item. The module claims only + the *admission* protocol differs, so this is a divergence it does not disclose. + **Memory ordering:** the reviewer argues the relaxed ticket operation gives no acquire edge from + the consumer''s most recent `release_permit`, leaving the slot write unordered against that read. + **A prior review round concluded the opposite** -- that a thread holding a permit always has + `tail - head <= capacity - 1` at its `fetch_add`, so the slot it claims was freed by a + `release_permit` preceding its `fetch_sub` in modification order, with the release sequence + supplying the edge. One of the two is wrong; settle it with an argument written down, not a patch. + ## M5: verify from outside the workspace - [ ] **SH-5.1** -- In a scratch project **outside this repository**, depend on both crates from From b5b778ea95d9704a640446e36f5e9a609aebf7fb Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 23:37:53 -0400 Subject: [PATCH 299/361] fix(tools): stop inject-mutant reporting a non-compiling mutant as caught Raised in PR #56 review. Every non-zero cargo exit was classified as 'caught', including a replacement that does not compile -- in which case no test ran at all, so the suite is credited with a detection it never made. That inflates mutation evidence exactly when source drift has made the manifest stale, which is when it is least likely to be questioned. Invoke-Suite now inspects the captured output and returns 'unviable' for a compilation failure, which reports as '!!! UNVIABLE !!! (did not compile -- no test ran, so this proves nothing)' and counts against the exit code rather than for it. Same distinction run-sabotage.ps1 already draws. Verified both directions: 'pub fn' -> 'pub zzz' now reports UNVIABLE where it previously said caught, and a compiling mutant that breaks a test still reports caught. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tools/inject-mutant.ps1 | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/tools/inject-mutant.ps1 b/tools/inject-mutant.ps1 index 359d965c..b85aee06 100644 --- a/tools/inject-mutant.ps1 +++ b/tools/inject-mutant.ps1 @@ -115,9 +115,24 @@ function Invoke-Suite { -PassThru -NoNewWindow -RedirectStandardOutput $out -RedirectStandardError "$out.err" try { if ($proc.WaitForExit($Seconds * 1000)) { - $outcome = if ($proc.ExitCode -eq 0) { 'passed' } else { 'failed' } - return [pscustomobject]@{ Outcome = $outcome; Code = $proc.ExitCode } - } + if ($proc.ExitCode -eq 0) { + return [pscustomobject]@{ Outcome = 'passed'; Code = 0 } + } + # A non-zero exit is not evidence the tests killed the mutant: a + # replacement that does not compile also exits non-zero, and then no + # test ran at all. Reporting that as `caught` credits the suite with + # a detection it never made -- the same unviable-vs-caught + # distinction `run-sabotage.ps1` draws. Raised in PR #56 review. + $text = @( + (Get-Content -LiteralPath $out -Raw -ErrorAction SilentlyContinue), + (Get-Content -LiteralPath "$out.err" -Raw -ErrorAction SilentlyContinue) + ) -join "`n" + $outcome = if ($text -match 'could not compile|(?m)^error\[E\d+\]') { + 'unviable' + } else { + 'failed' + } + return [pscustomobject]@{ Outcome = $outcome; Code = $proc.ExitCode } } Get-CimInstance Win32_Process -Filter "ParentProcessId=$($proc.Id)" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue @@ -210,9 +225,16 @@ foreach ($target in $targets) { # leaving the file truncated -- still reaches the restoring `finally`. [System.IO.File]::WriteAllText($path, (($mutated -join "`n") + "`n"), $utf8NoBom) $run = Invoke-Suite -Repository $repo -CargoArgs $cargoArgs -Seconds $TimeoutSeconds - $verdict = if ($run.Outcome -eq 'passed') { '*** SURVIVED ***' } else { 'caught' } - $detail = if ($run.Outcome -eq 'hung') { "HUNG past ${TimeoutSeconds}s" } else { "exit $($run.Code)" } - } + $verdict = switch ($run.Outcome) { + 'passed' { '*** SURVIVED ***' } + 'unviable' { '!!! UNVIABLE !!!' } + default { 'caught' } + } + $detail = switch ($run.Outcome) { + 'hung' { "HUNG past ${TimeoutSeconds}s" } + 'unviable' { 'did not compile -- no test ran, so this proves nothing' } + default { "exit $($run.Code)" } + } } finally { [System.IO.File]::WriteAllText($path, (($original -join "`n") + "`n"), $utf8NoBom) if ((Get-Content -LiteralPath $path -Raw) -ne (($original -join "`n") + "`n")) { @@ -222,8 +244,10 @@ foreach ($target in $targets) { } } - $level = if ($verdict -eq 'caught') { 'good' } else { $survivors++; 'bad' } - Write-Report ("{0}:{1} '{2}' -> '{3}' {4} ({5})" -f ` + # An unviable mutant counts as `bad` for exit purposes: it is an + # inconclusive result, not a pass, and treating it as one would hide the + # source drift that produced it. + $level = if ($verdict -eq 'caught') { 'good' } else { $survivors++; 'bad' } Write-Report ("{0}:{1} '{2}' -> '{3}' {4} ({5})" -f ` $File, $target.Line, $Find, $Replace, $verdict, $detail) -Level $level } From 9b80cbc81f8cb3d4ab87bc2f377ea673f2986629 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Thu, 3 Sep 2026 23:42:06 -0400 Subject: [PATCH 300/361] docs: queue the remaining PR #56 review findings (SH-4.8, SH-4.9) All 31 unresolved review threads are replied to and closed. The findings that were not fixed in this PR are recorded here rather than left in comment threads, so none depends on someone re-reading the PR. SH-4.8 leads with the memory-safety one: a file-watcher test returns early on ERROR_IO_PENDING -- a successful overlapped submission -- dropping buffers the queued IRP may still write through. It also carries the reserving_mpsc ordering dispute, which contradicts an earlier review round and must be settled by argument rather than by strengthening the ordering to be safe. SH-4.9 groups the three check-publishable.ps1 findings under their shared root: text searches standing in for structural facts, which is how a check goes quietly vacuous. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 36 +++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 5c29b056..25cc4766 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -529,6 +529,42 @@ that previously stood in the way are gone: `release_permit` preceding its `fetch_sub` in modification order, with the release sequence supplying the edge. One of the two is wrong; settle it with an argument written down, not a patch. +- [ ] **SH-4.8** -- **Six more PR #56 review findings, none blocking the release.** Replied to and + resolved on the PR; recorded here so none is lost. + **Memory safety in a test (do first).** `reopen_by_id_cannot_be_watched.rs:153` returns early on a + zero return with `ERROR_IO_PENDING` -- a *successful* overlapped submission -- dropping `buffer` + and `overlapped` while the queued IRP may still write through both. Production + `classify_submission` handles this case; the test does not. Wait for or cancel the operation + before dropping. + **`reserving_mpsc`''s `head` acquire load** (`reserving_mpsc.rs:608`), which **contradicts an + earlier review round** that called it the only acquire edge `Reservation::send`''s non-atomic slot + write has. This is a shipping shape, so settle it with a written argument naming the execution and + the edge -- do not strengthen the ordering to be safe, which hides whichever model is wrong. + **Overlapping domains resolved by iteration order**: `memory_domain_of` returns the first match and + the core map lets a later domain replace an earlier one. For hand-built and deserialized + topologies -- which the API explicitly accepts -- an overlap is an *ambiguity*, and + `memory_domain_of` is the sharper case because its value reaches `VirtualAllocExNuma`. + **A live-host test asserting cross-API agreement** (`cpu_set/tests.rs:242`) contradicts the model''s + premise that CPU Sets may disagree with the walk; it is a latent failure on untried hardware. + Assert that both observations are *recorded*, not that they agree. + **`release-placement-probe.yml` gating**: `workflow_dispatch` against an existing release tag + satisfies the tag-prefix condition, so a build-only run can create or modify a release. Add + `github.event_name == ''push''`, and verify both paths rather than reading the change. + **`queue_contention.rs:241`** starts its clock without ordering against workers entering their + loops, so a descheduled coordinator under-reports the baseline -- the optimistic direction, in a + probe whose numbers are quoted as evidence. + +- [ ] **SH-4.9** -- **`tools/check-publishable.ps1`: three findings with one root.** Its checks are + **text searches standing in for structural facts**, which is how a check goes quietly vacuous. + An unanchored pattern is satisfied by a *commented-out* assignment, so CI would believe the + dependency registry exists while the shell never defines it -- re-creating the publish race the + check exists to prevent. The trigger/choice searches scan every YAML list rather than + `on.push.tags` and `on.workflow_dispatch.inputs.crate.options`, so an identical entry in a matrix + keeps them green after the real trigger is removed. And the description still says the placement + probe is "not on crates.io yet", implying a registry publication that `publish = false` ruled out. + Fix together by parsing the workflow structure rather than adding anchors until the next false + green. + ## M5: verify from outside the workspace - [ ] **SH-5.1** -- In a scratch project **outside this repository**, depend on both crates from From 931d8a41b419861d5c96bbb7068d55355c7128c0 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 09:40:28 -0400 Subject: [PATCH 301/361] test(file-watcher): treat ERROR_IO_PENDING as a queued read, not a failure `read_directory_changes_accepted` returned early whenever `ReadDirectoryChangesW` returned zero, treating every zero return as a failed submission. A zero return with `ERROR_IO_PENDING` is a *queued* read: the IRP is outstanding against this frame's `overlapped` and the function's `buffer`, and returning there dropped both while the kernel still held them. That is a use-after-free, and it is the exact hazard the cancel-and-wait further down the function exists to prevent. Verified against the production reader rather than reasoned about: the crate's own `classify_submission` treats `returned != 0` *or* `ERROR_IO_PENDING` as `Issued::Pending`, so the test was disagreeing with the code it exists to check. Test-only: no library behaviour changes, so this is deliberately typed `test` rather than `fix` -- `windows-file-watcher` has no source change here and must not take a release bump from it. Raised in the PR #56 review; part of SH-4.8. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../tests/reopen_by_id_cannot_be_watched.rs | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs b/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs index ba9f57b8..f95789ed 100644 --- a/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs +++ b/crates/windows-file-watcher/tests/reopen_by_id_cannot_be_watched.rs @@ -23,8 +23,8 @@ use std::path::Path; use std::ptr; use windows_sys::Win32::Foundation::{ - CloseHandle, ERROR_INVALID_PARAMETER, ERROR_OPERATION_ABORTED, GetLastError, HANDLE, - INVALID_HANDLE_VALUE, + CloseHandle, ERROR_INVALID_PARAMETER, ERROR_IO_PENDING, ERROR_OPERATION_ABORTED, GetLastError, + HANDLE, INVALID_HANDLE_VALUE, }; use windows_sys::Win32::Storage::FileSystem::{ BY_HANDLE_FILE_INFORMATION, CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OVERLAPPED, @@ -145,16 +145,27 @@ fn read_directory_changes_accepted(handle: HANDLE) -> Result<(), u32> { ) }; if ok == 0 { - // The call failed, so no IRP was queued and nothing is outstanding - // against `buffer` or `overlapped`; both may leave scope freely. - // // SAFETY: called immediately after the failing call above. - return Err(unsafe { GetLastError() }); + let error = unsafe { GetLastError() }; + // **A zero return with `ERROR_IO_PENDING` is a queued read, not a failed + // one**, which is exactly how the production `classify_submission` + // reads it: `returned != 0` *or* `ERROR_IO_PENDING` both mean + // `Issued::Pending`. Returning here on that error would drop `buffer` + // and `overlapped` with the IRP still outstanding against both -- the + // very use-after-free the cancel-and-wait below exists to prevent, and + // the one this crate has already paid for once. Raised in PR #56 + // review. + // + // Anything else really did fail: no IRP was queued, so nothing is + // outstanding and both locals may leave scope freely. + if error != ERROR_IO_PENDING { + return Err(error); + } } - // A nonzero return here means the read was *queued*, so an IRP is - // outstanding against this frame's `overlapped` and this function's - // `buffer`. + // The read was *queued* -- by a nonzero return, or by a zero return with + // `ERROR_IO_PENDING` -- so an IRP is outstanding against this frame's + // `overlapped` and this function's `buffer`. // // SAFETY: `handle` is live and this thread issued the read above. unsafe { CancelIo(handle) }; From 0b726b1f8b23ec3d25e86ad108f6d1f133fb24e3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 09:41:45 -0400 Subject: [PATCH 302/361] fix(waitable-queues): stop mixing relaxed with acquire/release on one atomic An atomic that carries any acquire/release operation now carries acquire/release on every operation. A relaxed operation is *unordered*: it is unanchored with respect to the ordered operations on the same object and free to be moved by the optimizer or the processor, so it is not pinned to its textual site and reading such a program in statement order is not a weak argument but no argument at all. Mixing the two disciplines on one location makes the source text stop describing what happens. This is invisible on the host that tests it. On x86-64 TSO a decorative `Acquire` and a `Relaxed` load emit near-identical code, which is the same blindness D-31 already measured; on AArch64 they are `ldar` and `ldr`, and CI builds `aarch64-pc-windows-msvc`. Audited every atomic in the crate, grouped by field. Four had acquire loads with no release write anywhere, so the acquires paired with nothing and synchronized with nothing -- `reserving_mpsc`'s claim word, `permit_mpsc`'s `tail` and `head`, and `slotwise_mpsc`'s `tail`, whose claim CAS is deliberately `Relaxed/Relaxed` and says so at the site. Those are now uniformly relaxed. Four had a real release store with relaxed loads mixed in -- `reserving_mpsc::head`, `slotwise_mpsc::head`, `spsc::head`, `spsc::tail` -- and those loads were promoted, per the standing tie-break: an unnecessary acquire is a benchmark someone can bring later, an unnecessary relaxed is a defect on hardware we do not own. `permit_mpsc`'s permit counter had a relaxed operation inside a genuinely load-bearing edge, where the `Release` increment frees a slot and the `Acquire` decrement claims it. It is now `Release` throughout, so the edge no longer rests on the release-sequence rule -- a rule narrowed once already, when C++20 dropped same-thread relaxed stores from it, and not something a reader should have to reconstruct to trust a slot handoff. `permit_mpsc`'s `Drop` moved to `get_mut()`, matching `reserving_mpsc`: through `&mut self` these are not atomic operations at all, which removes the question instead of answering it. The reference counts keep their relaxed increment against an `AcqRel` decrement, and the reason is now written down rather than inherited from `Arc`: no dependent memory is read on the strength of the increment, so the coherence effect is not required until the count reaches zero, where the `AcqRel` supplies it. Relaxed is a statement about ordering only, never a step toward removing an atomic. The claim word packs two `u32` halves into one `u64`, so a torn read would yield a `(reserved, position)` pair that was never a state the queue was in; on `i686-pc-windows-msvc`, which D-18 keeps supported, that load costs a `cmpxchg8b` rather than two `mov`s, and that cost is the point. Recorded as D-40 so "every operation here is relaxed" is not read as an invitation. Verification found a defect in this work rather than confirming it. Waiting for a fresh `head` in `publish` made `the_high_water_mark_never_exceeds_the_capacity` hang for 314s of CPU: it wrote an impossible `head` to force an over-report and restored it only after `send` returned, so the fixed code correctly refused to proceed. That test asserted a mitigation the fix makes unreachable, and is replaced by `publish_waits_for_a_head_that_has_freed_the_slot`, which asserts the guarantee. Sabotage-verified: with the wait cut back to a single load it fails in 0.05s with the message written for it. The high-water clamp is kept as documented defence-in-depth, with its unreachability recorded at the site so a mutation survivor there reads as unreachable code rather than a missing test. 308 lib, 8 doc and 1 integration test green; sabotage sweep reports all 39 behaving as declared, both CONTROL entries surviving, no manifest staleness. Completed item: SH-3.3.1: Finish and verify the memory-ordering discipline sweep Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 58 +++++++++-- .../windows-waitable-queues/DESIGN-NOTES.md | 98 +++++++++++++++++++ .../windows-waitable-queues/src/doorbell.rs | 7 +- .../src/permit_mpsc.rs | 50 ++++++++-- .../src/reserving_mpsc.rs | 97 +++++++++++++++--- .../src/reserving_mpsc/tests.rs | 73 +++++++++----- .../src/slotwise_mpsc.rs | 27 ++++- crates/windows-waitable-queues/src/spsc.rs | 29 ++++-- 8 files changed, 372 insertions(+), 67 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 25cc4766..8ffd42bb 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -321,6 +321,49 @@ that previously stood in the way are gone: cannot be confused. Each repair was re-run individually and **caught** before the full sweep, so the fix restored the check rather than merely restoring the match. +- [x] **SH-3.3.1** -- **Finish and verify the memory-ordering discipline sweep.** + **Done 2026-09-04: 308 lib + 8 doc + 1 integration green in 0.41s, and the sabotage sweep reports + all 39 behaving as declared** with both `CONTROL` entries surviving and no `MANIFEST STALE`. + **The verification found a real defect in this work.** Waiting for a fresh `head` made + `the_high_water_mark_never_exceeds_the_capacity` hang -- it wrote an impossible `head` + (`u32::MAX - 10`) to force an over-report and restored it only *after* `send` returned, so the new + wait spun for 314s of CPU rather than proceeding. The state that test constructs is one the fixed + code correctly refuses. It was replaced by `publish_waits_for_a_head_that_has_freed_the_slot`, + which asserts the guarantee instead of the mitigation, and which was **sabotage-verified**: with + the wait reduced back to a single load it fails in 0.05s with the message written for it. The + clamp is kept as documented defence-in-depth and is now unreachable by construction, which is + recorded at the site so a mutation run's survivor there is read as unreachable code rather than a + missing test. + **The rule, from the engineer, recorded as `D-38`/`D-39`/`D-40` in + [DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md):** acquire/release discipline is + never intermixed with relaxed on the same atomic. A relaxed operation is *unordered* -- unanchored + with respect to the ordered operations on that object and free to be moved by the optimizer or the + processor -- so it is not pinned to its textual site and statement-order reasoning about it is not a + weak argument but no argument. It is still fully **atomic**, and that half is load-bearing + independently (`D-40`): the claim word packs two `u32`s into a `u64`, so a torn read would yield a + state the queue was never in. **When the two repairs differ, promote the load** -- an unnecessary + acquire is a benchmark someone can bring later, an unnecessary relaxed is a defect on hardware we do + not own. + **Why no test here can see this:** on x86-64 TSO a decorative `Acquire` and a `Relaxed` load emit + nearly the same code, which is the same blindness `D-31` measured. On AArch64 they are `ldar` and + `ldr`, and CI builds `aarch64-pc-windows-msvc`. + **What changed** (audit output in `D-38`): four atomics had acquire loads with **no release write + anywhere**, so the acquires paired with nothing -- `reserving_mpsc`'s claim word, `permit_mpsc`'s + `tail` and `head`, `slotwise_mpsc`'s `tail`; those are now uniformly relaxed. Four had a real + release store with relaxed loads mixed in -- `reserving_mpsc::head`, `slotwise_mpsc::head`, + `spsc::head`, `spsc::tail`; those loads were promoted to acquire. `permit_mpsc`'s permit counter had + a relaxed operation inside a genuinely load-bearing edge and is now `Release`. `permit_mpsc`'s `Drop` + was converted to `get_mut()`, matching `reserving_mpsc`, which removes the question instead of + answering it. The `producers` refcounts are deliberately unchanged (`D-39`). + **Tooling note:** the cargo-mcp server's `cargo_test` was wedged for this item -- it accepted the + call and never spawned a cargo process (confirmed repeatedly via `Get-Process`), while + `cargo_check`, `cargo_fmt` and `cargo_clippy` worked normally on the same process, and + `cargo_test` with `no_run` also worked. So the failure is in the test-execution phase, not the + build phase, and not a stale binary: the running process, the installed extension, the VSIX and + the source repo are all `cargo-mcp` 0.12.1. The engineer authorised the terminal as a fallback + while diagnosing it. + **This also settles half of SH-4.7**, whose memory-ordering half is subsumed by the rule above. + - [ ] **SH-3.4** -- Merge to `main`, and confirm release-please raises a release PR proposing **0.2.0** for the topology crate. If it proposes 0.1.1, the breaking-change marker did not take and @@ -522,12 +565,15 @@ that previously stood in the way are gone: dropped, so the caller gets no indication and the item is discarded at teardown -- where `reserving_mpsc::Reservation::send` returns `Disconnected` with the item. The module claims only the *admission* protocol differs, so this is a divergence it does not disclose. - **Memory ordering:** the reviewer argues the relaxed ticket operation gives no acquire edge from - the consumer''s most recent `release_permit`, leaving the slot write unordered against that read. - **A prior review round concluded the opposite** -- that a thread holding a permit always has - `tail - head <= capacity - 1` at its `fetch_add`, so the slot it claims was freed by a - `release_permit` preceding its `fetch_sub` in modification order, with the release sequence - supplying the edge. One of the two is wrong; settle it with an argument written down, not a patch. + **Memory ordering -- settled by SH-3.3.1, 2026-09-04.** The reviewer argued the relaxed ticket + operation gives no acquire edge from the consumer''s most recent `release_permit`; a prior round + concluded the opposite, that the release sequence supplies it. Both were reasoning about whether + the edge could be *rescued*, and the answer taken was to stop depending on the rescue: the permit + counter''s overdraw undo is now `Release` rather than `Relaxed`, so the counter carries one + discipline throughout and the edge holds without appealing to the release-sequence rule -- a rule + that was narrowed once already, when C++20 dropped same-thread relaxed stores from it. Recorded as + `D-38` in [DESIGN-NOTES.md](crates/windows-waitable-queues/DESIGN-NOTES.md). The cost is one + `stlxr` over `stxr` on AArch64, on the contended slow path. - [ ] **SH-4.8** -- **Six more PR #56 review findings, none blocking the release.** Replied to and resolved on the PR; recorded here so none is lost. diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index a1884e0b..d57f9a86 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -61,6 +61,9 @@ preferred. | D-35 | **Measured: the permit claim is 2.7x faster than `reserving_mpsc` at 16-32 producers, and 1.45x slower at one.** The safer claim is also the faster one everywhere contention exists, which was not the expected result -- it touches *two* shared lines where the shipping shape touches one plus a read, and [D-26](#d-26) had established that the shared line is what collapses. The mechanism is that both of its operations are unconditional read-modify-writes that never retry, where the shipping shape's compare-exchange retries once per lost race; the retries dominate long before the second line does. It is the only shape measured that gets *faster* per push as producers are added (42.8 ns at two to 19.5 at thirty-two) and the only one that stays within 1.5x of a bare contended `fetch_add`. **This decides the shape of the fix but not the fix**: the drained regime's refusal counts differ by orders of magnitude in a way this harness cannot attribute, which SH-15.5.1 exists to settle before SH-15.6 adopts anything. | | D-36 | **0.1.0 ships [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) disclosed rather than fixed, and the disclosure is a release blocker.** Following [D-31](#d-31)'s principle -- the disclosure, not the deferral, is the decision -- because the fix is a claim-protocol replacement ([D-35](#d-35)) whose adoption is still gated on an open question, and holding the release for it would trade a *documented* hazard for an undocumented rush. **The two gaps are not equally forgiving and the text says so**: an unverified ordering is a risk of a bug, this is a known one with a computed exposure, and its failure mode is silent -- no error, panic, or counter -- so a caller can neither detect nor mitigate it. That is precisely why it may not ship in silence. Stated in the crate docs, the README, and the shape's own module docs, each leading with **"on every target, not only 32-bit ones"**, because the natural spelling "32-bit position" invites the opposite reading and SH-6.1 already had to be corrected for exactly that. The shape-selection guidance in both documents was also amended: it previously said "start with `reserving_mpsc`" with no caveat, pointing callers at the hazardous shape by default. | | D-37 | **The reserving claim word ships in two widths: the narrow one on every target, the wide one only where a 128-bit exchange is genuinely lock-free.** `reserving_mpsc` keeps its packed 64-bit word, keeps [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md)'s hazard, keeps [D-36](#d-36)'s warnings, and is **never silently swapped** for the wide shape on targets that could host one -- a contract that changes with the target is what PLATFORM INTEGRITY rule 2 forbids, and a caller who read "2^32" must get 2^32. `reserving_mpsc_wide` is the same protocol with a `u128` word split 64/64: recurrence needs 2^64 pushes, and the capacity ceiling rises to 2^62. **The gate is one line of `Cargo.toml`: `default-features = false`.** Measured, not designed -- with the default feature set `portable-atomic` compiles on i686 and silently substitutes a global lock, but with defaults off `AtomicU128` **does not exist** there (`no AtomicU128 in the root`), nor on x86_64 built without `cmpxchg16b`. It exists exactly where a native lock-free exchange is guaranteed at compile time, so the `use` statement is the gate and it fails loudly. A `cfg(target_has_atomic = "128")` would be the *wrong* gate -- it is emitted even with `cmpxchg16b` disabled -- and a `const` assertion on `is_always_lock_free()`, though genuinely const-evaluable, is redundant where the type exists and unreachable where it does not. That is the standard [SH-14.2](../../CHECKLIST-ship-topology-and-queues.md) already set when it probed i686 to confirm `AtomicU64` was lock-free before widening `slotwise_mpsc`, recording that a hidden mutex "would have made this a bad trade". [D-7](#d-7)'s burden of proof for adding a Cargo feature is **discharged, not waived**: D-7 rejected feature-gating because the only benefit was compile time, and the cost here is a third-party dependency, which dead-code elimination does not remove from `Cargo.lock` or from an auditor's review. | +| D-38 | **One atomic, one discipline: an atomic that carries any acquire/release operation has acquire/release on *every* operation, and a relaxed load is never mixed in.** A relaxed operation is still **atomic** -- indivisible, untorn, and free of data-race UB -- but it is *unordered*: it behaves like a plain load or store with respect to placement, unanchored relative to the ordered operations on the same object and free to be moved by the optimizer or the processor. It is not pinned to its textual site, so reasoning about it in statement order is nonsense. **The two axes are independent, and conflating them is the mistake in both directions:** relaxed does not mean "no guarantees" (see [D-40](#d-40) -- the atomicity is often the whole point), and it does not mean "ordered but weakly". The failure mode is that it usually does what the source appears to say, until a change of code generator or a weaker processor makes it not; on x86-64 TSO a decorative `Acquire` and a `Relaxed` load emit near-identical code, so a test suite on this host cannot see the difference at all -- which is the same blindness [D-31](#d-31) measured. **When the two resolutions differ, promote the load.** The exception is a reference count ([D-39](#d-39)), and it is an exception for a stated reason rather than by convention. | +| D-39 | **The reference counts keep a relaxed increment against an `AcqRel` decrement, and that is the one sanctioned departure from [D-38](#d-38).** It is sanctioned because *no dependent memory is read on the strength of the relaxed increment*: a thread incrementing `producers` already holds a handle, so it needs no edge to learn the object exists, and the cache-coherence effect an acquire would buy is not required until the count reaches zero -- at which point the `AcqRel` decrement supplies it. That is the same argument `std::sync::Arc` makes, and it is a property of what the count is used for, not a general licence. A count whose value ever decided whether to *dereference* something would not qualify. | +| D-40 | **A relaxed atomic is chosen for its *atomicity*, and dropping to a plain field is never the way to "simplify" one.** [D-38](#d-38) says a relaxed operation is unordered; it is still indivisible, and that is frequently the entire reason the field is an atomic at all. Without it the implementation may synthesise a wide access out of narrower ones and observe a **torn** value, and concurrent access to a non-atomic field is a data race and therefore UB regardless. `reserving_mpsc`'s claim word is the worked example: it packs `reserved` and `position` into one `u64` and is uniformly relaxed ([D-38](#d-38)), yet a torn read would yield a pair that never existed as a state and would break the compare-and-swap protocol outright. On `i686-pc-windows-msvc` -- which [D-18](#d-18) deliberately keeps supported -- that load must be a `cmpxchg8b` or an 8-byte SSE load, *more* expensive than the two `mov`s a plain `u64` would get, and the compiler is obliged to pay it for exactly this reason. **Relaxed is a statement about ordering only; it is never a step toward removing the atomic.** | ## D-2: capabilities are sliced, not gathered @@ -1320,3 +1323,98 @@ consumer's `head` is simultaneously the stale input that SH-14.1 exploits and th measured. Removing it for correctness removes it for performance as well, which is why this measurement came out the way it did -- and why "closing the hole will cost throughput" was the wrong thing to have worried about. + +## D-38: one atomic, one discipline + +An atomic that carries any acquire/release operation carries acquire/release on **every** operation. +A relaxed load is never mixed onto it. + +The reason is not that relaxed is "weaker and therefore riskier". It is that a relaxed operation has no +memory ordering **at all**, so it is not placed at any defined point relative to the ordered operations +on the same object, and the code generator and the processor are both free to move it. With respect to +placement it behaves like a plain load or store: it is not pinned to its site in the source. Reading +such a program in textual order -- statement one, then statement two, then statement three -- and +concluding what the relaxed operation observes is not a weak argument; it is not an argument, because +the premise that it happens there is false. + +**"Plain" above is about placement only, and the distinction is easy to lose.** A relaxed operation is +still fully atomic: indivisible, never torn, immune to the compiler inventing or duplicating accesses, +and coherent (all threads agree on a single modification order for that one location). What it gives up +is ordering with respect to *other* memory. The two axes are independent, and the confusion runs in both +directions -- "relaxed means no guarantees, so I may as well use a plain field" is as wrong as "relaxed +is ordered, just weakly", and it is the more dangerous of the two because the field it produces is a +data race. [D-40](#d-40) states the atomicity half, with this crate's claim word as the worked example. + +What makes this expensive rather than merely wrong is that it **usually does what the author expected**: +a simple load or a simple store at the obvious place. It survives review, it survives testing, and it +breaks later, when an optimizer version changes or the code runs on a processor with a weaker model. + +This crate is unusually exposed to that, for a reason already measured. On x86-64's TSO, a decorative +`Acquire` load and a `Relaxed` load compile to very nearly the same instruction, so **no test on this +host can distinguish them** -- which is precisely the blindness [D-31](#d-31) recorded when weakening a +real `Acquire` to `Relaxed` left all twenty tests of the day green. On AArch64 the same two loads are +`ldar` and `ldr`, and the difference is real. CI builds `aarch64-pc-windows-msvc`. + +### The resolutions, and which one to take + +For a mixed atomic there are two consistent repairs: **promote the loads to acquire**, or **demote the +stores to relaxed**. Both are valid, and choosing between them is a separate and more involved analysis +of what the atomic is actually for. + +**The standing answer here is to promote the load.** An acquire that turns out to have been unnecessary +is a performance claim someone can come back and make with a benchmark. A relaxed load that turns out to +have been load-bearing is a defect that appears only on hardware we do not own. The asymmetry is not +close, so it is settled in advance rather than re-argued per site. + +Demotion is correct only where the atomic has **no release operation anywhere** -- there being nothing to +pair with, an acquire load on it would read as a guarantee the type does not make. Three atomics are in +that position and are uniformly relaxed for that reason: `reserving_mpsc`'s claim word (every write is a +`Relaxed/Relaxed` compare-exchange), `permit_mpsc`'s `tail` and `head`, and `slotwise_mpsc`'s `tail` +(whose claim CAS is deliberately `Relaxed/Relaxed` and says so at the site). + +Those four remain atomics, and the atomicity is load-bearing even with no ordering attached to it -- +[D-40](#d-40). The claim word makes the point unmissable: it is a `u64` packing two `u32` halves, so a +torn read would produce a `(reserved, position)` pair that was never a state the queue was in. "Every +operation on it is relaxed" is therefore not a step toward making it a plain field; it is a statement +about ordering and nothing else. + +### What the audit found + +Every atomic in the crate was grouped by field and asked one question: does it have a release write, and +does it also have relaxed operations? Four atomics had **acquire loads with no release write anywhere** +-- the acquire pairing with nothing, synchronizing with nothing: + +| Atomic | Acquire loads | Release writes | +|---|---|---| +| `reserving_mpsc` claim word | 4 | 0 | +| `permit_mpsc::tail` | 1 | 0 | +| `permit_mpsc::head` | 1 | 0 | +| `slotwise_mpsc::tail` | 1 | 0 | + +Four more had a real release store with relaxed loads mixed in, and those loads were promoted: +`reserving_mpsc::head`, `slotwise_mpsc::head`, `spsc::head`, `spsc::tail`. + +One had a genuine load-bearing edge with a relaxed operation sitting in the middle of it: +`permit_mpsc`'s permit counter, where the `Release` increment frees a slot and the `Acquire` decrement +claims it, but the overdraw undo was `Relaxed`. That one is rescuable by the release-sequence rule, but +that rule was narrowed once already (C++20 dropped same-thread relaxed stores from it) and it is not +something a reader should have to reconstruct to trust a slot handoff. It is now `Release`, which costs +one `stlxr` over `stxr` on AArch64, on the contended slow path. + +Two categories were deliberately left alone. Reference counts are [D-39](#d-39). Reads through +`&mut self` in `Drop` are not atomic operations at all -- `get_mut` is a plain read, and there is no +second thread for an ordering to order against -- so `permit_mpsc`'s drop was converted to `get_mut`, +matching `reserving_mpsc`, which removes the question rather than answering it. + +### Why the audit is a script and not a reading + +The first two versions of the audit were both wrong, in opposite directions, and neither error was +visible without checking a result by hand. The first required the receiver on one line and so missed +every multi-line `self.shared\n.head\n.0\n.store(...)` chain, reporting three atomics as having no writes +at all. The second matched across newlines and started absorbing words out of comments, splitting one +atomic's operations into several phantom fields and inventing "decorative acquire" findings for atomics +whose release store it had filed elsewhere. + +Both produced confident, plausible, wrong tables. The lesson is not about regular expressions: an +ordering audit's output has to be checked against the source at a few points before it is believed, +because a wrong audit here is indistinguishable from a right one by inspection. diff --git a/crates/windows-waitable-queues/src/doorbell.rs b/crates/windows-waitable-queues/src/doorbell.rs index b5b9e321..d11db9db 100644 --- a/crates/windows-waitable-queues/src/doorbell.rs +++ b/crates/windows-waitable-queues/src/doorbell.rs @@ -365,7 +365,12 @@ impl std::fmt::Debug for Doorbell { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Doorbell") .field("created", &self.event.get().is_some()) - .field("signalled", &self.signalled.load(Ordering::Relaxed)) + // Acquire, matching every other operation on `signalled`, which + // carries an `AcqRel` swap and a `Release` store. Nothing here + // depends on the edge, but a lone relaxed load on such an atomic is + // a plain load with no defined position relative to them, and this + // is a `Debug` formatter -- there is no cost worth the exception. + .field("signalled", &self.signalled.load(Ordering::Acquire)) .finish() } } diff --git a/crates/windows-waitable-queues/src/permit_mpsc.rs b/crates/windows-waitable-queues/src/permit_mpsc.rs index d6fa5e13..f6491fa4 100644 --- a/crates/windows-waitable-queues/src/permit_mpsc.rs +++ b/crates/windows-waitable-queues/src/permit_mpsc.rs @@ -269,7 +269,20 @@ impl Shared { } // Overdrawn. Put it back; a concurrent claimant that saw the negative // value is doing the same. - self.permits.0.fetch_add(1, Ordering::Relaxed); + // + // Release, matching `release_permit`, even though this thread published + // nothing and needs no edge of its own. Unlike `tail` and `head`, this + // counter carries a real edge, and a relaxed RMW here would sit in the + // middle of it: if this undo reads from a consumer's release and a + // third thread's acquire then reads from this undo, that thread's + // synchronization with the consumer rests entirely on the release + // sequence rule. That rule holds, but it was narrowed once already + // (C++20 dropped same-thread relaxed stores from it), and it is not a + // thing a reader should have to reconstruct to trust a slot handoff. + // Uniform acquire/release on this counter costs one `stlxr` over + // `stxr` on ARM64, on the contended slow path, and removes the + // argument entirely. + self.permits.0.fetch_add(1, Ordering::Release); false } @@ -309,28 +322,45 @@ impl Shared { self.doorbell.signal(); } + /// Relaxed on both, because neither `tail` nor `head` ever receives a + /// release write: `tail` only ever moves by `fetch_add(Relaxed)`, and the + /// consumer's `head` store is deliberately relaxed (see `pop`, where the + /// permit is what frees the slot). An acquire load here would therefore + /// pair with nothing and synchronize with nothing -- it would read as a + /// guarantee this queue does not make. The real edges are `sequence` + /// (release in `publish`, acquire in `pop`) and `permits` (release in + /// `release_permit`, acquire in `try_take_permit`); this is a snapshot and + /// rides on neither. fn len(&self) -> usize { - let tail = self.tail.0.load(Ordering::Acquire); - let head = self.head.0.load(Ordering::Acquire); + let tail = self.tail.0.load(Ordering::Relaxed); + let head = self.head.0.load(Ordering::Relaxed); (tail.wrapping_sub(head) as usize).min(self.capacity) } } impl Drop for Shared { fn drop(&mut self) { - // Every handle is gone, so the positions can be read directly. A slot - // whose sequence marks it published still holds an item nobody took. - let head = self.head.0.load(Ordering::Relaxed); - let tail = self.tail.0.load(Ordering::Relaxed); + // Every handle is gone, so `&mut self` proves this is the only thread. + // `get_mut` reads each position directly rather than atomically, which + // is why no ordering appears here at all: there is no second thread for + // one to order against, so the question does not arise. That is why + // this is not a relaxed read on an otherwise acquire/release atomic -- + // it is not an atomic read. `reserving_mpsc`'s drop does the same. + // + // A slot whose sequence marks it published still holds an item nobody + // took. + let mask = self.mask; + let head = *self.head.0.get_mut(); + let tail = *self.tail.0.get_mut(); let mut position = head; while position != tail { - let slot = &self.slots[position as usize & self.mask]; - if slot.sequence.load(Ordering::Relaxed) == position.wrapping_add(1) { + let slot = &mut self.slots[position as usize & mask]; + if *slot.sequence.get_mut() == position.wrapping_add(1) { // SAFETY: the sequence says a producer finished writing this // slot and no consumer took it. Every handle is gone, so this // is the only reader, and each position is visited once. unsafe { - (*slot.value.get()).assume_init_drop(); + slot.value.get_mut().assume_init_drop(); } } position = position.wrapping_add(1); diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index f2498d02..7cfd4d02 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -247,6 +247,16 @@ const fn reserved_of(word: u64) -> u32 { /// Builds a claim word from its two halves. /// +/// **Why the word is one `AtomicU64` and not two `AtomicU32`s**, given that every +/// operation on it is `Relaxed` (see D-38 in DESIGN-NOTES.md): relaxed is a +/// statement about *ordering*, and says nothing about atomicity. The two halves +/// are read and written as a unit, so the load must be indivisible -- a torn read +/// would return a `(reserved, position)` pair that was never a state this queue +/// was in, and the compare-and-swap protocol would be building on a value that +/// never existed. On `i686-pc-windows-msvc`, which D-18 keeps supported, that +/// costs a `cmpxchg8b` or an 8-byte SSE load rather than the two `mov`s a plain +/// `u64` would get. That cost is the point, not an overhead to optimize away. +/// /// The `|` could equally be `^`, or `+`, and a mutation run will report as much. /// The halves are disjoint by construction -- the shift clears every bit the /// position occupies -- so all three agree on every input, and no test can tell @@ -487,7 +497,7 @@ impl Shared { /// subtraction produce a number near `u32::MAX`. A bounded queue must never /// report holding more than it can. fn len(&self) -> usize { - let position = position_of(self.claim.0.load(Ordering::Acquire)); + let position = position_of(self.claim.0.load(Ordering::Relaxed)); let head = self.head.0.load(Ordering::Acquire); (position.wrapping_sub(head) as usize).min(self.capacity) } @@ -508,7 +518,7 @@ impl Shared { /// together to avoid. `head` is still a second load, so the result is /// clamped for the reason `len` is. fn remaining(&self) -> usize { - let word = self.claim.0.load(Ordering::Acquire); + let word = self.claim.0.load(Ordering::Relaxed); let head = self.head.0.load(Ordering::Acquire); let capacity = self.capacity_u32(); let occupied = position_of(word).wrapping_sub(head).min(capacity); @@ -523,7 +533,15 @@ impl Shared { /// is the right answer: the consumer may safely park on it, because the /// producer's publishing store is followed by a signal. fn has_ready_item(&self) -> bool { - let position = self.head.0.load(Ordering::Relaxed); + // Acquire, matching every other load of `head`. This thread is `head`'s + // only writer, so coherence alone would make a relaxed load read its + // own latest value -- but `head` carries a release store (in `pop`), and + // a relaxed load on an atomic that also carries acquire/release + // operations is a plain load: unanchored, free to be moved by the + // optimizer or the processor, with no defined position relative to the + // ordered operations on the same object. Uniform acquire is what makes + // the load mean, at this point in the source, what it appears to mean. + let position = self.head.0.load(Ordering::Acquire); let slot = &self.slots[position as usize & self.mask]; slot.sequence.load(Ordering::Acquire) == position.wrapping_add(1) } @@ -600,22 +618,65 @@ impl Shared { // given target's codegen happens to order it today. // // Placing it here rather than in `send` covers every path with one - // load. It must follow the claim exchange, and does: the invariant - // `occupied + reserved <= capacity` holds at that exchange with - // `reserved >= 1`, so `head >= position - capacity + 1` there, and - // `head` never moves backwards. Reading it afterwards can therefore - // only be fresher, never staler, than the edge the write requires. - let head = self.head.0.load(Ordering::Acquire); + // load. + // + // **The load must be fresh *enough*, and a single acquire load does not + // guarantee that.** An earlier version of this comment argued that + // because the claim invariant makes `head >= position - capacity + 1` + // true at the exchange, and `head` never moves backwards, a later load + // "can only be fresher". That conflates what `head` *is* in modification + // order with what a load is *guaranteed to observe*: an acquire load may + // legally return any earlier value in the modification order, and + // synchronizes only with the release store whose value it actually + // reads. + // + // Nothing else forces freshness here. The claim exchange is `Relaxed`, + // so it carries no edge; and while `reserve` does read `head`, a + // `Reservation` is `Send`, so the thread that redeems one **need never + // have read `head` at all** -- leaving no coherence constraint to + // inherit. A reservation held across a full lap and redeemed elsewhere + // is exactly the case. Raised in PR #56 review. + // + // So the load is repeated until it observes a `head` that has actually + // passed this position's previous occupant. That is the store which + // frees the slot, so observing it (or any later one, by the same + // consumer and therefore sequenced after its read) is precisely the + // edge the write below needs. The loop terminates because the claim + // invariant makes the condition already true in modification order -- + // this waits to *see* it, not for it to *become* true. + let mut head = self.head.0.load(Ordering::Acquire); + while position.wrapping_sub(head) >= self.capacity_u32() { + std::hint::spin_loop(); + head = self.head.0.load(Ordering::Acquire); + } if self.metrics.tracks_high_water() { let depth = position.wrapping_sub(head).wrapping_add(1) as usize; + // **The clamp is unreachable from here, and is kept deliberately.** + // The wait above exits only once `position - head < capacity`, so + // `depth <= capacity` already holds and `min` never binds. It was + // load-bearing when this was a single unvalidated load: a stale + // `head` then made the depth an unbounded over-report, and + // `the_high_water_mark_never_exceeds_the_capacity` drove exactly + // that. Waiting for a fresh `head` removes the over-report at its + // source, so that test was replaced by + // `publish_waits_for_a_head_that_has_freed_the_slot`, which asserts + // the fix instead of the mitigation. + // + // Kept because it costs one register-to-register `min` on a path + // already doing an atomic load, and because it bounds the metric by + // the shape's own contract rather than by an argument a future + // change to the wait might invalidate silently. A mutation run will + // report it as a survivor; that is expected, and it is unreachable + // code rather than a missing test. self.metrics.record_depth(depth.min(self.capacity)); } let slot = &self.slots[position as usize & self.mask]; // SAFETY: the caller's claim makes this thread the only writer, and the - // acquire load of `head` above synchronizes-with the `head.store` by - // which the consumer freed this slot a lap ago, so its read of the - // previous occupant happens-before this write. + // acquire load of `head` above -- repeated until it observed a value + // past this position's previous occupant -- synchronizes-with the + // `head.store` by which the consumer freed this slot a lap ago, so its + // read of the previous occupant happens-before this write. // // The claim alone is not enough. It establishes that the slot is // *logically* free -- `occupied + reserved <= capacity` with @@ -848,7 +909,7 @@ impl Producer { /// snapshot. #[must_use] pub fn outstanding_reservations(&self) -> usize { - reserved_of(self.shared.claim.0.load(Ordering::Acquire)) as usize + reserved_of(self.shared.claim.0.load(Ordering::Relaxed)) as usize } /// Whether the next best-effort push would be refused, as a snapshot. @@ -1066,8 +1127,12 @@ impl Consumer { /// correct answer -- and the producer signals when it publishes, so waiting /// is not a gamble. pub fn pop(&self) -> Option { - // Relaxed: this thread is the only writer of `head`. - let position = self.shared.head.0.load(Ordering::Relaxed); + // Acquire, matching every other load of `head`. Sole-writer coherence + // would suffice to read this thread's own latest value, but `head` also + // carries the release store below, and a relaxed load mixed onto such an + // atomic is a plain load the code generator may move. See + // `has_ready_item` for the full argument. + let position = self.shared.head.0.load(Ordering::Acquire); let slot = &self.shared.slots[position as usize & self.shared.mask]; // Acquire: pairs with the producer's release store, so an item it // published is visible here. @@ -1123,7 +1188,7 @@ impl Consumer { /// a drained queue with an outstanding reservation is not an idle one. #[must_use] pub fn outstanding_reservations(&self) -> usize { - reserved_of(self.shared.claim.0.load(Ordering::Acquire)) as usize + reserved_of(self.shared.claim.0.load(Ordering::Relaxed)) as usize } /// How many further items a best-effort push could still place, as a diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index dc7a0f05..12875820 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -26,7 +26,7 @@ use crate::Consumer as _; use crate::{Bounded, PushError, RecvError, Reserving}; use std::rc::Rc; use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::thread; use std::time::Duration; @@ -1332,36 +1332,65 @@ fn the_gauges_are_exact_when_the_two_loads_agree() { } #[test] -fn the_high_water_mark_never_exceeds_the_capacity() { - // The defect, driven directly. The depth is sampled from this producer's - // position and a load of the consumer's, which are two readings rather than - // one instant -- and `Reservation::send` is the path with no room check, so - // the only `head` its thread is ordered against is the one `reserve` read, - // which may be arbitrarily old by the time the reservation is redeemed. +fn publish_waits_for_a_head_that_has_freed_the_slot() { + // The `send`-path data race, asserted as the guarantee that closes it. + // + // Freeing a slot is the consumer's `head.store(Release)`, and on that path + // it is the *only* release it performs -- so a producer may write the slot + // only once an acquire load of `head` has actually observed that store. A + // single acquire load does not give that: it may legally return any earlier + // value in the modification order, and synchronizes only with the release + // whose value it in fact reads. `Reservation::send` is the exposed path, + // because it has no room check and a `Reservation` is `Send`, so the thread + // redeeming one need never have read `head` at all. // // A stale read cannot be raced for on a coherent machine, so the state one - // would observe is written instead: `head` behind the claim position by more - // than the capacity. Without the clamp this records a peak of 12 on a - // four-slot queue. - let (tx, rx) = bounded_with::(4, Options::new().tracking_high_water()) - .expect("4 is a valid capacity"); + // would observe is written instead: `head` far enough behind the claim + // position that this slot's previous occupant has not been freed. `publish` + // must then wait rather than write. + // + // **This replaces `the_high_water_mark_never_exceeds_the_capacity`**, which + // drove the same stale state to prove the high-water *clamp* bounded the + // over-report. Waiting for a fresh `head` removes the over-report at its + // source -- after the wait, `position - head < capacity`, so the depth is + // already bounded and the clamp cannot be reached from here. Asserting the + // fix is worth more than asserting a mitigation that is now unreachable. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); let slot = tx.reserve().expect("an empty queue has room"); - let stale_head = u32::MAX - 10; - tx.shared.head.0.store(stale_head, Ordering::Release); + // `send` claims position 0, so this leaves `position - head == 11` on a + // four-slot queue: a view in which the slot is not free. + tx.shared.head.0.store(u32::MAX - 10, Ordering::Release); - slot.send(7).expect("the consumer is still here"); + // `Arc` rather than a `static`, for the reason `DropCounter` + // gives: tests share a process, so a module-scope flag would be visible to + // whichever test ran beside this one. + let sent = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&sent); + let sender = thread::spawn(move || { + slot.send(7).expect("the consumer is still here"); + flag.store(true, Ordering::Release); + }); - // Restored before anything walks the ring: teardown and `pop` both step from - // `head` to the claim position, and a head this far behind sets them a - // four-billion-step loop that hangs rather than fails. - tx.shared.head.0.store(0, Ordering::Release); + // One-sided on purpose: a slow machine leaves it waiting and the assertion + // still holds. Only a `publish` that wrongly proceeded can fail it, and that + // one returns immediately. + thread::sleep(Duration::from_millis(50)); + assert!( + !sent.load(Ordering::Acquire), + "the slot was written while `head` still said its previous occupant was live" + ); - let peak = tx.high_water().expect("tracking was asked for"); + // Free it, and the wait must end. Also restores a `head` the teardown walk + // can use: it steps from `head` to the claim position, and a head this far + // behind would set it a four-billion-step loop that hangs rather than fails. + tx.shared.head.0.store(0, Ordering::Release); + sender.join().expect("the sending thread must not panic"); assert!( - peak <= tx.capacity(), - "a four-slot queue reported a peak of {peak}" + sent.load(Ordering::Acquire), + "observing the freeing store must end the wait" ); + assert_eq!(rx.pop(), Some(7), "the item itself must be unaffected"); } diff --git a/crates/windows-waitable-queues/src/slotwise_mpsc.rs b/crates/windows-waitable-queues/src/slotwise_mpsc.rs index ed6241a4..ad62577f 100644 --- a/crates/windows-waitable-queues/src/slotwise_mpsc.rs +++ b/crates/windows-waitable-queues/src/slotwise_mpsc.rs @@ -352,8 +352,21 @@ impl Shared { /// /// The clamp is also what makes the narrowing cast exact: the result is at /// most the capacity, which is a `usize` by construction. + /// The two orderings differ, because the two atomics do. + /// + /// `tail` never receives a release write at all -- the claim CAS in `push` + /// is deliberately `Relaxed/Relaxed`, and says so -- so every operation on + /// it is relaxed and an acquire load here would pair with nothing. + /// + /// `head` does carry a release store (in `pop`), so its loads are acquire + /// throughout. Not because this snapshot needs the edge -- nothing is + /// dereferenced on the strength of the number -- but because a relaxed load + /// mixed onto an atomic that also carries acquire/release operations is a + /// plain load, unanchored with respect to the ordered operations on the + /// same object and free to be moved. Mixing the two disciplines on one + /// atomic makes the source text stop describing what happens. fn len(&self) -> usize { - let tail = self.tail.0.load(Ordering::Acquire); + let tail = self.tail.0.load(Ordering::Relaxed); let head = self.head.0.load(Ordering::Acquire); tail.wrapping_sub(head).min(self.capacity as Position) as usize } @@ -375,7 +388,10 @@ impl Shared { /// sequentially consistent fences on both sides are what stop both loads /// from returning stale values. fn has_ready_item(&self) -> bool { - let position = self.head.0.load(Ordering::Relaxed); + // Acquire, matching every other load of `head`: it carries a release + // store, so a relaxed load here would be a plain load with no defined + // position relative to it. See `len` for the full argument. + let position = self.head.0.load(Ordering::Acquire); let slot = &self.slots[self.slot_index(position)]; slot.sequence.load(Ordering::Acquire) == position.wrapping_add(1) } @@ -718,8 +734,11 @@ impl Consumer { /// from "empty for good"; the order matters, and [`Self::is_disconnected`] /// documents which way round. pub fn pop(&self) -> Option { - // Relaxed: this thread is the only writer of `head`. - let position = self.shared.head.0.load(Ordering::Relaxed); + // Acquire, matching every other load of `head`. Sole-writer coherence + // would suffice to read this thread's own latest value, but `head` also + // carries the release store below, and a relaxed load mixed onto such an + // atomic is a plain load the code generator may move. See `len`. + let position = self.shared.head.0.load(Ordering::Acquire); let slot = &self.shared.slots[self.shared.slot_index(position)]; // Acquire: pairs with the producer's release store, so an item it // published is visible here. diff --git a/crates/windows-waitable-queues/src/spsc.rs b/crates/windows-waitable-queues/src/spsc.rs index d919125b..8b207663 100644 --- a/crates/windows-waitable-queues/src/spsc.rs +++ b/crates/windows-waitable-queues/src/spsc.rs @@ -300,8 +300,11 @@ impl Shared { // `head` to decide there was room, so the depth is a subtraction of two // values it is holding. The counter's line is producer-owned too, since // nothing else writes it. + // Acquire, matching every other load of `head`: it carries a release + // store, and a relaxed load mixed onto such an atomic is a plain load + // with no defined position relative to the ordered operations on it. self.metrics - .record_depth(tail.wrapping_sub(self.head.0.load(Ordering::Relaxed)) + 1); + .record_depth(tail.wrapping_sub(self.head.0.load(Ordering::Acquire)) + 1); // SAFETY: the caller's precondition says this slot holds no initialized // item, so writing a `MaybeUninit` over it drops nothing. @@ -379,9 +382,14 @@ impl Producer { /// [`PushError::Disconnected`] when the consumer is gone. Either way the /// item comes back, so nothing is lost by the refusal. pub fn push(&self, item: T) -> Result<(), PushError> { - // Relaxed: this thread is the only writer of `tail`, so it cannot read - // a stale value of its own. - let tail = self.shared.tail.0.load(Ordering::Relaxed); + // Acquire, matching every other load of `tail`. Sole-writer coherence + // would suffice to read this thread's own latest value, but `tail` also + // carries a release store (in `publish`), and a relaxed load mixed onto + // an atomic that carries acquire/release operations is a plain load: + // unanchored with respect to those operations, and free to be moved by + // the optimizer or the processor. Uniform acquire is what keeps the + // source text describing what actually happens. + let tail = self.shared.tail.0.load(Ordering::Acquire); // Acquire: pairs with the consumer's release store, so a slot it freed // is visible as free here. let head = self.shared.head.0.load(Ordering::Acquire); @@ -501,7 +509,9 @@ impl Producer { /// on another thread -- it is only the writing side that is pinned. #[must_use = "a reservation withholds capacity from the best-effort path until it is used or dropped"] pub fn reserve(&self) -> Option> { - let tail = self.shared.tail.0.load(Ordering::Relaxed); + // Acquire on both: each carries a release store, so a relaxed load on + // either would be a plain load, unanchored with respect to it. + let tail = self.shared.tail.0.load(Ordering::Acquire); let head = self.shared.head.0.load(Ordering::Acquire); let reserved = self.shared.reserved.load(Ordering::Relaxed); @@ -586,7 +596,8 @@ impl Reservation<'_, T> { return Err(Disconnected(item)); } - let tail = shared.tail.0.load(Ordering::Relaxed); + // Acquire, matching every other load of `tail`; see `push`. + let tail = shared.tail.0.load(Ordering::Acquire); // SAFETY: the reservation guarantees a free slot -- the room check that // granted it withheld one from the best-effort path, and this thread is // the only one that could have consumed it since. @@ -659,8 +670,10 @@ impl Consumer { /// good"; the order matters, and [`Self::is_disconnected`] documents which /// way round. pub fn pop(&self) -> Option { - // Relaxed: this thread is the only writer of `head`. - let head = self.shared.head.0.load(Ordering::Relaxed); + // Acquire, matching every other load of `head`. Sole-writer coherence + // would suffice here, but `head` also carries the release store below; + // see `push` for why the two disciplines are not mixed on one atomic. + let head = self.shared.head.0.load(Ordering::Acquire); // Acquire: pairs with the producer's release store, so an item it // published is visible here. let tail = self.shared.tail.0.load(Ordering::Acquire); From 01de0bbb52bc6dece6955a435bc8345d68e63a2d Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 11:20:23 -0400 Subject: [PATCH 303/361] fix(topology): refuse to serialize an unrecognised kind named after a known one `DomainKind::Other` promises that a description this crate cannot fully interpret still round-trips losslessly. An `Other` whose `name` is a kind this crate *does* decode breaks that promise outright, and the quiet half is the dangerous one: `Group`, `Package`, `Die` and `Module` carry no fields, so an `Other` named "group" was written as `"kind": "group"`, read back as `DomainKind::Group`, and its attributes were dropped silently -- no error, and a document meaning something other than what was serialized. `core`, `cache` and `memory` usually fail loudly on a missing field, but decode cleanly as a different kind when the attributes happen to supply one. Refused rather than escaped or renamed: mangling the name would let the write succeed while altering a value the caller chose, trading a detectable error for an undetectable one. This is the same judgement and the same remedy as the pre-existing check one level down, which already refuses an `Other` whose *attribute* name collides with the reserved `kind`/`id`/`processors` fields. The hazard was identical and only the field differed; the two checks now sit beside each other. `WELL_KNOWN_KIND_NAMES` is bound to the deserializer's arms by `every_well_known_name_decodes_to_a_named_kind` rather than trusting the two to stay in step, because both directions of drift are defects: a name listed but no longer decoded makes the refusal spurious, and a name decoded but not listed re-opens the hole. Sabotage-verified -- with the check disabled the guard test fails and prints the corrupt document `{"kind":"group","processors":[],"watts":15}`, which is the defect itself. Recorded as D-25. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/DESIGN-NOTES.md | 27 +++++++ crates/windows-topology-sys/src/domain.rs | 41 ++++++++++- .../windows-topology-sys/src/domain/tests.rs | 72 +++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index f9f92dc8..b1586eed 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -772,3 +772,30 @@ record that declares it, because the walk never hands out the bytes. Recorded as `M6` in [CHECKLIST.md](CHECKLIST.md), which is where the work is queued; this decision is the thing that work derives from. + +## D-25: an unrecognised kind may not borrow a name this crate decodes + +`DomainKind::Other` exists so that "a description this crate cannot fully interpret still round-trips +losslessly". Serializing one whose `name` is a kind this crate *does* decode breaks that promise at the +only point where it matters, so it is refused rather than written. + +The failure is not symmetric across the kinds, and the quiet half is the dangerous one. `Group`, +`Package`, `Die` and `Module` carry no fields, so an `Other` named `"group"` is written as +`"kind": "group"`, read back as `DomainKind::Group`, and its attributes are dropped **silently** -- no +error, no warning, and a document that means something other than what was serialized. `core`, `cache` +and `memory` usually fail loudly on a missing field, which is better but still not a round trip; and +where the attributes happen to supply those fields, the result decodes cleanly as a different kind. + +**Refused, not escaped or renamed.** Prefixing or mangling the name would let the write succeed while +changing a value the caller chose, which trades a detectable error for an undetectable one. This is the +same judgement, and the same remedy, as the pre-existing check one level down that refuses an `Other` +whose *attribute* name collides with the reserved `kind`/`id`/`processors` fields -- the hazard is +identical, only the field differs, and the two now sit beside each other in the serializer. + +The list of reserved names is `WELL_KNOWN_KIND_NAMES`, and `every_well_known_name_decodes_to_a_named_kind` +binds it to the deserializer's arms rather than trusting them to stay in step. Both directions of drift +are defects: a name listed but no longer decoded makes the refusal spurious, and a name decoded but not +listed re-opens exactly this hole. A test that derived one from the other was preferred to a comment +asking the next editor to remember. + +Raised in the PR #56 review. diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index 2999b636..e6fc90ad 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -461,6 +461,15 @@ mod serde_impl { .ok_or_else(|| E::custom(format!("domain is missing required field \"{key}\""))) } + /// Every kind name this crate decodes into a named [`DomainKind`]. + /// + /// **Must list exactly the arms the deserializer matches before its + /// `other =>` fallback**, and `every_well_known_name_decodes_to_a_named_kind` + /// asserts that it does rather than leaving the two to drift. + pub(super) const WELL_KNOWN_KIND_NAMES: &[&str] = &[ + "group", "package", "die", "module", "core", "cache", "memory", + ]; + impl Serialize for Domain { fn serialize(&self, serializer: S) -> Result { let mut map = serializer.serialize_map(None)?; @@ -472,7 +481,33 @@ mod serde_impl { DomainKind::Core { .. } => "core", DomainKind::Cache { .. } => "cache", DomainKind::Memory { .. } => "memory", - DomainKind::Other { name, .. } => name.as_str(), + DomainKind::Other { name, .. } => { + // The same rule as the attribute-name collision check + // below, for the same reason and by the same remedy. + // + // `Other` exists so a description this crate cannot fully + // interpret "still round-trips losslessly", and a name this + // crate *does* interpret breaks exactly that: the document + // would say `"kind": "group"`, and reading it back yields + // `DomainKind::Group`, not the `Other` that was written. + // `Group`, `Package`, `Die` and `Module` carry no fields, + // so that substitution succeeds silently and the attributes + // are dropped on the floor; `core`, `cache` and `memory` + // fail loudly on a missing field, or -- worse -- succeed as + // a different kind when the attributes happen to match. + // + // Refused rather than escaped or renamed, because both of + // those would change a name the caller chose. Raised in the + // PR #56 review. + if WELL_KNOWN_KIND_NAMES.contains(&name.as_str()) { + return Err(S::Error::custom(format!( + "domain kind name \"{name}\" collides with a kind this crate names \ + itself, so the document would deserialize as that kind rather than \ + as `Other`" + ))); + } + name.as_str() + } }; map.serialize_entry("kind", kind_name)?; // The wire shape keeps an "id" because a description is written by @@ -601,6 +636,10 @@ mod serde_impl { Some(value) => crate::observed::Observed::Known(as_u64(value)?), }, }, + // Every arm above must appear in `WELL_KNOWN_KIND_NAMES`, or + // `Serialize` would let an `Other` claim that name and the + // document would decode as the named kind instead. The test + // named on that constant is what enforces it. other => DomainKind::Other { name: other.to_string(), attributes: fields, diff --git a/crates/windows-topology-sys/src/domain/tests.rs b/crates/windows-topology-sys/src/domain/tests.rs index d4fd6d31..e076c3a7 100644 --- a/crates/windows-topology-sys/src/domain/tests.rs +++ b/crates/windows-topology-sys/src/domain/tests.rs @@ -318,6 +318,78 @@ mod serde_tests { } } + #[test] + fn an_unrecognised_domain_kind_named_after_a_known_one_is_refused() { + // The sibling of the attribute-name check above, and the same hazard + // one level up: `Other` promises that a description this crate cannot + // interpret round-trips losslessly, and a name this crate *does* + // interpret breaks that promise outright. Written as `"kind": "group"`, + // the document reads back as `DomainKind::Group` -- a different kind, + // with the attributes silently gone. Raised in the PR #56 review. + for known in [ + "group", "package", "die", "module", "core", "cache", "memory", + ] { + let mut attributes = BTreeMap::new(); + attributes.insert("watts".to_string(), AttributeValue::UnsignedInteger(15)); + let domain = Domain { + kind: DomainKind::Other { + name: known.to_string(), + attributes, + }, + processors: ProcessorSet::empty(), + observations: Vec::new(), + }; + serde_json::to_string(&domain).expect_err(&format!( + "an unrecognised kind named {known:?} must not be written as that known kind" + )); + } + } + + #[test] + fn an_unrecognised_domain_kind_with_its_own_name_still_round_trips() { + // The complement, so the refusal above cannot be satisfied by refusing + // every `Other`: a genuinely unknown name is exactly what the variant + // is for and must still survive the round trip intact. + let mut attributes = BTreeMap::new(); + attributes.insert("watts".to_string(), AttributeValue::UnsignedInteger(15)); + let domain = Domain { + kind: DomainKind::Other { + name: "power".to_string(), + attributes, + }, + processors: ProcessorSet::empty(), + observations: Vec::new(), + }; + let restored = round_trip(&domain); + assert_eq!(restored, domain); + } + + #[test] + fn every_well_known_name_decodes_to_a_named_kind() { + // Binds the constant the serializer refuses on to the arms the + // deserializer actually matches, so the two cannot drift apart. A name + // listed but no longer decoded would make the refusal above spurious; + // a name decoded but not listed would let an `Other` claim it, which is + // the defect this pair of tests exists to prevent. Derived from the + // constant rather than restating it, so adding a kind to one place and + // not the other fails here. + for name in crate::domain::serde_impl::WELL_KNOWN_KIND_NAMES { + let json = format!( + r#"{{"kind":"{name}","id":0,"processors":[], + "simultaneous_multithreading":false,"efficiency_class":0, + "level":1,"associativity":8,"line_size":64,"size_bytes":32768, + "cache_type":"unified","memory_bytes":0}}"# + ); + let domain: Domain = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("{name:?} must decode as a named kind: {e}")); + assert!( + !matches!(domain.kind, DomainKind::Other { .. }), + "{name:?} is listed as well known but decoded as `Other`, so the \ + serializer refuses a name nothing actually reserves" + ); + } + } + #[test] fn a_hand_written_synthetic_description_parses() { // The whole point of an open, hand-writable schema: no discovery, no From 2830f0955e68bc5ce0f402aa1761a73a84f4d36a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 11:20:48 -0400 Subject: [PATCH 304/361] fix(placement-probe): never report an unobserved cache relationship as same or cross `Observed` derives `PartialEq`, so comparing two `cache_domain` values with `==` answers a question nobody asked: `NotObserved == NotObserved` is true, and two processors merely both *missing* from the cache partition compared as sharing a cache; `NotObserved != Known(0)` is also true, so an unobserved domain against a known one compared as a cache crossing. Either way an unknown was promoted into a finding, in a tool whose entire product is measurements other people are asked to trust. Both directions were live, at two sites. `classify` filed such pairs under `SameCache*` or `CrossCache*`. `within_class_pair` -- which selects the evidence for a `by_class` measurement that claims cache control -- would choose two unobserved processors as a same-cache pair, which is the worse of the two: it does not merely mislabel a measurement, it causes the run to make one it cannot describe. The rule now has one definition, `ProcessorPlace::shares_cache_domain_with`, returning `Option`; `classify`, `within_class_pair` and `Slice::same_cache_domain` all ask it instead of restating it. That last one already had the rule right, which is why this was hard to see: the contract was stated correctly in one place while two other sites re-implemented it with `==` and got it wrong. `Absent` is deliberately not unknown -- it is the platform positively reporting that no cache level partitions this machine, so two `Absent` processors really do share the single domain. The cheap fix here is to treat every non-`Known` alike, which would discard a real answer on every host without a partitioning cache level, so a test guards the distinction. `Placement` gained `UnknownCacheSameClass` and `UnknownCacheCrossClass` rather than the pairs being dropped: the handoff really was timed between two named processors, so the number is real and only the cache relationship is unknown. They sit after `CrossNumaNode` because that ordering runs tightest-to-loosest coupling and an unobserved relationship is not a point on that scale. No schema bump -- the freeze starts at the first release and this crate has not had one. Swept the workspace for the same pattern: no other `Observed` field is compared peer-to-peer, and the only remaining `==` on `cache_domain` is inside the new definition. Sabotage-verified: removing the guard fails four of the new tests, including the selector one. Raised in the PR #56 review as two suppressed comments on one defect. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-placement-probe/DESIGN-NOTES.md | 40 +++++++ .../src/core_affinity.rs | 52 +++++++-- .../src/core_affinity/tests.rs | 102 +++++++++++++++++- .../src/fingerprint.rs | 55 ++++++++-- 4 files changed, 231 insertions(+), 18 deletions(-) diff --git a/crates/windows-placement-probe/DESIGN-NOTES.md b/crates/windows-placement-probe/DESIGN-NOTES.md index f00d15ab..43c5c926 100644 --- a/crates/windows-placement-probe/DESIGN-NOTES.md +++ b/crates/windows-placement-probe/DESIGN-NOTES.md @@ -168,3 +168,43 @@ tell that three of them were never emitted by any build anyone could obtain. **Rejected: dropping the golden until release.** The guard is what makes an accidental shape change visible, and that is as valuable during development as after it -- more so, since that is when the shape actually moves. + +## An unobserved cache relationship is a third answer, not a coin flip + +`ProcessorPlace::cache_domain` is an `Observed`, and `Observed` derives `PartialEq`. Comparing two +of them with `==` therefore answers a question nobody asked: `NotObserved == NotObserved` is **true**, so +two processors that were merely both *missing* from the cache partition compare as sharing a cache, and +`NotObserved != Known(0)` is also true, so an unobserved domain against a known one compares as a cache +*crossing*. Either way an unknown is promoted into a finding, in a tool whose entire product is +measurements that other people are asked to trust. + +Both directions were live. `classify` filed such pairs under `SameCache*` or `CrossCache*`, and +`within_class_pair` -- which selects the evidence for a `by_class` measurement that claims cache control +-- would choose two unobserved processors as a same-cache pair. The second is the worse of the two: it +does not merely mislabel a measurement, it causes the run to *make* one it cannot describe. + +The rule now has one definition, `ProcessorPlace::shares_cache_domain_with`, returning `Option`; +`classify`, `within_class_pair` and `Slice::same_cache_domain` all ask it rather than restating it. That +last one already had the rule right and was the reason the defect was hard to see: the contract was +stated correctly in one place while two other sites re-implemented it with `==` and got it wrong. A +hand-written second copy of a contract rule is not a check of the contract, it is a check of the copy. + +**`Absent` is deliberately not unknown.** It is the platform positively reporting that no cache level +partitions this machine, so two `Absent` processors really do share the single domain. Only +`NotObserved` -- "nothing asked, or no way to ask" -- poisons the comparison. The cheap fix for the +defect above is to treat every non-`Known` alike, which would silently discard a real answer on every +host without a partitioning cache level; `an_absent_cache_partition_is_shared_not_unknown` exists to +stop that. + +**Labelled rather than dropped.** `Placement` gained `UnknownCacheSameClass` and +`UnknownCacheCrossClass` instead of the pairs being excluded, because the handoff really was timed +between two named processors and the number is real -- it is only the *cache* relationship that is +unknown, and the efficiency class is still carried. They sit after `CrossNumaNode` rather than beside +their `SameCache`/`CrossCache` counterparts because that ordering runs tightest-to-loosest *coupling* +and an unobserved relationship is not a point on that scale. This follows `CrossNumaNode`'s own +reasoning, quoted from its doc: the merge is silent, and the run that would expose it is the expensive +one. + +No schema version bump: the freeze starts at the first release and this crate has not had one. + +Raised in the PR #56 review, as two suppressed comments on the same defect. diff --git a/crates/windows-placement-probe/src/core_affinity.rs b/crates/windows-placement-probe/src/core_affinity.rs index 3ef7e423..66a4d763 100644 --- a/crates/windows-placement-probe/src/core_affinity.rs +++ b/crates/windows-placement-probe/src/core_affinity.rs @@ -113,7 +113,13 @@ fn within_class_pair( members .iter() .flat_map(|a| members.iter().map(move |b| (**a, **b))) - .find(|(a, b)| a.core != b.core && a.cache_domain == b.cache_domain) + // `shares_cache_domain_with`, not `==`: this pair is the *evidence* for + // a within-class comparison that claims cache control, so an unknown + // relationship disqualifies it. Comparing the `Observed` values + // directly selected two processors that were merely both missing from + // the cache partition, and reported them as a same-cache pair. Raised + // in the PR #56 review. + .find(|(a, b)| a.core != b.core && a.shares_cache_domain_with(*b) == Some(true)) } /// Every efficiency class this machine has. @@ -291,6 +297,27 @@ pub enum Placement { /// Absent on every host measured so far -- all three are VM slices that /// present a single node -- and reported inexpressible rather than merged. CrossNumaNode, + /// Same efficiency class; the cache relationship was never observed. + /// + /// Not a cache category, which is why these two sit after `CrossNumaNode` + /// rather than beside their `SameCache`/`CrossCache` counterparts: the + /// ordering above runs from tightest to loosest *coupling*, and an + /// unobserved relationship is not a point on that scale. `CrossNumaNode` + /// remains the loosest coupling this machine can express. + /// + /// Reached when the topology omitted a processor from the level that + /// partitions the machine, which `windows-topology-sys` reports as + /// `Observed::NotObserved` rather than inventing a domain. The measurement + /// itself is real -- a handoff was timed between two named processors -- so + /// it is labelled honestly instead of dropped, or worse, filed under a + /// cache relationship nobody established. Same reasoning as + /// `CrossNumaNode`'s: the merge is silent, and the run that would expose it + /// is the expensive one. + UnknownCacheSameClass, + /// Different efficiency class; the cache relationship was never observed. + /// + /// See [`Self::UnknownCacheSameClass`]. + UnknownCacheCrossClass, } impl Placement { @@ -304,6 +331,11 @@ impl Placement { Self::CrossCacheSameClass => "cross cache, same class", Self::CrossCacheCrossClass => "cross cache, cross class", Self::CrossNumaNode => "cross NUMA node", + // "unknown", never "same" or "cross": a reader scanning the table + // must be able to see that the topology did not answer, rather than + // being handed a cache claim it never made. + Self::UnknownCacheSameClass => "unknown cache, same class", + Self::UnknownCacheCrossClass => "unknown cache, cross class", } } } @@ -450,13 +482,19 @@ pub fn classify(producer: ProcessorPlace, consumer: ProcessorPlace) -> Placement if producer.numa_node != consumer.numa_node { return Placement::CrossNumaNode; } - let same_cache = producer.cache_domain == consumer.cache_domain; + // `shares_cache_domain_with` rather than `==`, and the third answer is a + // label rather than a guess. Comparing the `Observed` values directly made + // two unobserved domains "same cache" and an unobserved against a known one + // "cross cache", filing a measurement under a relationship the topology + // never established. Raised in the PR #56 review. let same_class = producer.efficiency_class == consumer.efficiency_class; - match (same_cache, same_class) { - (true, true) => Placement::SameCacheSameClass, - (true, false) => Placement::SameCacheCrossClass, - (false, true) => Placement::CrossCacheSameClass, - (false, false) => Placement::CrossCacheCrossClass, + match (producer.shares_cache_domain_with(consumer), same_class) { + (Some(true), true) => Placement::SameCacheSameClass, + (Some(true), false) => Placement::SameCacheCrossClass, + (Some(false), true) => Placement::CrossCacheSameClass, + (Some(false), false) => Placement::CrossCacheCrossClass, + (None, true) => Placement::UnknownCacheSameClass, + (None, false) => Placement::UnknownCacheCrossClass, } } diff --git a/crates/windows-placement-probe/src/core_affinity/tests.rs b/crates/windows-placement-probe/src/core_affinity/tests.rs index b9cf7e09..2fd6d87c 100644 --- a/crates/windows-placement-probe/src/core_affinity/tests.rs +++ b/crates/windows-placement-probe/src/core_affinity/tests.rs @@ -7,7 +7,10 @@ //! seconds. What is worth testing here is that the probe cannot silently //! mislabel a pair, because every conclusion it prints is keyed on that label. -use super::{Placement, RunPlan, classify, memory_placements, node_pairs, representative_pairs}; +use super::{ + Placement, RunPlan, classify, memory_placements, node_pairs, representative_pairs, + within_class_pair, +}; use crate::peer_index_cache::ITEMS; use windows_topology_sys::MachineMemoryTopology; @@ -91,6 +94,103 @@ fn differing_in_both_is_cross_cross() { assert_eq!(classify(a, b), Placement::CrossCacheCrossClass); } +#[test] +fn two_unobserved_cache_domains_are_unknown_rather_than_the_same() { + // The defect this guards, in the direction that flatters the machine. + // `Observed` derives `PartialEq`, so `NotObserved == NotObserved` is true + // and comparing the values directly reported two processors that were + // merely both *missing* from the cache partition as sharing a cache. The + // measurement then claimed a cache-controlled comparison nobody observed. + let a = place(0, 1, windows_topology_sys::Observed::NotObserved); + let b = place(1, 1, windows_topology_sys::Observed::NotObserved); + assert_eq!(classify(a, b), Placement::UnknownCacheSameClass); + assert_eq!(classify(b, a), Placement::UnknownCacheSameClass); +} + +#[test] +fn an_unobserved_domain_against_a_known_one_is_unknown_rather_than_cross() { + // The same defect in the other direction, which no reader would suspect + // from the first: `NotObserved != Known(0)` is also true, so the pair was + // reported as a *cache crossing* that was equally never observed. Both + // directions are asserted because fixing only one is the likelier mistake. + let known = place(0, 1, windows_topology_sys::Observed::Known(0)); + let unknown = place(6, 1, windows_topology_sys::Observed::NotObserved); + assert_eq!(classify(known, unknown), Placement::UnknownCacheSameClass); + assert_eq!(classify(unknown, known), Placement::UnknownCacheSameClass); +} + +#[test] +fn an_unobserved_domain_still_reports_the_class_it_does_know() { + // Unknown is about the cache alone. The efficiency class is a separate + // observation and is still carried, so the label degrades in one axis + // rather than collapsing to a single "unknown" bucket. + let a = place(0, 1, windows_topology_sys::Observed::NotObserved); + let b = place(1, 0, windows_topology_sys::Observed::NotObserved); + assert_eq!(classify(a, b), Placement::UnknownCacheCrossClass); +} + +#[test] +fn an_absent_cache_partition_is_shared_not_unknown() { + // `Absent` is the platform positively reporting that no cache level + // partitions this machine, so every processor really is behind the one + // domain. Only `NotObserved` -- "nothing asked, or no way to ask" -- is + // unknown. Guarding the distinction because the cheap fix for the defect + // above is to treat every non-`Known` alike, which would silently discard + // a real answer on every host without a partitioning cache level. + let a = place(0, 1, windows_topology_sys::Observed::Absent); + let b = place(1, 1, windows_topology_sys::Observed::Absent); + assert_eq!(classify(a, b), Placement::SameCacheSameClass); +} + +#[test] +fn a_node_crossing_still_dominates_an_unobserved_cache() { + // The precedence order is unchanged by the new variants: a pair on + // different nodes is a node crossing whatever the cache relationship was, + // including not knowing it. + let a = place(0, 1, windows_topology_sys::Observed::NotObserved); + let b = on_node(place(6, 1, windows_topology_sys::Observed::NotObserved), 1); + assert_eq!(classify(a, b), Placement::CrossNumaNode); +} + +#[test] +fn siblings_are_siblings_even_when_the_cache_was_not_observed() { + // Sharing a physical core is established by the core id alone, so it does + // not depend on the cache observation and must not be downgraded by it. + let a = sibling(0, 0, 1, windows_topology_sys::Observed::NotObserved); + let b = sibling(1, 0, 1, windows_topology_sys::Observed::NotObserved); + assert_eq!(classify(a, b), Placement::SameCoreSiblings); +} + +#[test] +fn a_within_class_pair_is_never_chosen_on_an_unobserved_cache() { + // The selector's contract is a pair that genuinely shares a cache, because + // the `by_class` measurement it feeds claims cache control. Two processors + // merely both missing from the partition satisfied `==` and were chosen, + // so the run measured a pair it could not describe. + let places = [ + place(0, 1, windows_topology_sys::Observed::NotObserved), + place(1, 1, windows_topology_sys::Observed::NotObserved), + ]; + assert!( + within_class_pair(&places, 1).is_none(), + "an unobserved cache domain cannot evidence a same-cache pair" + ); +} + +#[test] +fn a_within_class_pair_is_still_chosen_on_a_known_shared_cache() { + // The complement, so the fix above cannot be satisfied by refusing every + // pair: a genuinely observed shared domain must still be selected. + let places = [ + place(0, 1, windows_topology_sys::Observed::Known(0)), + place(1, 1, windows_topology_sys::Observed::Known(0)), + ]; + assert!( + within_class_pair(&places, 1).is_some(), + "a known shared cache domain is exactly what this selector is for" + ); +} + #[test] fn classification_is_symmetric_in_the_two_ends() { // The placement describes a relationship, so naming which end produces must diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index 1ca870a8..45a09873 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -124,6 +124,37 @@ pub struct ProcessorPlace { } impl ProcessorPlace { + /// Whether this processor and `other` sit behind the same cache domain, + /// or `None` when the topology never said. + /// + /// **The one definition of that comparison; every caller asks here.** It + /// exists because `Observed` derives `PartialEq`, so `==` answers the wrong + /// question on the variant that matters: two `NotObserved` domains compare + /// *equal* and would be reported as sharing a cache the topology never + /// established, and a `NotObserved` against a `Known` compares *unequal* + /// and would be reported as a cache crossing that was never observed. + /// Either way an unknown is silently promoted to a finding, in a tool whose + /// entire product is measurements other people trust. + /// + /// `Absent` is deliberately *not* unknown: it is the platform positively + /// reporting that no cache level partitions this machine, so two `Absent` + /// processors really do share the (single) domain. Only `NotObserved` -- + /// "nothing asked, or no way to ask" -- poisons the comparison. + /// + /// Raised in the PR #56 review, which found `core_affinity` comparing these + /// with `==` at two sites while [`Slice::same_cache_domain`] had the rule + /// right. That is why the rule now lives in one place instead of being + /// restated: a second copy is not a check of the contract, it is a check of + /// the copy. + #[must_use] + pub fn shares_cache_domain_with(self, other: Self) -> Option { + if self.cache_domain == Observed::NotObserved || other.cache_domain == Observed::NotObserved + { + return None; + } + Some(self.cache_domain == other.cache_domain) + } + /// This processor's full identity, as the pinning call needs it. /// /// Exists so no call site is tempted to pass a bare `number`, which is the @@ -216,17 +247,21 @@ impl Slice { // sharing a cache. That is the hazard the old refusal existed to // prevent, moved from "refuse the whole run" to "answer the one // question that cannot be answered". - let mut domains = participants.iter().map(|(_, place)| place.cache_domain); - let first = domains.next()?; - if first == Observed::NotObserved { - return None; - } + // + // The rule itself is [`ProcessorPlace::shares_cache_domain_with`] and + // is asked rather than restated here -- this fold only extends it from + // a pair to a set. + let mut places = participants.iter().map(|(_, place)| *place); + let first = places.next()?; let mut same = true; - for domain in domains { - if domain == Observed::NotObserved { - return None; - } - same &= domain == first; + for place in places { + same &= first.shares_cache_domain_with(place)?; + } + // A single participant is trivially of one domain, but only if that + // domain was observed at all -- otherwise the loop above never ran and + // never asked. + if first.cache_domain == Observed::NotObserved { + return None; } Some(same) } From 4d8f075dd1a35186386bc7937984567fbed54959 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 11:46:37 -0400 Subject: [PATCH 305/361] fix(platform-probes): put the host banner in the report instead of on stdout `queue_contention` and `peer_index_cache` composed their whole report as a string and handed it to a sink at one place -- except for the banner, which they rendered by calling `print_banner`, which wrote to stdout itself. Both files carry a comment at that exact spot saying "the only place that names the real stream; everything below composes text, nothing below knows where it goes", and the banner call three lines later did precisely that. The consequence is confined to non-stdout sinks, and that is the case that matters: a `Captured` report received the body without the line naming the machine that produced it, and the taint marker with it. On a terminal both paths happened to reach stdout, and `render` ran before `emit`, so the ordering was right by luck rather than by construction -- which is why this survived being looked at. The stdout-writing helpers are removed rather than documented against. `print_banner` and `print_banner_with` are gone; `banner_lines_with` returns the two-line string, alongside the `banner_line` that already existed. Three call sites had each grown a comment warning readers off those helpers, which is a rule restated three times instead of a hazard removed once. There is now no banner helper that writes to a stream, so the defect class is unreachable rather than merely fixed. Swept the probe surface rather than trusting the two reported sites: no probe binary contains a direct `println!`/`eprintln!`. The two that remain in these crates are both correct -- `report.rs`'s `Stdout::line` is the sink itself, and `worker_context.rs` writes one diagnostic from a `Drop` during an unwind, where panicking again would abort the process. M34.2 updated to record the real state: it claimed seven binaries still violated the rule, when five had already been converted and these two were the remainder. Its outstanding work is the capture test, and the obstacle is now named -- each `render` lives in a `bin` target that nothing can import, which is exactly why both banner defects survived every test. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST.md | 51 +++++++++++-------- .../src/fingerprint.rs | 34 +++++++------ .../src/bin/core_affinity.rs | 7 ++- .../src/bin/peer_index_cache.rs | 9 +++- .../src/bin/queue_contention.rs | 9 +++- 5 files changed, 68 insertions(+), 42 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index 96a67001..d5949639 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -128,16 +128,23 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. - [ ] **M34.2** -- **Route every tool's output through one sink, per the repository's own rule**: never call `println!`/`eprintln!` from more than one site in a tool; introduce a writer trait, sink or - formatter at the first occurrence and route everything through it. Seven binaries violate this today - and were flagged individually in review 5072622803 on pull request #56: - [main.rs](crates/windows-placement-probe/src/bin/placement_probe/main.rs), - [doorbell_cost.rs](crates/windows-platform-probes/src/bin/doorbell_cost.rs), - [queue_contention.rs](crates/windows-platform-probes/src/bin/queue_contention.rs), - [request_cost.rs](crates/windows-platform-probes/src/bin/request_cost.rs), - [topology.rs](crates/windows-platform-probes/src/bin/topology.rs), - [core_affinity.rs](crates/windows-platform-probes/src/bin/core_affinity.rs) and - [peer_index_cache.rs](crates/windows-platform-probes/src/bin/peer_index_cache.rs). The banner - helpers that hardcode stdout are part of it, not an exception to it. + formatter at the first occurrence and route everything through it. + **Updated 2026-09-04: the conversion is done; what remains is the capture test.** The item said + "seven binaries violate this today" and named them, from review 5072622803 on pull request #56. + Five had already been converted when a later review round re-checked, and the last two -- + [queue_contention.rs](crates/windows-platform-probes/src/bin/queue_contention.rs) and + [peer_index_cache.rs](crates/windows-platform-probes/src/bin/peer_index_cache.rs) -- were fixed in + that pull request, so all seven now compose their whole report as text and hand it to a sink at one + place. Verified by counting, not by reading: no probe binary contains a direct `println!`/ + `eprintln!` at all. The two survivors were the *banner*, which those two rendered by calling a + helper that wrote to stdout itself -- so a captured report was missing the one line naming the + machine that produced it, and the banner also emitted mid-`render`, ahead of the body, making the + order on a terminal luck rather than construction. + **The stdout-writing banner helpers are gone rather than documented against.** `print_banner` and + `print_banner_with` were removed and `banner_lines_with` returns the string instead, because three + call sites had each grown a comment warning about them -- a rule restated three times instead of a + hazard removed once. The defect class is now unreachable by construction: there is no + banner helper that writes to a stream. **The PowerShell tools are NOT part of this item, because they are already done.** A later review round on the same pull request observed that the inventory above named only Rust binaries while five scripts emitted from many sites, so those were converted in that pull request rather than queued @@ -148,17 +155,19 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. sink. They were small enough to convert in place, which is exactly why they did not need deferring. (`run-sabotage.ps1`'s `Exit-WithMessage` is deliberately outside its sink: that path writes to stderr and exits, and there the destination is part of the meaning.) - **What remains deferred is the seven Rust binaries, and the reason is sequencing rather than doubt.** - It is one refactor across seven binaries; done properly it means choosing the seam once and applying - it uniformly, which is a large diff touching every probe's output. Landing it inside a 90-commit - branch already under review would mix it with unrelated correctness work. - **The point of the rule is that output becomes testable, so the conversion is not done until - something tests it.** An abstraction introduced without a capture-based test spends the cost and - skips the benefit -- do not check this item off on the refactor alone. That test is what the Rust - binaries still owe; a PowerShell sink is a function whose destination can be swapped, but this - workspace runs no PowerShell test harness in which to assert against it, and inventing one to cover - five diagnostic scripts is not a cost this item is willing to spend without deciding to adopt such a - harness first. + **What remains is the capture test, and it has a structural obstacle worth naming.** + The point of the rule is that output becomes testable, so this item is not checked off on the + refactor alone -- an abstraction introduced without a capture-based test spends the cost and skips + the benefit. `Captured` exists in [report.rs](crates/windows-platform-probes/src/report.rs) for + exactly that purpose, and `banner_line` is already asserted directly. + **The obstacle: each probe's `render()` lives in its own `bin` target, which nothing can import.** + That is precisely why the two banner defects survived every test -- there was no reachable seam to + assert against. Closing it means moving each `render()` into the crate's library and leaving `main` + as the one place that names the stream, which is a real refactor rather than a test to write. + Decide the seam once and apply it uniformly. + A PowerShell sink is a function whose destination can be swapped, but this workspace runs no + PowerShell test harness in which to assert against it, and inventing one to cover five diagnostic + scripts is not a cost this item is willing to spend without deciding to adopt such a harness first. Start with `placement_probe`: its output is a published artifact that strangers paste into a discussion thread, so "can this be captured and asserted end to end?" has real value there rather than being architectural tidiness. diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index 45a09873..d16b450f 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -820,22 +820,26 @@ pub fn places_from_topology( .collect() } -/// Print the host fingerprint as a probe's first line, or say why it could not -/// be read. +/// The host fingerprint as a probe's first line, or why it could not be read. /// /// Never fails the probe: a measurement without a fingerprint is still worth /// having, and is far better than one that refused to run. But it says so /// loudly, because an unlabelled number is what this exists to prevent. -pub fn print_banner() { - println!("{}", banner_line()); -} - -/// The banner as a string, so what a probe prints can be asserted rather than -/// inspected. /// -/// Separated from [`print_banner`] for one reason: the taint marker reaching -/// this line is the whole point of carrying provenance, and a property that -/// matters that much should not rest on a human having read the format string. +/// **Returns the line rather than printing it, and there is deliberately no +/// printing counterpart.** A probe composes its whole report as text and hands +/// it to a sink at exactly one place; a helper that wrote to stdout itself put +/// a line on the terminal that the returned report did not contain, so a +/// *captured* report was missing the one line naming the machine that produced +/// it -- and the taint marker with it. It also emitted during `render`, ahead +/// of the body, so even on a terminal the ordering was luck. Both printing +/// forms were removed rather than documented against, because three call sites +/// had each grown a comment warning about them, which is a rule restated three +/// times instead of a hazard removed once. +/// +/// Returning a string has a second benefit worth keeping: what a probe prints +/// can be *asserted*, and the taint marker reaching this line is too important +/// to rest on a human having read the format string. #[must_use] pub fn banner_line() -> String { match Fingerprint::discover() { @@ -844,14 +848,14 @@ pub fn banner_line() -> String { } } -/// Print the host fingerprint and the slice one measurement ran on. +/// The host fingerprint and the slice one measurement ran on, as two lines. /// /// Both, always: the host says which experiments the machine can express, and /// the slice says which one this number came from. Either alone leaves a /// reader unable to tell whether two figures are comparable. -pub fn print_banner_with(slice: &Slice) { - print_banner(); - println!("slice: {slice}"); +#[must_use] +pub fn banner_lines_with(slice: &Slice) -> String { + format!("{}\nslice: {slice}", banner_line()) } #[cfg(test)] diff --git a/crates/windows-platform-probes/src/bin/core_affinity.rs b/crates/windows-platform-probes/src/bin/core_affinity.rs index eb5f23d3..ebf3aacf 100644 --- a/crates/windows-platform-probes/src/bin/core_affinity.rs +++ b/crates/windows-platform-probes/src/bin/core_affinity.rs @@ -19,10 +19,9 @@ fn main() -> std::io::Result<()> { /// The probe's whole report, as text. fn render(observation: &Observation) -> String { let mut out = String::new(); - // `banner_line`, not `print_banner`: the latter writes to stdout itself, - // which would put a line on the terminal that the returned report does not - // contain -- so a captured report would be missing the one line that says - // which machine produced it, and the taint marker with it. + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. let _ = writeln!( out, "{}", diff --git a/crates/windows-platform-probes/src/bin/peer_index_cache.rs b/crates/windows-platform-probes/src/bin/peer_index_cache.rs index 802be212..f4a5185b 100644 --- a/crates/windows-platform-probes/src/bin/peer_index_cache.rs +++ b/crates/windows-platform-probes/src/bin/peer_index_cache.rs @@ -19,7 +19,14 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); - windows_placement_probe::fingerprint::print_banner(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!( out, "== what does caching the peer's index buy an SPSC ring? ==\n" diff --git a/crates/windows-platform-probes/src/bin/queue_contention.rs b/crates/windows-platform-probes/src/bin/queue_contention.rs index c2a3943d..ab29ad64 100644 --- a/crates/windows-platform-probes/src/bin/queue_contention.rs +++ b/crates/windows-platform-probes/src/bin/queue_contention.rs @@ -23,7 +23,14 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); - windows_placement_probe::fingerprint::print_banner(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!(out, "== does the array queue's tail claim contend? ==\n"); let observation = measure(); From 2381d88ddb27aa9d74abf9583b95437a0ca1491b Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 12:01:21 -0400 Subject: [PATCH 306/361] fix(ci): let a broken NUMA spike fail the build again The `numa-spikes` job carried `continue-on-error: true`, which converted every failure into a green check -- including the one the script exits non-zero specifically to raise. `run-numa-spikes.ps1` already draws the line this job needs, and is the only place that does: it exits 0 for any *finding*, including the vacuous result expected on a single-node runner, and non-zero only when a spike fails to build or to run. Its own comment says why, at the site: a spike that crashed printed no `VACUOUS` line, so without the instrument-failure count the summary would announce "NOT vacuous -- this runner has more than one NUMA node" on the strength of a stack trace. The job-level flag discarded that distinction wholesale, so a spike that no longer compiled would have gone unnoticed indefinitely. The workflow comment block contradicted itself across seven lines: "A FAILURE HERE MUST NOT BREAK THE BUILD ... `continue-on-error` is deliberate, not laziness", then "this job is the executable form of that README instruction, so the instruction cannot rot without turning this step red". The second is the intent, and it is only true while the step is allowed to turn red. The first predates the script gaining its build/run distinction and is now stated where it belongs -- in the script that implements it -- rather than restated here where it was enforced by a flag that could not tell the two cases apart. Verified rather than assumed, because removing the flag makes a broken spike red for real: both spikes build and run on this host, the script reports them VACUOUS (one NUMA node) and exits 0. So CI stays green on an ordinary runner and reddens only on instrument failure, which is the behaviour the PR description already claimed. Swept the workflows: this was the only `continue-on-error` in any job. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bd317a0..b7e2f23c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -216,19 +216,28 @@ jobs: # ever appears, the answer is already in that build's log. A design decision # is currently resting on documentation because no such machine is available. # - # A FAILURE HERE MUST NOT BREAK THE BUILD. These are observations, not - # assertions -- `continue-on-error` is deliberate, not laziness. + # A SPIKE'S RESULT MUST NOT BREAK THE BUILD, BUT A BROKEN SPIKE MUST. + # `run-numa-spikes.ps1` already draws that line and is the only place that + # does: it exits 0 for any *finding*, including the vacuous one expected here, + # and non-zero only when a spike fails to build or to run. So this job carries + # no `continue-on-error` -- adding one back would discard exactly the signal + # the script exits non-zero to raise, and the two statements below would stop + # being true. + # + # It had one until 2026-09-04, and that is what the PR #56 review caught: the + # job-level flag converted the script's deliberate exit 1 into a green check, + # so a spike that no longer compiled would have gone unnoticed indefinitely. # # They are compiled through the scratch-crate procedure their own README # documents, rather than being made workspace members, because each is # deliberately written against `windows-sys` alone so that what it measures is # the operating system and not us. A useful side effect: this job is the # executable form of that README instruction, so the instruction cannot rot - # without turning this step red. + # without turning this step red -- which is only true while the step is + # allowed to turn red. numa-spikes: - name: NUMA spikes (observational, never fails the build) + name: NUMA spikes (results observational, instrument failures block) runs-on: windows-latest - continue-on-error: true timeout-minutes: 15 env: RUSTUP_TOOLCHAIN: stable From 9d757142adf69c6cac3777fef94817e606718fcc Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 13:16:28 -0400 Subject: [PATCH 307/361] fix(ioring): stop ring_copy reporting a remote run that measured a local one `--placement remote` silently fell back to local placement whenever no remote NUMA node could be found, and a restored topology can never supply one: `Domain::deserialize` deliberately leaves `observations` empty (D-12/D-22 -- a file cannot establish what the relationship walk saw), and the node number lives in a `Source::RelationshipWalk` observation. So `label_from` answered `None` for every domain in a description, however many nodes that description described, and `.or(local)` quietly turned the request into its opposite. That is worse than an unsupported switch. The mode exists so the buffer-placement effect is "measurable rather than assumed", so a run that measures local placement while reporting itself as remote shows no placement effect and invites the reader to conclude there is none. `remote_numa_node` now returns a three-state `RemoteNode` instead of `Option`, because the two ways of having no remote node need opposite handling and an `Option` cannot tell them apart. `Unnamed` (the topology names no nodes at all) is refused with an explanation and exit 2; `SameAsLocal` (an ordinary single-node machine) falls back to local and says so; `Other` does what the switch says. Verified by running all four combinations rather than by reading them, which caught a defect in the fix itself. The refusal check asks the question in its global form by passing `None` as the local node -- and every named node differs from "no node", so that call reports `Other` on a single-node machine and the fallback note never fired. Whether a *different* node exists is per-domain and is now asked of the plans against their real local nodes. Measured: live+local exit 0 silent, live+remote exit 0 with the note, restored+local exit 0, restored+remote exit 2 refused. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../examples/ring_copy/main.rs | 59 ++++++++++++++++++- .../examples/ring_copy/plan.rs | 59 ++++++++++++++++--- 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/crates/windows-ioring-sys/examples/ring_copy/main.rs b/crates/windows-ioring-sys/examples/ring_copy/main.rs index f5aef8f9..52d810bc 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/main.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/main.rs @@ -233,6 +233,54 @@ fn main() -> io::Result<()> { let source_handle = SendHandle(source_file.as_raw_handle()); let destination_handle = SendHandle(destination_file.as_raw_handle()); + // Settled once, before any thread starts, because both answers are about + // the topology rather than about a domain -- and because the refusal must + // happen before the copy rather than per-chunk inside it. + if args.remote_placement { + // Two separate questions, and answering only the first was a defect + // caught by running this rather than by reading it. + // + // `None` as the local node asks the *global* form -- is there any + // memory domain carrying an operational node number at all? -- because + // every named node differs from "no node". That detects a restored + // description, and nothing else: an `Other` here says the nodes are + // named, never that a remote one exists. + // + // Whether a genuinely different node exists is per-domain, so it is + // asked of the plans below against their real local nodes. Skipping + // that left a single-node machine silently measuring local placement + // under `--placement remote`, which is the same substitution this + // whole block exists to prevent. + match plan::remote_numa_node(&topology, None) { + plan::RemoteNode::Unnamed => { + report.error_line(format_args!( + "--placement remote needs a topology that names its NUMA nodes, and this one \ + does not. A restored description (--topology) carries no node numbers: \ + deserialization deliberately drops the observations that hold them, because \ + a file cannot establish what the relationship walk saw. Refusing rather \ + than placing locally, which would report a remote run that measured a local \ + one. Drop --topology to measure this machine, or use --placement local." + )); + std::process::exit(2); + } + plan::RemoteNode::SameAsLocal | plan::RemoteNode::Other(_) => { + let any_remote = plans.iter().any(|domain_plan| { + matches!( + plan::remote_numa_node(&topology, domain_plan.local_numa_node), + plan::RemoteNode::Other(_) + ) + }); + if !any_remote { + report.line(format_args!( + "note: no domain has a NUMA node other than its own, so there is nothing \ + remote to place on; --placement remote measures the same placement as \ + --placement local on this machine" + )); + } + } + } + } + let domain_count = plans.len() as u64; let per_domain = source_len.div_ceil(domain_count.max(1)); @@ -244,8 +292,15 @@ fn main() -> io::Result<()> { let start = per_domain * index as u64; let end = (start + per_domain).min(source_len); let numa_node = if args.remote_placement { - plan::remote_numa_node(&topology, domain_plan.local_numa_node) - .or(domain_plan.local_numa_node) + match plan::remote_numa_node(&topology, domain_plan.local_numa_node) { + plan::RemoteNode::Other(node) => Some(node), + // Both already reported above -- `Unnamed` exited, and + // `SameAsLocal` said that local is the only node there + // is. Neither may reach here as a silent substitution. + plan::RemoteNode::SameAsLocal | plan::RemoteNode::Unnamed => { + domain_plan.local_numa_node + } + } } else { domain_plan.local_numa_node }; diff --git a/crates/windows-ioring-sys/examples/ring_copy/plan.rs b/crates/windows-ioring-sys/examples/ring_copy/plan.rs index 9cad72f9..0c5ebb97 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/plan.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/plan.rs @@ -106,16 +106,57 @@ fn numa_node_for(topology: &MachineMemoryTopology, processors: &ProcessorSet) -> }) } +/// What `--placement remote` can actually be given on this topology. +/// +/// Three answers rather than `Option`, because the two ways of having no +/// remote node need opposite handling and an `Option` cannot tell them apart. +/// Conflating them is what let a restored topology silently produce a *local* +/// measurement while the caller had asked for a remote one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteNode { + /// A NUMA node other than the local one. The switch does what it says. + Other(u32), + /// The memory domains name their nodes, and there is only the local one -- + /// an ordinary single-node machine. Falling back to local is the honest + /// answer here, because no other node exists to place on. + SameAsLocal, + /// No memory domain carries an operational node number, so which nodes + /// exist cannot be determined -- let alone which is remote. + /// + /// This is what a **restored** topology gives. `Domain::deserialize` + /// deliberately leaves `observations` empty (D-12/D-22: a file cannot + /// establish that the relationship walk saw anything), and the node number + /// is carried by a `Source::RelationshipWalk` observation -- so + /// `label_from` answers `None` for every domain in a description, however + /// many nodes that description describes. + Unnamed, +} + /// A NUMA node other than `local`, for the sample's `--placement remote` /// switch -- deliberately the wrong node, so the buffer-placement effect /// (M7.4) is measurable rather than assumed. -pub fn remote_numa_node(topology: &MachineMemoryTopology, local: Option) -> Option { - topology - .domains - .iter() - .filter_map(|domain| match domain.kind { - DomainKind::Memory { .. } => domain.label_from(Source::RelationshipWalk), - _ => None, - }) - .find(|&id| Some(id) != local) +/// +/// That purpose is why [`RemoteNode::Unnamed`] must not degrade to the local +/// node: a run that measures local placement while reporting itself as remote +/// would show no placement effect, and the reader would conclude there is +/// none. A refused run says less than a wrong one, and says it honestly. +pub fn remote_numa_node(topology: &MachineMemoryTopology, local: Option) -> RemoteNode { + let mut any_named = false; + for domain in &topology.domains { + if !matches!(domain.kind, DomainKind::Memory { .. }) { + continue; + } + let Some(id) = domain.label_from(Source::RelationshipWalk) else { + continue; + }; + any_named = true; + if Some(id) != local { + return RemoteNode::Other(id); + } + } + if any_named { + RemoteNode::SameAsLocal + } else { + RemoteNode::Unnamed + } } From 4622d59505e40f94c09e5717f42d08a60b21ef7f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 13:16:52 -0400 Subject: [PATCH 308/361] docs(placement-probe): say what the build marker actually proves "Trusting the binary" implied `--version` distinguishes an official build from a forged one. It does not. The stamp comes from two ordinary environment variables read by `build.rs` (`PLACEMENT_PROBE_SOURCE` and `PLACEMENT_PROBE_COMMIT`), so anyone building this crate can set them and produce a binary that calls itself official at any commit it likes, and nothing signs the result -- no Authenticode signature, no build attestation. A self-reported marker cannot authenticate a download. What the marker is genuinely for is catching an accident: a local build submitted by mistake, or a result pasted from a working copy with uncommitted changes. That is the common case and worth catching, and the README now says that is the claim rather than implying a stronger one. Points at the mechanism that does anchor a download instead. GitHub records a SHA-256 digest for every release asset at upload time and exposes it in the release page, the REST and GraphQL APIs, and `gh`, so comparing `Get-FileHash` against the published digest ties the bytes to what this repository released. Verified rather than assumed: the exact `gh release view --json assets --jq '.assets[] | "\(.name) \(.digest)"'` form in the README was run against a public repository and returns `sha256:` digests. Swept the other statements of the claim rather than fixing only the one reported. `build_identity.rs`'s `BuildSource::Ci` gained a note that the value is what the build was told about itself, since that enum is where a reader meets the concept. The workflow comment and the superseded DESIGN-NOTES section say a binary *attached to a release* is traceable to its commit, which remains true -- the release is the anchor -- so they are left alone; the error was in claiming `--version` carried that weight. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/README.md | 32 +++++++++++++++++-- .../src/build_identity.rs | 8 +++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/windows-placement-probe/README.md b/crates/windows-placement-probe/README.md index 91abc71d..891d8af5 100644 --- a/crates/windows-placement-probe/README.md +++ b/crates/windows-placement-probe/README.md @@ -74,9 +74,35 @@ its commit and reads as official; anything else is marked `!!UNOFFICIAL!!`, including a build from a local working copy. The same marking appears in the result, so a submission always says which build produced it. -That distinction is why the download is the recommended path: an artifact -attached to a release here is traceable to the commit that built it, in a way a -local build of byte-identical source is not. +**That marker is self-reported, and it is not evidence about a binary you did +not build.** The build stamp comes from two ordinary environment variables +(`PLACEMENT_PROBE_SOURCE` and `PLACEMENT_PROBE_COMMIT`) read by `build.rs`, so +anyone building this crate can set them and produce a binary that calls itself +official and names any commit it likes. Nothing signs the result: these +binaries carry no Authenticode signature and no build attestation, so +`--version` cannot authenticate an arbitrary download. + +What the marker is genuinely for is catching an **accident** -- a local build +submitted by mistake, or a result pasted from a working copy with uncommitted +changes -- which is the common case and worth catching. It is not a defence +against anyone who wants to misreport a build, and this document previously +implied otherwise. + +**To establish that a download is what this repository published, verify it +against the release.** GitHub records a SHA-256 digest for every release asset +at upload time and exposes it in the release page, the API, and `gh`: + +```powershell +# What you have. +Get-FileHash .\placement-probe-x86_64.exe -Algorithm SHA256 + +# What the release published. +gh release view --repo MikeGrier/windows-threadpool-sys --json assets ` + --jq '.assets[] | "\(.name) \(.digest)"' +``` + +That anchors the bytes to the release, which is the traceable thing -- the +`--version` line only repeats what the build was told about itself. ## What a result does not establish diff --git a/crates/windows-placement-probe/src/build_identity.rs b/crates/windows-placement-probe/src/build_identity.rs index ce90b534..b036eb5c 100644 --- a/crates/windows-placement-probe/src/build_identity.rs +++ b/crates/windows-placement-probe/src/build_identity.rs @@ -20,6 +20,14 @@ pub enum BuildSource { Local, /// Built by this repository's CI, which is the only path that produces an /// artifact traceable to the commit that made it. + /// + /// **This is what the build was told about itself, not something it can + /// prove.** The value comes from an environment variable read at build + /// time, so it distinguishes an *accidental* local build from a CI one -- + /// which is what it is for -- and does not authenticate a binary someone + /// else handed you. See the crate README, "Trusting the binary": the + /// release asset's SHA-256 digest is what ties a download to what this + /// repository published. Ci, } From e9d85a23f4cf8cd9138c684e41e950c8b8001484 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 13:31:07 -0400 Subject: [PATCH 309/361] ci(placement-probe): attest released binaries with GitHub build provenance The `--version` stamp is self-reported: `build.rs` reads it from environment variables, so a binary built anywhere can claim to be an official build at any commit. That catches an accidental local build, not a forgery, and the previous commit corrected the README to say so. This adds the thing that does not rely on being told. `actions/attest-build-provenance` has GitHub sign a statement binding the exact released bytes to this repository, this workflow and this commit. A downloader checks it without trusting anything the binary says about itself: gh attestation verify placement-probe-x86_64.exe \ --repo MikeGrier/windows-threadpool-sys Three placement decisions, each for a reason: It runs in the `release` job rather than the build matrix. That job holds the exact files the upload publishes, so nothing can change between the claim and the artifact, and it is already gated on the tag -- so the pull-request path keeps building and verifying without minting attestations for binaries nobody will ever download. It runs *before* the upload, so a failure means nothing was published rather than something unattested was. The job is written to be re-runnable after a partial upload; re-running mints a second attestation over the same bytes, which is allowed and harmless. The permissions sit on that job rather than at workflow level, so the build and pull-request paths cannot mint anything. `id-token: write` to obtain the OIDC token Sigstore signs against, `attestations: write` to persist the result; `contents: write` was already there. No repository setting or plan change is needed: attestations are available to public repositories on every current plan, and this one is public. Nothing changes for whoever cuts a release -- it is still a `placement-probe-v*` tag push, and the attestation happens in the same run. README updated, including the claim two paragraphs earlier that these binaries carry "no build attestation", which this commit makes false. They remain un-Authenticode-signed, and the README now distinguishes that from the attestation rather than lumping them together. `gh attestation verify` is documented as the primary check, with the release asset's SHA-256 digest kept as the weaker fallback for someone without `gh`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-placement-probe.yml | 35 +++++++++++++++++++ crates/windows-placement-probe/README.md | 35 ++++++++++++------- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release-placement-probe.yml b/.github/workflows/release-placement-probe.yml index 73977d0c..20eaea5e 100644 --- a/.github/workflows/release-placement-probe.yml +++ b/.github/workflows/release-placement-probe.yml @@ -229,12 +229,47 @@ jobs: permissions: # The only job that needs it, and only for the tag path. contents: write + # Artifact attestation needs both: `id-token` to mint the OIDC token + # Sigstore signs against, and `attestations` to persist the result. Kept + # on this job rather than at workflow level so the build and pull-request + # paths cannot mint anything. + id-token: write + attestations: write steps: - uses: actions/download-artifact@v4 with: path: artifacts merge-multiple: true + # **What the `--version` stamp cannot do.** That stamp is self-reported: + # `build.rs` reads it from environment variables, so a binary built + # anywhere can claim to be an official build at any commit. It catches an + # accident, not a forgery, and the crate README says so. + # + # This is the part that does not rely on being told. GitHub signs a + # statement binding these exact bytes to this workflow, this repository + # and this commit, and a downloader checks it without trusting anything + # the binary says about itself: + # + # gh attestation verify placement-probe-x86_64.exe \ + # --repo MikeGrier/windows-threadpool-sys + # + # Deliberately **before** the upload, so a failure here means nothing was + # published rather than something unattested was. Re-running after a + # partial upload mints a second attestation for the same bytes, which is + # allowed and harmless -- the alternative is publishing first and finding + # out afterwards. + # + # It runs here rather than in the build matrix for two reasons: these are + # the exact files that get uploaded, so nothing can change between the + # claim and the artifact; and this job alone is gated on the tag, so the + # pull-request path builds and verifies without minting attestations for + # binaries nobody will ever download. + - name: Attest the binaries + uses: actions/attest-build-provenance@v4 + with: + subject-path: artifacts/placement-probe-*.exe + - name: Write the release notes # A file rather than an inline `--notes` string. The notes mention # `--preview` in backticks, and a backtick inside a double-quoted bash diff --git a/crates/windows-placement-probe/README.md b/crates/windows-placement-probe/README.md index 891d8af5..be76b937 100644 --- a/crates/windows-placement-probe/README.md +++ b/crates/windows-placement-probe/README.md @@ -78,9 +78,11 @@ result, so a submission always says which build produced it. not build.** The build stamp comes from two ordinary environment variables (`PLACEMENT_PROBE_SOURCE` and `PLACEMENT_PROBE_COMMIT`) read by `build.rs`, so anyone building this crate can set them and produce a binary that calls itself -official and names any commit it likes. Nothing signs the result: these -binaries carry no Authenticode signature and no build attestation, so -`--version` cannot authenticate an arbitrary download. +official and names any commit it likes. Nothing about the stamp is checked or +signed, so `--version` cannot authenticate an arbitrary download. (Released +binaries *are* attested -- see below -- but that is a signature over the bytes +made by GitHub, not something the stamp establishes. These binaries carry no +Authenticode signature.) What the marker is genuinely for is catching an **accident** -- a local build submitted by mistake, or a result pasted from a working copy with uncommitted @@ -88,22 +90,31 @@ changes -- which is the common case and worth catching. It is not a defence against anyone who wants to misreport a build, and this document previously implied otherwise. -**To establish that a download is what this repository published, verify it -against the release.** GitHub records a SHA-256 digest for every release asset -at upload time and exposes it in the release page, the API, and `gh`: +**To establish what a download actually is, verify its attestation.** Every +released binary is signed by GitHub at build time with a statement binding those +exact bytes to this repository, the workflow that built them, and the commit +they were built from. Checking it trusts none of what the binary says about +itself: ```powershell -# What you have. -Get-FileHash .\placement-probe-x86_64.exe -Algorithm SHA256 +gh attestation verify .\placement-probe-x86_64.exe ` + --repo MikeGrier/windows-threadpool-sys +``` + +A binary that was not built by this repository's release workflow has no such +attestation and fails that check, whatever its `--version` line claims. + +If you would rather not install `gh`, the weaker check is the release digest. +GitHub records a SHA-256 for every release asset at upload time, so comparing it +tells you the bytes match what the release published -- though unlike the +attestation it says nothing about how they were built: -# What the release published. +```powershell +Get-FileHash .\placement-probe-x86_64.exe -Algorithm SHA256 gh release view --repo MikeGrier/windows-threadpool-sys --json assets ` --jq '.assets[] | "\(.name) \(.digest)"' ``` -That anchors the bytes to the release, which is the traceable thing -- the -`--version` line only repeats what the build was told about itself. - ## What a result does not establish These are **timing** measurements. They say nothing about memory ordering, and a From 15a242f16da0d23bb79702a58550d86d7792c7ef Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 13:39:24 -0400 Subject: [PATCH 310/361] fix(file-enumeration): assert the default buffer is whole pages, not merely a power of two The const guard on `DEFAULT_BUFFER_CAPACITY` asserted `is_power_of_two` and its comment gave the wrong reason for why that killed the `64 + 1024` mutant. The comment said 1088 is not "a whole number of records' worth of aligned buffer" -- but 1088 is 136 * 8, so it is exactly that. The guard killed the mutant only because 1088 happens not to be a power of two, which is incidental to the property being claimed. The gap that leaves is concrete: 2048 is a power of two, is not a whole page, and would have passed the guard while making every refill straddle a page boundary -- the regression the guard exists to prevent. Now asserts the claimed property directly, that the default is a whole number of pages. Verified in both directions: with the default set to 2048 the crate fails to compile naming that assertion, and 1088 and 2048 are both rejected while 65536 is accepted. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/request.rs | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/crates/windows-file-enumeration-sys/src/request.rs b/crates/windows-file-enumeration-sys/src/request.rs index 23f4b4b7..e23436c4 100644 --- a/crates/windows-file-enumeration-sys/src/request.rs +++ b/crates/windows-file-enumeration-sys/src/request.rs @@ -35,21 +35,45 @@ pub const MINIMUM_BUFFER_CAPACITY: usize = 1024; /// to it. pub(crate) const RECORD_ALIGNMENT: usize = 8; +/// The page size every Windows target this crate builds for uses. +/// +/// Named rather than written as `4096` at the assertion site, and stated here +/// because it is the unit [`DEFAULT_BUFFER_CAPACITY`] is chosen in: a buffer +/// that is a whole number of pages has each refill begin on a page boundary. +/// Large-page allocations are a different mechanism and are not what this +/// buffer uses. +const PAGE_SIZE: usize = 4096; + // The relationships these capacities depend on, checked by the compiler rather // than by a test -- they are facts about constants, so a test could only report // after the fact, on a build somebody chose to run. // // A mutation run replaced `64 * 1024` with `64 + 1024`, and every test passed: // 1088 is still above the minimum and still a legal capacity, so nothing that -// merely enumerates a directory can tell the difference. What it is *not* is a -// whole number of records' worth of aligned buffer, which is the property the -// default is chosen for. +// merely enumerates a directory can tell the difference. +// +// **The first version of this guard asserted `is_power_of_two`, and its comment +// gave the wrong reason.** It said 1088 is not "a whole number of records' worth +// of aligned buffer" -- but 1088 is 136 * 8, so it is exactly that. The +// assertion killed the mutant only because 1088 happens not to be a power of +// two, which is incidental: 2048 is a power of two, is not a whole page, and +// would have passed the guard while regressing every refill. Raised in the +// PR #56 review. +// +// Asserted below is the property actually claimed -- whole pages -- which is +// what makes a refill land on a page boundary instead of straddling one. const _: () = { + assert!( + DEFAULT_BUFFER_CAPACITY.is_multiple_of(PAGE_SIZE), + "the default is sized to whole pages; a value that is merely 'big enough' \ + would pass every functional test while making each refill straddle a \ + page boundary" + ); assert!( DEFAULT_BUFFER_CAPACITY.is_power_of_two(), - "the default is sized to whole pages and record alignments; a value that \ - is merely 'big enough' would pass every functional test while making \ - each refill straddle a boundary" + "kept alongside the page check because it is the stronger statement of \ + the same intent: the default is a round size, not an arbitrary one that \ + happens to divide by the page" ); assert!( DEFAULT_BUFFER_CAPACITY > MINIMUM_BUFFER_CAPACITY, From f62363d15992ac04c65e365368998878f00ac7a3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 13:39:25 -0400 Subject: [PATCH 311/361] docs(guard-alloc): stop overstating what the parse_seed split made reachable The note said the split made the prefix arm, the truncation check and both comparisons in the environment guard reachable. Only the first is true. `parse_seed` is called after that guard, and the tests call `parse_seed` directly, so the `GetEnvironmentVariableW` result branches are exactly as unexercised as they were before. Now says which parts moved and became testable, which did not, and what closing the rest would take -- making the call injectable, which is a larger change than this split and is not pretended to be done. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-guard-alloc/src/lib.rs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/crates/windows-guard-alloc/src/lib.rs b/crates/windows-guard-alloc/src/lib.rs index 44b31e9c..4fb7f8c7 100644 --- a/crates/windows-guard-alloc/src/lib.rs +++ b/crates/windows-guard-alloc/src/lib.rs @@ -142,9 +142,18 @@ fn seed_from_environment() -> Option { /// /// A mutation run made that concrete: the prefix arm, the truncation check, and /// both comparisons in the guard above all survived, and not one of them could -/// have been reached from a test. Splitting the pure half out is what makes -/// them reachable; the impure half that remains is a single Win32 call with no -/// branch of its own. +/// have been reached from a test. +/// +/// **The split closes some of that and not all of it, and an earlier version of +/// this note claimed otherwise.** What became reachable is what moved: the +/// prefix arm, the radix that follows it, the empty-digits case and the overflow +/// checks, all of which tests now drive directly. The truncation check and the +/// two comparisons in [`seed_from_environment`] did **not** move -- they run +/// before this function is called, so a test that calls `parse_seed` cannot +/// reach them, and they remain unexercised. Closing those needs the +/// `GetEnvironmentVariableW` call to be injectable, which is a larger change +/// than this split and is not pretended to be done. Raised in the PR #56 review. + fn parse_seed(digits: &[u16]) -> Option { const ZERO: u16 = b'0' as u16; const LOWER_X: u16 = b'x' as u16; From a9969ed2edd59a98e786d395213c38bb1f89dd32 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 13:53:50 -0400 Subject: [PATCH 312/361] feat(topology)!: implement D-16's bounded coherence retry, which was never built D-16 specifies that `discover` retries until the two Win32 sources agree, and that what survives the bound is a genuine disagreement to represent. None of it existed: `discover` read the relationship walk once, read CPU sets once, folded them and returned. Meanwhile `Observed`'s **public rustdoc** told consumers "the retry in D-16 has already removed the transient cases by the time anything is represented, so what reaches this type is a settled question", and D-17 and D-19 argued from the same premise. That is a false guarantee in the docs of a crate about to publish 0.2.0, and it is the failure mode this repository's own rule names. D-16 was recorded under MMT-1.2 -- whose text is about *representation* -- and the retry it implies was never transcribed into a work item, so nothing ever caused it to be built. No checklist anywhere scheduled it. Caught by the PR #56 review. `discover` now makes up to three passes, comparing which processors each source reports, and returns on the first coherent one. Three because the bound's job is to separate "the machine changed while we were looking" from "these two APIs disagree", and one repeat already does that; the third is slack for two changes in quick succession. The comparison is deliberately narrower than D-16's prose, and the design note now says so. It covers processor **existence**, because that is what a second pass can settle -- a hot-add is not repeated microseconds later. Disagreements about **grouping** are not retried: D-17 establishes those are expected and persistent in the field, so re-reading cannot settle them, and they are already carried as separate per-source observations. Inactive slots are excluded, and that exclusion is load-bearing rather than tidiness: the walk reports a slot for every position up to a group's maximum while CPU Sets reports only real processors, so a raw comparison would exhaust the bound and report a false `Disagreed` on any machine with an unoccupied slot -- which is most of them. The outcome is stated, per D-16's "coherence is stated, not implied", as a new public `Coherence` on the topology: `Agreed`, `Disagreed` carrying what differed on the final pass, or `NotCollected`. It is written to a description but never read back from one, because a file cannot establish that two enumerations agreed -- the same reason D-12 drops observations across that boundary. Breaking: `MachineMemoryTopology` gains a public field, and `Coherence` is a new public type. `Observed`'s rustdoc now says which class of disagreement the retry settles and which it does not, rather than implying it settles all of them -- the overstatement that made this gap invisible. Five tests: both divergence directions separately, since a comparison can be right one way and wrong the other; the inactive-slot exclusion; the coherent case; and a live test that `discover` states an answer rather than leaving the default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/DESIGN-NOTES.md | 25 +++ .../src/granularity/tests.rs | 2 + crates/windows-topology-sys/src/lib.rs | 2 +- crates/windows-topology-sys/src/observed.rs | 15 +- crates/windows-topology-sys/src/topology.rs | 163 +++++++++++++++++- .../src/topology/tests.rs | 124 ++++++++++++- 6 files changed, 316 insertions(+), 15 deletions(-) diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index b1586eed..8d0eed2c 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -259,6 +259,31 @@ here. *Recorded by [CHECKLIST.md](COMPLETED-CHECKLIST.md) MMT-1.2.* +**Implemented 2026-09-04, and it was not before.** This decision was recorded under MMT-1.2 -- whose own +text is about *representation* -- and the retry it specifies was never transcribed into a work item, so +nothing ever caused it to be built. Meanwhile `Observed`'s public rustdoc, [D-17](#d-17) and +[D-19](#d-19) all argued from it as though it existed. The PR #56 review caught the gap. It is the +failure mode this repository's own rule names: a decision recorded only in a design note, with the work +it implies never queued, is orphaned. + +**What was built, and the one place it is narrower than the text above.** `discover` now makes up to +`COHERENCE_ATTEMPTS` (three) passes, comparing **which processors each source reports**, and returns on +the first coherent pass. The comparison is deliberately limited to processor *existence*, because that +is the disagreement a second pass can settle -- a processor hot-added or removed between the two calls +appears in one source and not the other, and is not hot-added again a microsecond later. Disagreements +about **grouping** -- which core, which node -- are not retried, because [D-17](#d-17) establishes those +are expected and persistent, so re-reading cannot settle them; they are carried as separate per-source +observations, which is the representation half of this decision and already worked. + +Inactive slots are excluded from the comparison: the walk reports a slot for every position up to a +group's maximum while CPU Sets reports only real processors, so comparing raw would make any machine +with an unoccupied slot exhaust the bound and report a false `Disagreed` on every call. + +The outcome is stated on the topology as `Coherence` -- `Agreed`, `Disagreed { .. }` with what differed +on the final pass, or `NotCollected` for a topology nobody collected. It is written to a description but +never read back from one, for the same reason [D-12](#d-12) drops observations across that boundary: a +file cannot establish that two enumerations agreed. + ### The problem, stated without the wrong framing `discover()` reads two Win32 sources -- the relationship walk and the CPU-set enumeration -- and diff --git a/crates/windows-topology-sys/src/granularity/tests.rs b/crates/windows-topology-sys/src/granularity/tests.rs index 80774a30..affec81b 100644 --- a/crates/windows-topology-sys/src/granularity/tests.rs +++ b/crates/windows-topology-sys/src/granularity/tests.rs @@ -7,6 +7,7 @@ use crate::observed::Observed; use crate::processor_set::ProcessorSet; use crate::provenance::Provenance; use crate::relation::CacheKind; +use crate::topology::Coherence; use crate::topology::MachineMemoryTopology; /// `count` processors in group 0, all online. @@ -69,6 +70,7 @@ fn topology(processor_count: u8, domains: Vec) -> MachineMemoryTopology domains, cpu_sets: None, provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), } diff --git a/crates/windows-topology-sys/src/lib.rs b/crates/windows-topology-sys/src/lib.rs index 79b7493a..e4a5ce06 100644 --- a/crates/windows-topology-sys/src/lib.rs +++ b/crates/windows-topology-sys/src/lib.rs @@ -128,7 +128,7 @@ pub use relation::{ Relations, discover, }; #[cfg(windows)] -pub use topology::MachineMemoryTopology; +pub use topology::{Coherence, MachineMemoryTopology}; // The crate's markdown documentation is compiled as doctests, so an example that // a contract change invalidates breaks the build instead of quietly teaching the diff --git a/crates/windows-topology-sys/src/observed.rs b/crates/windows-topology-sys/src/observed.rs index 2c1b7f12..8613293e 100644 --- a/crates/windows-topology-sys/src/observed.rs +++ b/crates/windows-topology-sys/src/observed.rs @@ -34,10 +34,17 @@ /// /// Per [D-19](../DESIGN-NOTES.md), a subject the two Win32 sources genuinely /// disagreed about is one the unified view does not cover -- which is -/// [`Observed::NotObserved`], not a fourth state. The retry in -/// [D-16](../DESIGN-NOTES.md) has already removed the transient cases by the -/// time anything is represented, so what reaches this type is a settled -/// question, and "we cannot say" is the honest answer to it. +/// [`Observed::NotObserved`], not a fourth state. +/// +/// [D-16](../DESIGN-NOTES.md)'s retry runs before anything is represented, and +/// removes the transient case it can settle: two enumerations describing +/// different sets of processors because the machine changed between the calls. +/// What it deliberately does not retry is a disagreement about how processors +/// are *grouped*, which [D-17](../DESIGN-NOTES.md) establishes is persistent in +/// the field -- re-reading cannot settle those, so they are carried as separate +/// per-source observations instead. Either way "we cannot say" is the honest +/// answer, and [`crate::Coherence`] on the topology says which of the two +/// happened during collection. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub enum Observed { diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 734810e0..e45f252d 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -1,7 +1,7 @@ // Copyright (c) 2026 Mike Grier //! Assembling a [`MachineMemoryTopology`] from discovered relations. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::io; use crate::EnumerationAnomaly; @@ -12,6 +12,63 @@ use crate::observed::Observed; use crate::processor_set::ProcessorSet; use crate::provenance::Provenance; use crate::relation::{self, Relations}; +/// How many passes [`MachineMemoryTopology::discover`] makes before concluding +/// that a disagreement is genuine. +/// +/// Three, because the bound's job is to separate "the machine changed while we +/// were looking" from "these two APIs disagree", and one repeat already does +/// that: a hot-add is not repeated microseconds later. The third pass is +/// slack for two changes landing in quick succession, and the cost of being +/// wrong in this direction is one more cheap enumeration, where the cost of +/// being wrong in the other is reporting a transient as genuine. +const COHERENCE_ATTEMPTS: u32 = 3; + +/// How the two Win32 sources agreed when a topology was collected. +/// +/// [`MachineMemoryTopology::discover`] reads the relationship walk and the +/// CPU-set enumeration in two separate calls, and Windows offers no +/// transactional way to read them together -- so the pair may describe +/// different instants. [D-16](../DESIGN-NOTES.md#d-16) is the decision this +/// type implements: retry to remove the transient cases, then represent +/// whatever survives. +/// +/// **What is compared is which processors exist**, because that is the +/// disagreement a second pass can actually settle. A processor hot-added or +/// removed between the two calls appears in one source and not the other, and +/// is not hot-added again a microsecond later. Disagreements about how +/// processors are *grouped* -- which core, which node -- are deliberately not +/// retried: [D-17](../DESIGN-NOTES.md#d-17) establishes those are expected in +/// the field and persistent, so re-reading cannot settle them and they are +/// carried as separate per-source observations instead. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Coherence { + /// Not collected from a running system, so the question does not arise. + /// + /// A hand-built or deserialized topology takes this: nothing was read + /// twice, so there is nothing to have agreed. The default for the same + /// reason. + #[default] + NotCollected, + /// Both sources described the same processors on the pass this topology + /// was built from. + Agreed, + /// They never agreed within the retry bound, so the disagreement is + /// genuine rather than transient, and is reported rather than hidden. + /// + /// Per [D-16](../DESIGN-NOTES.md#d-16), exhausting the bound is not a + /// failure to collect -- it is the *conclusion* that the disagreement is + /// real. The topology is returned, and this says what differed on the + /// final pass so a caller can decide what that means for its own use. + Disagreed { + /// Processors the relationship walk reported and CPU Sets did not. + walk_only: Vec, + /// Processors CPU Sets reported and the relationship walk did not. + cpu_sets_only: Vec, + /// How many passes were made before giving up. + attempts: u32, + }, +} /// A processor, cache, and memory topology: a set of processors and the /// domains that relate them. @@ -104,6 +161,24 @@ pub struct MachineMemoryTopology { /// fields above and is still correct. #[cfg_attr(feature = "serde", serde(default))] pub enumeration_anomalies: Vec, + /// Whether the two Win32 sources described the same machine when this was + /// collected. + /// + /// Stated rather than implied, per [D-16](../DESIGN-NOTES.md#d-16): a + /// reader cannot infer from the data's shape how far its parts may be + /// *correlated* with each other, and that is a different question from + /// whether any single part is accurate. + /// + /// **Written out, never read back in.** A description is welcome to carry + /// this so a human reading a dump can see how the run that produced it + /// went, but a file cannot *establish* that two enumerations agreed -- + /// exactly as it cannot establish that the relationship walk observed + /// anything (D-12). So deserialization always yields + /// [`Coherence::NotCollected`], which is the true answer for a topology + /// nobody collected, and a description claiming `Agreed` gains nothing by + /// saying so. + #[cfg_attr(feature = "serde", serde(skip_deserializing))] + pub coherence: Coherence, } impl MachineMemoryTopology { @@ -114,6 +189,40 @@ impl MachineMemoryTopology { /// Returns any error from the underlying `GetLogicalProcessorInformationEx` /// or `GetSystemCpuSetInformation` calls. pub fn discover() -> io::Result { + // D-16's retry. Both calls are whole-machine enumerations and cheap, so + // a repeat costs almost nothing, and it is not plausible that several + // passes in a row fail to find a coherent set. + // + // Bounded, and the bound is the point: exhausting it is not a failure + // to collect, it is the conclusion that the disagreement is genuine. + // The topology is returned either way, with `coherence` saying which + // happened. + let mut last = None; + for attempt in 1..=COHERENCE_ATTEMPTS { + let (topology, cpu_sets) = Self::collect_once()?; + let (walk_only, cpu_sets_only) = topology.processor_divergence(&cpu_sets); + if walk_only.is_empty() && cpu_sets_only.is_empty() { + return Ok(topology.finish(cpu_sets, Coherence::Agreed)); + } + last = Some((topology, cpu_sets, walk_only, cpu_sets_only, attempt)); + } + + // Every pass disagreed. What survives a retry is not transience, so it + // is represented rather than retried further or refused over. + let (topology, cpu_sets, walk_only, cpu_sets_only, attempts) = + last.expect("COHERENCE_ATTEMPTS is a non-zero constant, so the loop ran at least once"); + Ok(topology.finish( + cpu_sets, + Coherence::Disagreed { + walk_only, + cpu_sets_only, + attempts, + }, + )) + } + + /// One pass over both sources, in the order the fold needs them. + fn collect_once() -> io::Result<(Self, Vec)> { let relations = relation::discover()?; let mut topology = Self::from_relations(relations); // Both are cheap reads of the running system, so both belong to @@ -124,16 +233,56 @@ impl MachineMemoryTopology { topology.record_walk_attributes(); let (cpu_sets, cpu_set_anomaly) = crate::cpu_set::enumerate()?; topology.enumeration_anomalies.extend(cpu_set_anomaly); + // Returned unfolded, because the coherence question is asked of the two + // sources *before* they are combined -- once folded, which source said + // what about a processor's existence is no longer a question the shape + // can answer. + Ok((topology, cpu_sets)) + } + + /// Combine the pass's two sources and state how they agreed. + fn finish(mut self, cpu_sets: Vec, coherence: Coherence) -> Self { // Folded into the relation set, and *also* kept verbatim. Not a // contradiction: D-19's unified view is presented **in addition to** // the individual per-source ones, so a caller wanting what CPU Sets // said, in its own shape, still has it. - topology.fold_in_cpu_sets(&cpu_sets); - topology.cpu_sets = Some(cpu_sets); + self.fold_in_cpu_sets(&cpu_sets); + self.cpu_sets = Some(cpu_sets); + self.coherence = coherence; // The one place in the crate that may claim this is the machine you are // on, because it is the one place that asked the operating system. - topology.provenance = Provenance::Measured; - Ok(topology) + self.provenance = Provenance::Measured; + self + } + + /// Which processors one source reported and the other did not. + /// + /// Existence only. See [`Coherence`] for why grouping disagreements are + /// deliberately outside this comparison. + fn processor_divergence(&self, cpu_sets: &[CpuSet]) -> (Vec, Vec) { + // Only processors the walk calls active are comparable: it reports a + // slot for every position up to a group's maximum, including ones no + // processor occupies, and CPU Sets reports only real processors. A raw + // set comparison would therefore diverge on every machine with an + // inactive slot, which is a difference in what the two APIs enumerate + // rather than a difference about the machine. + let walk: BTreeSet = self + .processors + .iter() + .filter(|processor| processor.online) + .map(|processor| processor.id) + .collect(); + let sets: BTreeSet = cpu_sets + .iter() + .map(|set| ProcessorId { + group: set.group, + number: set.logical_processor_index, + }) + .collect(); + ( + walk.difference(&sets).copied().collect(), + sets.difference(&walk).copied().collect(), + ) } /// Record what the CPU-set enumeration says about relations, unifying with @@ -421,6 +570,10 @@ impl MachineMemoryTopology { // machine -- so if this ever gains a second caller, that caller // does not silently inherit an assertion it has not earned. provenance: Provenance::Synthetic, + // For the same reason: one set of relations is one source, and + // coherence is a statement about two agreeing. `discover` sets this + // once it has both. + coherence: Coherence::NotCollected, // Carried, not re-derived: whatever the walk could not decode is a // fact about the enumeration that produced these relations, and a // pure transform is the wrong place to lose it. diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index 630b3954..bb793d6b 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -78,6 +78,7 @@ fn synthetic() -> MachineMemoryTopology { // Named rather than defaulted, so this fixture states what it is. The // helper is called `synthetic` and now says so in the value too. provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), } @@ -182,12 +183,13 @@ mod serde_tests { // are on", and once written to a file it can no longer assert that. // Reloading yields `Restored`. // - // Three things are downgraded across the boundary, for one reason. The + // Four things are downgraded across the boundary, for one reason. The // provenance drops to `Restored`; every relation's platform - // observations are dropped; and so are the per-processor attribute - // claims -- because a file saying "the relationship walk observed this" - // cannot establish that it did, and carrying the claim would be exactly - // the forgery D-12 refuses. + // observations are dropped; so are the per-processor attribute claims; + // and the coherence drops to `NotCollected` -- because a file saying + // "the relationship walk observed this" or "the two enumerations + // agreed" cannot establish that it did, and carrying the claim would be + // exactly the forgery D-12 refuses. // // The assertion is deliberately not weakened to "the parts I still // expect to match". Everything else must survive verbatim, so this @@ -224,9 +226,15 @@ mod serde_tests { back.processor_attributes.is_empty(), "nor may a restored topology claim a source said anything per processor" ); + assert_eq!( + back.coherence, + Coherence::NotCollected, + "nor may it claim the two enumerations agreed, which only a collection can establish" + ); let mut expected = topology; expected.provenance = Provenance::Restored; + expected.coherence = Coherence::NotCollected; expected.processor_attributes.clear(); for domain in &mut expected.domains { domain.observations.clear(); @@ -328,6 +336,102 @@ mod serde_tests { ); } } + /// A topology whose walk reports exactly `processors` in group 0. + fn walk_reporting(processors: &[(u8, bool)]) -> MachineMemoryTopology { + MachineMemoryTopology { + processors: processors + .iter() + .map(|&(number, online)| Processor { + id: ProcessorId { group: 0, number }, + online, + capacity: 0, + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn two_sources_reporting_the_same_processors_are_coherent() { + let topology = walk_reporting(&[(0, true), (1, true)]); + let sets = vec![cpu_set(0, 0, 0, 0), cpu_set(1, 0, 0, 0)]; + assert_eq!( + topology.processor_divergence(&sets), + (Vec::new(), Vec::new()), + "identical processor sets are the coherent case" + ); + } + + #[test] + fn a_processor_only_the_walk_saw_is_a_divergence() { + // The hot-remove shape: the walk enumerated a processor that was gone + // by the time CPU Sets was asked. D-16's retry exists to find out + // whether that persists. + let topology = walk_reporting(&[(0, true), (1, true)]); + let sets = vec![cpu_set(0, 0, 0, 0)]; + let (walk_only, cpu_sets_only) = topology.processor_divergence(&sets); + assert_eq!( + walk_only, + vec![ProcessorId { + group: 0, + number: 1 + }] + ); + assert!(cpu_sets_only.is_empty()); + } + + #[test] + fn a_processor_only_cpu_sets_saw_is_a_divergence() { + // The hot-add shape, and asserted separately because a comparison that + // comes out right in one direction can be wrong in the other. + let topology = walk_reporting(&[(0, true)]); + let sets = vec![cpu_set(0, 0, 0, 0), cpu_set(1, 0, 0, 0)]; + let (walk_only, cpu_sets_only) = topology.processor_divergence(&sets); + assert!(walk_only.is_empty()); + assert_eq!( + cpu_sets_only, + vec![ProcessorId { + group: 0, + number: 1 + }] + ); + } + + #[test] + fn an_inactive_slot_is_not_a_divergence() { + // The walk reports a slot for every position up to a group's maximum, + // occupied or not, and CPU Sets reports only real processors. Comparing + // those raw would make every machine with an inactive slot look + // incoherent forever -- three passes, then a false `Disagreed`. That is + // a difference in what the two APIs enumerate, not about the machine. + let topology = walk_reporting(&[(0, true), (1, false)]); + let sets = vec![cpu_set(0, 0, 0, 0)]; + assert_eq!( + topology.processor_divergence(&sets), + (Vec::new(), Vec::new()), + "an offline slot is outside the comparison" + ); + } + + #[test] + fn a_discovered_topology_states_its_coherence() { + // The live path. This machine's two enumerations agree, so the retry + // settles on the first pass -- but what is asserted is that `discover` + // *states* an answer, never leaving the default that means "nobody + // collected this". + let topology = MachineMemoryTopology::discover().expect("discover"); + assert_ne!( + topology.coherence, + Coherence::NotCollected, + "a collected topology must say how its two sources agreed" + ); + assert_eq!( + topology.coherence, + Coherence::Agreed, + "the two enumerations disagree about which processors exist on this host" + ); + } + /// A CPU-set record for one processor in group 0. fn cpu_set(index: u8, core: u8, node: u8, efficiency_class: u8) -> crate::cpu_set::CpuSet { crate::cpu_set::CpuSet { @@ -381,6 +485,7 @@ mod serde_tests { domains: vec![core_domain(0, &[0, 1, 2, 3], 0)], cpu_sets: None, provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; @@ -427,6 +532,7 @@ mod serde_tests { domains: Vec::new(), cpu_sets: None, provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; @@ -461,6 +567,7 @@ mod serde_tests { domains: vec![core_domain(7, &[0, 1], 0)], cpu_sets: None, provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; @@ -495,6 +602,7 @@ mod serde_tests { domains: vec![memory], cpu_sets: None, provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; @@ -620,6 +728,7 @@ mod serde_tests { cpu_sets: None, processor_attributes: Vec::new(), provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), }; topology.record_walk_attributes(); @@ -666,6 +775,7 @@ mod serde_tests { cpu_sets: None, processor_attributes: Vec::new(), provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), }; topology.record_walk_attributes(); @@ -696,6 +806,7 @@ mod serde_tests { cpu_sets: None, processor_attributes: Vec::new(), provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), }; assert!(topology.processor_attributes.is_empty()); @@ -1110,6 +1221,7 @@ fn split_l1_machine(cores: u32, last_level: u8) -> MachineMemoryTopology { domains, cpu_sets: None, provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), } @@ -1128,6 +1240,7 @@ fn cache_levels_are_empty_when_no_cache_is_reported() { domains: Vec::new(), cpu_sets: None, provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), processor_attributes: Vec::new(), }; @@ -1365,6 +1478,7 @@ fn machine_of(count: u8, domains: Vec) -> MachineMemoryTopology { cpu_sets: None, processor_attributes: Vec::new(), provenance: Provenance::Synthetic, + coherence: Coherence::NotCollected, enumeration_anomalies: Vec::new(), } } From 0da95a382fc942b5cff08b16028a9b57a0b71075 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:04:20 -0400 Subject: [PATCH 313/361] fix(topology): reject a float at 2^64 instead of saturating it to u64::MAX `u64::MAX as f64` rounds *up* to 2^64, one greater than any `u64`, so the inclusive range in `as_u64` admitted exactly that value and the cast saturated it to `u64::MAX`. A description carrying 18446744073709551616 was read back as a different number, silently. The bound is now exclusive, which costs nothing: the largest `f64` below 2^64 is 2^64-2048, a representable `u64`, still accepted. Swept for the same shape rather than fixing only the reported site, and `as_i64` had it too -- `i64::MAX as f64` rounds up to 2^63. Its lower bound stays inclusive, because `i64::MIN as f64` is -2^63 exactly: a power of two, representable, and it converts back losslessly. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/src/domain.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/windows-topology-sys/src/domain.rs b/crates/windows-topology-sys/src/domain.rs index e6fc90ad..57c4400a 100644 --- a/crates/windows-topology-sys/src/domain.rs +++ b/crates/windows-topology-sys/src/domain.rs @@ -352,7 +352,16 @@ mod serde_impl { u64::try_from(n).map_err(|_| E::custom("expected a non-negative whole number")) } AttributeValue::Float(n) - if n.fract() == 0.0 && (0.0..=u64::MAX as f64).contains(&n) => + // **Exclusive at the top, and that is not a style choice.** + // `u64::MAX as f64` rounds *up* to 2^64, which is one greater than + // any `u64`. An inclusive bound therefore admitted exactly 2^64, + // and `n as u64` saturates it to `u64::MAX` -- so a description + // carrying 18446744073709551616 was silently read as a different + // number. Excluding the bound rejects it instead, and costs + // nothing: the largest `f64` below 2^64 is 2^64 - 2048, which is a + // representable `u64` and still accepted. Raised in the PR #56 + // review. + if n.fract() == 0.0 && (0.0..u64::MAX as f64).contains(&n) => { Ok(n as u64) } @@ -376,7 +385,13 @@ mod serde_impl { i64::try_from(n).map_err(|_| E::custom("expected a whole number")) } AttributeValue::Float(n) - if n.fract() == 0.0 && (i64::MIN as f64..=i64::MAX as f64).contains(&n) => + // Exclusive at the top for the reason `as_u64` gives, and found by + // sweeping for the same shape rather than reported: `i64::MAX as + // f64` rounds up to 2^63, which no `i64` can hold, and the cast + // would saturate it to `i64::MAX`. The *lower* bound stays + // inclusive because `i64::MIN as f64` is -2^63 exactly -- it is a + // power of two and representable, so it converts back losslessly. + if n.fract() == 0.0 && (i64::MIN as f64..i64::MAX as f64).contains(&n) => { Ok(n as i64) } From a3d52a384a596ac0540d9c6401b550c5d28e3957 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:04:42 -0400 Subject: [PATCH 314/361] fix(placement-probe)!: refuse a processor no memory domain names, never invent node 0 `places_from_topology` fell back to node 0 whenever the topology named no memory domain, reasoning that such a machine has one node and every processor is in it. That reasoning invents a fact. `MachineMemoryTopology` reports an unobserved memory domain as `NotObserved` and offers no node-zero default, so the probe was manufacturing a placement the topology had declined to state -- and this number reaches `VirtualAllocExNuma`, so the run would allocate on a node nobody established and record the timing as though it had. Now refuses the processor, matching the `cache_domain` arm corrected earlier in this same review cycle for exactly this reason: an unknown must not be promoted to a finding in a tool whose product is measurements other people are asked to trust. Twelve tests encoded the fallback, which is what a fabrication looks like once it has bedded in. The shared `bare_processors` fixture named no memory domain, so every test using it was quietly also asserting the invented node; it now names the node it means, and each test is about the thing it is named for again. `node_zero_is_the_answer_only_when_no_memory_domain_exists` asserted the fallback directly and is replaced by `a_topology_naming_no_memory_domain_is_refused_rather_than_defaulted`. The bare-render test is the one worth reading: it pinned `numa[]` and asserted the node list summed to *less* than the processor count. Both were artifacts -- the list was suppressed precisely because a count built from an invented node would be indistinguishable from a real one-node host. With the fixture naming a real node it renders `numa[4]` and sums to 4, and the comment says why it changed. Breaking: a topology naming no memory domain is now refused rather than placed. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/fingerprint.rs | 25 ++-- .../src/fingerprint/tests.rs | 115 ++++++++++++++---- 2 files changed, 106 insertions(+), 34 deletions(-) diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index d16b450f..0872b927 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -720,10 +720,10 @@ pub fn places_from_topology( } } + // No `any_memory_domain` flag any more: it existed only to license the + // node-zero fallback below, and that fallback invented a placement. let mut numa_of = std::collections::BTreeMap::new(); - let mut any_memory_domain = false; for domain in topology.memory_domains() { - any_memory_domain = true; // The relationship walk's label, which is the real Windows NUMA node // number -- NOT a position. This value reaches `VirtualAllocExNuma`, // so a positional index would allocate on the wrong node on any machine @@ -799,13 +799,20 @@ pub fn places_from_topology( None if !any_cache_partition => Observed::Absent, None => Observed::NotObserved, }; - let numa_node = match numa_of.get(&id).copied() { - Some(node) => node, - // The same rule again: a topology naming no memory domain - // describes a machine with one node, and every processor is in - // it. - None if !any_memory_domain => 0, - None => return refuse(MissingPlacement::NumaNode), + // **No fallback, deliberately.** This previously read node 0 when + // the topology named no memory domain, on the reasoning that such + // a machine has one node and every processor is in it. That + // reasoning invents a fact: `MachineMemoryTopology` reports an + // unobserved memory domain as `NotObserved` and offers no + // node-zero default, so the probe was manufacturing a placement + // the topology declined to state -- and this number is passed to + // `VirtualAllocExNuma`, which then allocates on a node nobody + // established. Refusing the processor is the honest answer, and it + // matches the `cache_domain` arm above, which was corrected + // earlier in this same review cycle for exactly this reason. + // Raised in the PR #56 review. + let Some(numa_node) = numa_of.get(&id).copied() else { + return refuse(MissingPlacement::NumaNode); }; Ok(ProcessorPlace { diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index 147a7792..01130087 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -758,14 +758,33 @@ mod multi_group_conversion { capacity: 0, }) .collect(), - domains: vec![Domain { - kind: DomainKind::Group, - processors: ProcessorSet::from_group_mask(0, mask), - observations: vec![windows_topology_sys::Observation::new( - windows_topology_sys::Source::RelationshipWalk, - 0, - )], - }], + domains: vec![ + Domain { + kind: DomainKind::Group, + processors: ProcessorSet::from_group_mask(0, mask), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 0, + )], + }, + // A memory domain, because these fixtures exist to test what + // happens with no *core* domain and every one of them would + // otherwise be refused for an unrelated reason. It used to be + // absent, and the conversion invented node 0 to cover for it -- + // so each of these tests was quietly also asserting that + // fabrication. Supplying the node keeps each test about the + // thing it is named for. + Domain { + kind: DomainKind::Memory { + memory_bytes: windows_topology_sys::Observed::NotObserved, + }, + processors: ProcessorSet::from_group_mask(0, mask), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 0, + )], + }, + ], cpu_sets: None, ..Default::default() } @@ -800,6 +819,18 @@ mod multi_group_conversion { online: true, capacity: 0, }); + // Group 1 needs its own memory domain, because a processor no memory + // domain names is now refused rather than defaulted to node 0. + topology.domains.push(Domain { + kind: DomainKind::Memory { + memory_bytes: windows_topology_sys::Observed::NotObserved, + }, + processors: ProcessorSet::from_group_mask(1, 0b1), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 1, + )], + }); topology.domains.push(Domain { kind: DomainKind::Group, processors: ProcessorSet::from_group_mask(1, 0b1), @@ -836,12 +867,24 @@ mod multi_group_conversion { } #[test] - fn node_zero_is_the_answer_only_when_no_memory_domain_exists() { - // The half of the default that is correct: a topology naming no memory - // domain describes one node, and every processor is in it. - let places = places_from_topology(&bare_processors(2)).expect("one implicit node"); + fn a_topology_naming_no_memory_domain_is_refused_rather_than_defaulted() { + // **This test asserted the opposite until the PR #56 review.** It said a + // topology naming no memory domain "describes one node, and every + // processor is in it", and node 0 was supplied for them all. That reads + // as a reasonable default and is a fabrication: the topology declined to + // state a memory domain, `MachineMemoryTopology` reports that as + // `NotObserved` with no node-zero fallback of its own, and the number + // invented here reaches `VirtualAllocExNuma` -- so the probe would + // allocate on a node nobody established and measure the result as + // though it had. + let mut topology = bare_processors(2); + topology + .domains + .retain(|domain| !matches!(domain.kind, DomainKind::Memory { .. })); - assert!(places.iter().all(|p| p.numa_node == 0)); + let refusal = places_from_topology(&topology) + .expect_err("a placement nobody observed must not be invented"); + assert_eq!(refusal.missing, MissingPlacement::NumaNode); } #[test] @@ -851,6 +894,11 @@ mod multi_group_conversion { // this machine does not have, which is exactly the fabricated label the // crate's own seam rule forbids. let mut topology = bare_processors(3); + // Drop the fixture's blanket node so cpu2 is genuinely outside every + // named domain; the two pushed below cover only cpu0 and cpu1. + topology + .domains + .retain(|domain| !matches!(domain.kind, DomainKind::Memory { .. })); for (id, mask) in [(1_u32, 0b001_usize), (2, 0b010)] { topology.domains.push(Domain { kind: DomainKind::Memory { @@ -959,6 +1007,18 @@ mod multi_group_conversion { online: true, capacity: 0, }); + // Group 1 needs its own memory domain, because a processor no memory + // domain names is now refused rather than defaulted to node 0. + topology.domains.push(Domain { + kind: DomainKind::Memory { + memory_bytes: windows_topology_sys::Observed::NotObserved, + }, + processors: ProcessorSet::from_group_mask(1, 0b1), + observations: vec![windows_topology_sys::Observation::new( + windows_topology_sys::Source::RelationshipWalk, + 1, + )], + }); topology.domains.push(Domain { kind: DomainKind::Group, processors: ProcessorSet::from_group_mask(1, 0b1), @@ -1130,12 +1190,16 @@ mod multi_group_conversion { // `L-[4]` improved silently: the unpartitioned branch fills the cache // list with the processor count, so it used to read `L-[0]`. // - // `numa[]` is the deliberate asymmetry. Every placement for this - // topology reports node 0, so the node list no longer sums to the - // processor count -- but `L-` marks the cache absence and nothing marks - // a NUMA one, so rendering `numa[4]` would be indistinguishable from a - // host that really did report one node of four, and the more useful fact - // would be lost. See the field docs and PT-6.2. + // **`numa[4]`, and it used to be `numa[]`.** The old expectation rested + // on a fabrication: this fixture named no memory domain, the conversion + // invented node 0 for every processor, and the render then suppressed + // the node list because a `numa[4]` built from an invented node would be + // indistinguishable from a host that really did report one node of four. + // The fixture now names the node it means, so the count is real and is + // rendered. A topology that genuinely names no memory domain is refused + // outright rather than rendered -- see + // `a_topology_naming_no_memory_domain_is_refused_rather_than_defaulted`. + // See the field docs and PT-6.2. let fingerprint = Fingerprint::from_topology(&bare_processors(4)); // The `!!SYNTHETIC!!` prefix is load-bearing rather than noise: a @@ -1144,15 +1208,16 @@ mod multi_group_conversion { assert_eq!( fingerprint.to_string(), format!( - "!!SYNTHETIC!! {} 4p/0c smt- L-[4] ec[] numa[]", + "!!SYNTHETIC!! {} 4p/0c smt- L-[4] ec[] numa[4]", std::env::consts::ARCH ) ); - assert!( - fingerprint.numa_node_sizes.iter().sum::() < fingerprint.processors, - "the node list is what the topology reported, not a partition of the \ - processors; this asymmetry is documented, so a future change that \ - removes it should fail here and be made on purpose" + assert_eq!( + fingerprint.numa_node_sizes.iter().sum::(), + fingerprint.processors, + "this fixture now names one node covering every processor, so the list \ + sums to the count -- it previously summed to less only because the \ + node was invented and the render suppressed it" ); } From 9257928510df3a6adcefd3aa6d90c0ddb803deef Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:04:42 -0400 Subject: [PATCH 315/361] docs(file-enumeration): correct the page rationale, which over-claimed in turn The previous commit replaced an over-claim with another one, caught by the same review. It said a page-multiple length makes "each refill begin on a page boundary". It does not: `NativeBuffer` stores `Vec`, so the allocation is 8-byte aligned and a length that is a whole number of pages says nothing about where the buffer starts. What the constraint actually buys is a page-sized *request* -- a round amount for the kernel to fill, with no part-page tail. Alignment would need page-aligned storage, which this buffer does not have and does not need. The assertion is unchanged and still rejects 1088 and 2048; only the claim it makes about itself is corrected. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-file-enumeration-sys/src/request.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/windows-file-enumeration-sys/src/request.rs b/crates/windows-file-enumeration-sys/src/request.rs index e23436c4..3c36bcad 100644 --- a/crates/windows-file-enumeration-sys/src/request.rs +++ b/crates/windows-file-enumeration-sys/src/request.rs @@ -60,14 +60,20 @@ const PAGE_SIZE: usize = 4096; // would have passed the guard while regressing every refill. Raised in the // PR #56 review. // -// Asserted below is the property actually claimed -- whole pages -- which is -// what makes a refill land on a page boundary instead of straddling one. +// **And the first repair over-claimed in turn**, which the same review caught: +// it said a page-multiple length makes "each refill begin on a page boundary". +// It does not. `NativeBuffer` stores `Vec`, so the allocation is 8-byte +// aligned and nothing more -- a length that is a whole number of pages says +// nothing about where the buffer starts. What the constraint actually buys is a +// page-sized *request*: a round amount for the kernel to fill per call, and one +// that does not leave a part-page tail. Alignment would need page-aligned +// storage, which this buffer does not have and does not need. const _: () = { assert!( DEFAULT_BUFFER_CAPACITY.is_multiple_of(PAGE_SIZE), - "the default is sized to whole pages; a value that is merely 'big enough' \ - would pass every functional test while making each refill straddle a \ - page boundary" + "the default is a whole number of pages in length; a value that is merely \ + 'big enough' would pass every functional test while asking the kernel for \ + a ragged part-page amount on every refill" ); assert!( DEFAULT_BUFFER_CAPACITY.is_power_of_two(), From c45c94865fcdb9214196c17ab9cc08956c2ea870 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:12:06 -0400 Subject: [PATCH 316/361] docs(file-enumeration): correct the second copy of the page-alignment claim The previous commit fixed the assertion's rationale and left the identical over-claim in `PAGE_SIZE`'s own doc comment six lines above, still saying a whole number of pages makes "each refill begin on a page boundary". Both copies are now corrected, and the note records that there were two. That is the failure this PR has now produced five times and the second time in the same file: a fact restated in more places than it was checked. Fixing the reported site is not fixing the claim. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-enumeration-sys/src/request.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/windows-file-enumeration-sys/src/request.rs b/crates/windows-file-enumeration-sys/src/request.rs index 3c36bcad..41912f71 100644 --- a/crates/windows-file-enumeration-sys/src/request.rs +++ b/crates/windows-file-enumeration-sys/src/request.rs @@ -38,10 +38,16 @@ pub(crate) const RECORD_ALIGNMENT: usize = 8; /// The page size every Windows target this crate builds for uses. /// /// Named rather than written as `4096` at the assertion site, and stated here -/// because it is the unit [`DEFAULT_BUFFER_CAPACITY`] is chosen in: a buffer -/// that is a whole number of pages has each refill begin on a page boundary. -/// Large-page allocations are a different mechanism and are not what this -/// buffer uses. +/// because it is the unit [`DEFAULT_BUFFER_CAPACITY`]'s *length* is chosen in. +/// +/// **It says nothing about alignment**, and an earlier version of this comment +/// claimed it did -- twice, in two places six lines apart, of which only one +/// was corrected on the first attempt. [`crate::buffer::NativeBuffer`] stores +/// `Vec`, so the allocation is 8-byte aligned; a length that is a whole +/// number of pages does not decide where the buffer *starts*, and a refill can +/// begin partway through a page. What the constraint buys is a round request +/// size with no part-page tail. Large-page allocations are a different +/// mechanism and are not what this buffer uses. const PAGE_SIZE: usize = 4096; // The relationships these capacities depend on, checked by the compiler rather From 9c166ad79b193703af61c3678a4286f0a813bc00 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:12:06 -0400 Subject: [PATCH 317/361] docs(file-watcher): say that long-path opt-in does not lift MAX_PATH for relative paths `subscribe`'s note gave two escapes for a path longer than `MAX_PATH` -- the `longPathAware` manifest plus `LongPathsEnabled`, or an already-prefixed path -- without saying that neither applies to a relative path. Since the same paragraph advertises that relative paths are accepted, it invited the reading that the machine setting is enough for any path, and an overlong relative path then fails to open however the machine is configured. A relative path is always limited to `MAX_PATH` in total, because the `\\?\` prefix cannot be used with one: the prefix means "do not resolve this", and a relative path exists to be resolved against the current directory. The note now splits the two cases and says to make the path absolute if it may exceed the limit. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/src/session.rs | 25 +++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/crates/windows-file-watcher/src/session.rs b/crates/windows-file-watcher/src/session.rs index 7b2ced99..8ffa50f3 100644 --- a/crates/windows-file-watcher/src/session.rs +++ b/crates/windows-file-watcher/src/session.rs @@ -69,11 +69,26 @@ impl Session { /// `path` reaches Win32 **verbatim** (D-85). It may be relative, may use /// forward slashes, and may contain `.` or `..`, because this crate does not /// prepend `\\?\` on a caller's behalf -- that prefix selects a different - /// path parsing mode in which none of those resolve. The consequence worth - /// knowing: a path longer than `MAX_PATH` opens only if **your** - /// application declares `longPathAware` in its manifest and the machine has - /// `LongPathsEnabled` set, or if you pass an already-`\\?\`-prefixed path, - /// which is forwarded unchanged. + /// path parsing mode in which none of those resolve. + /// + /// The consequence worth knowing is about length, and it differs for the + /// two kinds of path: + /// + /// - **Absolute.** A path longer than `MAX_PATH` opens if **your** + /// application declares `longPathAware` in its manifest *and* the machine + /// has `LongPathsEnabled` set, or if you pass an already-`\\?\`-prefixed + /// path, which is forwarded unchanged. + /// - **Relative.** Neither escape applies. A relative path is always + /// limited to `MAX_PATH` in total, because the `\\?\` prefix cannot be + /// used with one -- the prefix means "do not resolve this", and a + /// relative path exists to be resolved against the current directory. + /// Opting into long paths does not lift that, so an overlong relative + /// path fails to open however the machine and manifest are configured. + /// Make it absolute if it may exceed `MAX_PATH`. + /// + /// An earlier version of this note gave the two escapes without that + /// distinction, which invited the reading that the manifest setting is + /// enough for any path. Raised in the PR #56 review. /// /// # Errors /// From 92bd9e81bc4fe3f272ad687cb3a2319317ed2cd4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:17:25 -0400 Subject: [PATCH 318/361] docs(file-watcher): correct the relative-path long-path note, which I got backwards The previous commit claimed a relative path is limited to `MAX_PATH` however the machine and manifest are configured. That is wrong, and the engineer caught it. Microsoft's `Maximum Path Length Limitation` page puts the sentence "relative paths are always limited to a total of MAX_PATH characters" inside the **\\?\ prefix** discussion, where it is a consequence of that mechanism: the prefix cannot be applied to a relative path, so that route is unavailable to one. The separate "Enable long paths in Windows 10, version 1607, and later" section says MAX_PATH limitations "have been removed from many common Win32 file and directory functions" and lists `CreateFileW` among them, with no relative-path exclusion. The opt-in is a property of the call, not of the shape of the path. So a long relative path is fine in a long-path-aware process, and has no escape at all in one that has not opted in. The note now says that, and separates the two escapes by which paths each can serve. How this happened is worth recording. The review finding asserted the opposite, and rather than reading the primary source I ran a web search, got blog posts agreeing with it, and reported the claim as verified. Two failures: taking a secondary source as confirmation, and describing that as verification. The MSDN page settles it in two paragraphs and was one fetch away. Typed `docs` and not breaking: no behaviour changes, only the documented contract, and the correction widens what callers may do rather than narrowing it. The first push of this commit carried a `!` by mistake, contradicting this very paragraph; amended before anything built on it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-file-watcher/src/session.rs | 33 +++++++++++----------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/crates/windows-file-watcher/src/session.rs b/crates/windows-file-watcher/src/session.rs index 8ffa50f3..e3098524 100644 --- a/crates/windows-file-watcher/src/session.rs +++ b/crates/windows-file-watcher/src/session.rs @@ -71,24 +71,25 @@ impl Session { /// prepend `\\?\` on a caller's behalf -- that prefix selects a different /// path parsing mode in which none of those resolve. /// - /// The consequence worth knowing is about length, and it differs for the - /// two kinds of path: + /// The consequence worth knowing: a path longer than `MAX_PATH` opens only + /// by one of two escapes, and **only the first is available to a relative + /// path**. /// - /// - **Absolute.** A path longer than `MAX_PATH` opens if **your** - /// application declares `longPathAware` in its manifest *and* the machine - /// has `LongPathsEnabled` set, or if you pass an already-`\\?\`-prefixed - /// path, which is forwarded unchanged. - /// - **Relative.** Neither escape applies. A relative path is always - /// limited to `MAX_PATH` in total, because the `\\?\` prefix cannot be - /// used with one -- the prefix means "do not resolve this", and a - /// relative path exists to be resolved against the current directory. - /// Opting into long paths does not lift that, so an overlong relative - /// path fails to open however the machine and manifest are configured. - /// Make it absolute if it may exceed `MAX_PATH`. + /// - **Long-path opt-in -- works for relative and absolute alike.** If + /// **your** application declares `longPathAware` in its manifest *and* + /// the machine has `LongPathsEnabled` set, `CreateFileW` is one of the + /// functions Windows lists as no longer carrying a `MAX_PATH` + /// restriction, and that is a property of the call rather than of the + /// shape of the path. + /// - **The `\\?\` prefix -- absolute only.** Windows states that a + /// relative path is limited to `MAX_PATH` *because the prefix cannot be + /// applied to one*: the prefix means "pass this through with minimal + /// modification", so there is nothing left to resolve it against. That + /// limit is a consequence of the prefix route, not a separate ceiling + /// the opt-in leaves standing. /// - /// An earlier version of this note gave the two escapes without that - /// distinction, which invited the reading that the manifest setting is - /// enough for any path. Raised in the PR #56 review. + /// So a long relative path is fine in a long-path-aware process, and has + /// no escape at all in one that has not opted in. /// /// # Errors /// From 13171f6e9c6a5d0a9032fb7ba4a76000253ec5d7 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:24:00 -0400 Subject: [PATCH 319/361] docs(file-watcher): stop the long-path note asserting behaviour nobody measured The corrected note still stated an outcome as fact. It is what Microsoft documents, not what this workspace has run, and this exact question has now produced two wrong answers from reading alone -- the review round's, and mine accepting it. The engineer's point is that documentation cannot settle it, and named the hypothesis that makes it falsifiable: a plausible implementation of the opt-in is to regularize the path and prepend `\\?\` before proceeding, and that prefix is precisely what disables `.`, `..` and forward-slash translation. If that is how it works, a relative path using any of those could resolve under `MAX_PATH` and fail over it -- a sharp edge no page states, and one callers here would meet directly, since this crate passes paths to Win32 verbatim (D-85) and cannot mask it. So the note now separates what is documented from what is verified, says plainly that nothing here has run the experiment, and tells a caller relying on `..` in a long relative path to measure it on their own target. Queued as M35.1 rather than left as a doubt in prose, following D-23's precedent for the unverifiable CPU-set flag bits. The reason it is not answered now is narrow and recorded: `LongPathsEnabled` is already 1 on the development host, so the machine half of the opt-in is satisfied, and what is missing is a test binary carrying `longPathAware` in its manifest, which no crate here has yet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST.md | 29 ++++++++++++++++++++++ crates/windows-file-watcher/src/session.rs | 22 ++++++++++++++-- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index d5949639..7431b682 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -172,6 +172,35 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. discussion thread, so "can this be captured and asserted end to end?" has real value there rather than being architectural tidiness. +## M35 -- Measure what the long-path opt-in actually does + +- [ ] **M35.1** -- **Measure whether the long-path opt-in lifts `MAX_PATH` for a *relative* path, and + whether it does so without changing how the path is parsed.** A probe, not a doc edit: the question + is about behaviour and has already produced two wrong answers from reading alone. + **The state of the evidence.** Microsoft's + [Maximum Path Length Limitation](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation) + puts "relative paths are always limited to a total of MAX_PATH characters" inside the `\\?\` + **prefix** section, as a consequence of that mechanism, and its separate long-path opt-in section + lists `CreateFileW` among the functions with the restriction removed, excluding nothing. So the + documented answer is that the opt-in covers relative paths. A PR #56 review round asserted the + opposite; that assertion was accepted and shipped for one commit on the strength of a web search + returning blog posts, then reverted against the primary source. + **The hypothesis worth falsifying, and why documentation cannot settle it.** A plausible + implementation is to regularize the path and prepend `\\?\` before proceeding -- and that prefix is + precisely what disables `.`, `..` and forward-slash translation. If that is how it works, a relative + path containing any of those could resolve under `MAX_PATH` and fail over it, which no page states + and which callers of this crate would meet directly, since `windows-file-watcher` passes paths to + Win32 verbatim (`D-85`) and cannot mask the difference. + **What the experiment needs.** `LongPathsEnabled` is already `1` on the development host, so the + machine half is satisfied; what is missing is a test binary carrying `longPathAware` in its + manifest, which no crate here does yet. Then: build a directory tree past `MAX_PATH`, set the + current directory inside it, and call `CreateFileW` with relative paths that are short/long and + plain/`..`-bearing/forward-slashed, recording which combinations open. Run it with the manifest + present and absent, since the absent case is the one most consumers will actually have. + **Report the result where the claim lives**: `Session::subscribe`'s note currently says what + Microsoft documents and says plainly that this workspace has not measured it. Replace that with the + measurement, or with the sharp edge if one is found. + ## M-inf -- Parked Ungated work with no identified predecessor deliverable. diff --git a/crates/windows-file-watcher/src/session.rs b/crates/windows-file-watcher/src/session.rs index e3098524..35d22c45 100644 --- a/crates/windows-file-watcher/src/session.rs +++ b/crates/windows-file-watcher/src/session.rs @@ -88,8 +88,26 @@ impl Session { /// limit is a consequence of the prefix route, not a separate ceiling /// the opt-in leaves standing. /// - /// So a long relative path is fine in a long-path-aware process, and has - /// no escape at all in one that has not opted in. + /// So, *as documented*, a long relative path is fine in a long-path-aware + /// process and has no escape at all in one that has not opted in. + /// + /// **That last paragraph is what Microsoft documents, not what this crate + /// has measured, and the distinction is deliberate.** A plausible + /// implementation of the opt-in is to regularize the path and then prepend + /// `\\?\` before proceeding as usual -- and that would have an observable + /// edge, because the prefix is exactly what disables `.`, `..` and + /// forward-slash translation. A relative path using any of those could then + /// behave one way under `MAX_PATH` and another way over it. Nothing here + /// has run that experiment, this crate passes the path to Win32 verbatim + /// so it cannot mask such a difference, and a caller relying on `..` in a + /// long relative path should measure it on their own target before + /// depending on it. + /// + /// Queued as `M35.1` rather than left as a doubt in prose. The reason it + /// is not answered here is narrow and worth stating: the machine half of + /// the opt-in is already satisfied on the development host, and what is + /// missing is a test binary carrying `longPathAware` in its manifest, + /// which no crate in this workspace does yet. /// /// # Errors /// From 91f4796aae4e2d04d366ced8b111e6c205c2b2ab Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:34:50 -0400 Subject: [PATCH 320/361] feat(platform-probes): measure the long-path opt-in instead of documenting the doubt Whether Windows' long-path opt-in lifts `MAX_PATH` for a *relative* path had produced three wrong answers from reading in one review cycle: the review round asserted it does not, I accepted that on the strength of a web search returning blog posts, and the correction that followed still stated an outcome nobody here had run. The engineer's instruction was to resolve it by experiment. `probe-long-path-aware` and `probe-long-path-unaware` are the same code, differing only in whether their manifest declares `longPathAware`. `build.rs` embeds it into that one binary through `rustc-link-arg-bin` rather than `-bins`, so the other thirteen probes keep measuring what they measured. Verified the split by searching both executables for the manifest XML: present in one, absent in the other. The first check for it matched both, because the report prints the literal string `manifest longPathAware` -- a reminder that a detector can pass on its own output. Result on a host with `LongPathsEnabled=1`. With the opt-in, a relative path of 429 characters opens in every shape: plain, containing `b\..`, and forward-slash separated. Without it all three are refused with `ERROR_PATH_NOT_FOUND` while the same shapes at 78 characters open. Every target is created first, so a not-found from a file that provably exists is the length refusal rather than an absence. So the documented reading was right: the opt-in covers relative paths, and `MAX_PATH` binds them only in a process that has not opted in. And the engineer's hypothesis is falsified, which documentation could not have settled either way. If the opt-in worked by regularizing then prepending `\\?\`, that prefix would disable `.`, `..` and forward-slash translation, so those shapes would fail past the ceiling while working below it -- a discontinuity at a length boundary. Both resolve at both lengths. The opt-in lifts the length check without re-parsing, so a caller of `windows-file-watcher` meets no edge there despite the crate passing paths to Win32 verbatim. `Session::subscribe`'s note now carries the measurement in place of the disclaimer, and M35.1 is closed with the result. Completed item: M35.1: Measure whether the long-path opt-in lifts MAX_PATH for a relative path Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST.md | 46 +-- crates/windows-file-watcher/src/session.rs | 36 +- crates/windows-platform-probes/Cargo.toml | 12 + crates/windows-platform-probes/build.rs | 31 ++ .../long-path-aware.manifest | 9 + .../src/bin/long_path_aware.rs | 22 ++ .../src/bin/long_path_unaware.rs | 23 ++ crates/windows-platform-probes/src/lib.rs | 2 + .../windows-platform-probes/src/long_path.rs | 372 ++++++++++++++++++ .../src/long_path_report.rs | 140 +++++++ 10 files changed, 648 insertions(+), 45 deletions(-) create mode 100644 crates/windows-platform-probes/build.rs create mode 100644 crates/windows-platform-probes/long-path-aware.manifest create mode 100644 crates/windows-platform-probes/src/bin/long_path_aware.rs create mode 100644 crates/windows-platform-probes/src/bin/long_path_unaware.rs create mode 100644 crates/windows-platform-probes/src/long_path.rs create mode 100644 crates/windows-platform-probes/src/long_path_report.rs diff --git a/CHECKLIST.md b/CHECKLIST.md index 7431b682..dc7c0c01 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -174,33 +174,25 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. ## M35 -- Measure what the long-path opt-in actually does -- [ ] **M35.1** -- **Measure whether the long-path opt-in lifts `MAX_PATH` for a *relative* path, and - whether it does so without changing how the path is parsed.** A probe, not a doc edit: the question - is about behaviour and has already produced two wrong answers from reading alone. - **The state of the evidence.** Microsoft's - [Maximum Path Length Limitation](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation) - puts "relative paths are always limited to a total of MAX_PATH characters" inside the `\\?\` - **prefix** section, as a consequence of that mechanism, and its separate long-path opt-in section - lists `CreateFileW` among the functions with the restriction removed, excluding nothing. So the - documented answer is that the opt-in covers relative paths. A PR #56 review round asserted the - opposite; that assertion was accepted and shipped for one commit on the strength of a web search - returning blog posts, then reverted against the primary source. - **The hypothesis worth falsifying, and why documentation cannot settle it.** A plausible - implementation is to regularize the path and prepend `\\?\` before proceeding -- and that prefix is - precisely what disables `.`, `..` and forward-slash translation. If that is how it works, a relative - path containing any of those could resolve under `MAX_PATH` and fail over it, which no page states - and which callers of this crate would meet directly, since `windows-file-watcher` passes paths to - Win32 verbatim (`D-85`) and cannot mask the difference. - **What the experiment needs.** `LongPathsEnabled` is already `1` on the development host, so the - machine half is satisfied; what is missing is a test binary carrying `longPathAware` in its - manifest, which no crate here does yet. Then: build a directory tree past `MAX_PATH`, set the - current directory inside it, and call `CreateFileW` with relative paths that are short/long and - plain/`..`-bearing/forward-slashed, recording which combinations open. Run it with the manifest - present and absent, since the absent case is the one most consumers will actually have. - **Report the result where the claim lives**: `Session::subscribe`'s note currently says what - Microsoft documents and says plainly that this workspace has not measured it. Replace that with the - measurement, or with the sharp edge if one is found. - +- [x] **M35.1** -- **Measure whether the long-path opt-in lifts `MAX_PATH` for a *relative* path, and + whether it does so without changing how the path is parsed.** + **Done 2026-09-04, and it settles a question that had produced three wrong answers from reading.** + `probe-long-path-aware` and `probe-long-path-unaware` in + [windows-platform-probes](crates/windows-platform-probes/src/long_path.rs) are the same code + differing only in whether their manifest declares `longPathAware`; `build.rs` embeds it into that + one binary via `rustc-link-arg-bin`, so the other thirteen probes are unaffected. + **Result, on a host with `LongPathsEnabled=1`.** With the opt-in, a relative path of 429 characters + opens in every shape -- plain, containing `b\..`, and forward-slash separated. Without it, all three + are refused with `ERROR_PATH_NOT_FOUND` while the same shapes at 78 characters open. The targets are + created first, so a not-found from a file that provably exists is the length refusal. + **So the documented reading was right and the review finding was wrong**: the opt-in covers relative + paths, and `MAX_PATH` binds them only in a process that has not opted in. + **And the regularize-then-prefix hypothesis is falsified.** If the opt-in worked by prepending + `\\?\`, that prefix would disable `.`, `..` and forward-slash translation, so those shapes would + have failed past the ceiling while working below it. Both resolve at both lengths. The opt-in lifts + the length check without re-parsing, so there is no discontinuity at `MAX_PATH` for a caller of + `windows-file-watcher` to fall into. + The measurement is recorded where the claim lives, in `Session::subscribe`'s note. ## M-inf -- Parked Ungated work with no identified predecessor deliverable. diff --git a/crates/windows-file-watcher/src/session.rs b/crates/windows-file-watcher/src/session.rs index 35d22c45..f89d3c0a 100644 --- a/crates/windows-file-watcher/src/session.rs +++ b/crates/windows-file-watcher/src/session.rs @@ -88,26 +88,26 @@ impl Session { /// limit is a consequence of the prefix route, not a separate ceiling /// the opt-in leaves standing. /// - /// So, *as documented*, a long relative path is fine in a long-path-aware - /// process and has no escape at all in one that has not opted in. + /// So a long relative path is fine in a long-path-aware process, and has + /// no escape at all in one that has not opted in. /// - /// **That last paragraph is what Microsoft documents, not what this crate - /// has measured, and the distinction is deliberate.** A plausible - /// implementation of the opt-in is to regularize the path and then prepend - /// `\\?\` before proceeding as usual -- and that would have an observable - /// edge, because the prefix is exactly what disables `.`, `..` and - /// forward-slash translation. A relative path using any of those could then - /// behave one way under `MAX_PATH` and another way over it. Nothing here - /// has run that experiment, this crate passes the path to Win32 verbatim - /// so it cannot mask such a difference, and a caller relying on `..` in a - /// long relative path should measure it on their own target before - /// depending on it. + /// **Measured, not inferred.** `probe-long-path-aware` and + /// `probe-long-path-unaware` in `windows-platform-probes` are the same code + /// differing only in whether their manifest declares `longPathAware`. They + /// open a file through a relative path of 429 characters, from a short + /// current directory, with no prefix. With the opt-in every shape opens; + /// without it every shape is refused with `ERROR_PATH_NOT_FOUND` while the + /// same shapes at 78 characters open, which is the length refusal rather + /// than a missing file -- the probe creates each target first. /// - /// Queued as `M35.1` rather than left as a doubt in prose. The reason it - /// is not answered here is narrow and worth stating: the machine half of - /// the opt-in is already satisfied on the development host, and what is - /// missing is a test binary carrying `longPathAware` in its manifest, - /// which no crate in this workspace does yet. + /// The measurement also answers a question the documentation does not. + /// A plausible implementation of the opt-in would be to regularize the path + /// and prepend `\\?\`, and that prefix is exactly what disables `.`, `..` + /// and forward-slash translation -- so a relative path using any of those + /// could have resolved under `MAX_PATH` and failed over it. **It does + /// not.** Both shapes resolve past the ceiling exactly as they do below it, + /// so the opt-in lifts the length check without re-parsing, and this crate + /// passing paths verbatim exposes no edge at that boundary. /// /// # Errors /// diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index 084f58be..4fe9415d 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -15,6 +15,14 @@ description = "Executable probes for the undocumented Windows behaviour this wor [lib] path = "src/lib.rs" +[[bin]] +name = "probe-long-path-aware" +path = "src/bin/long_path_aware.rs" + +[[bin]] +name = "probe-long-path-unaware" +path = "src/bin/long_path_unaware.rs" + [[bin]] name = "probe-core-affinity" path = "src/bin/core_affinity.rs" @@ -120,6 +128,10 @@ features = [ "Win32_Storage_FileSystem", # GetSystemDirectoryW, so the request probe measures the real system # directory instead of assuming Windows is installed on C:. + # SetCurrentDirectoryW, for the long-path probe: the current directory is + # half of what a relative path resolves against, so the probe has to place + # it deliberately rather than inherit whatever launched it. + "Win32_System_Environment", "Win32_System_SystemInformation", "Win32_System_Diagnostics_Debug", "Win32_System_IO", diff --git a/crates/windows-platform-probes/build.rs b/crates/windows-platform-probes/build.rs new file mode 100644 index 00000000..445568fb --- /dev/null +++ b/crates/windows-platform-probes/build.rs @@ -0,0 +1,31 @@ +// Copyright (c) Mike Grier. + +//! Embeds `longPathAware` into **one** binary, so the long-path opt-in can be +//! measured rather than read about. +//! +//! The opt-in has two halves and neither is a runtime switch: a machine-wide +//! registry value, and a per-executable manifest. The manifest half is what +//! this adds, and it is added to `probe-long-path-aware` **alone** -- +//! `probe-long-path-unaware` is the same code without it, because a comparison +//! needs both sides and the un-opted-in case is what most consumers of this +//! workspace actually have. +//! +//! `rustc-link-arg-bin` rather than `rustc-link-arg-bins`: the latter would +//! opt every probe in this crate into long paths, silently changing what the +//! other thirteen measure. + +fn main() { + // Only the MSVC linker understands these, and this crate is Windows-only + // anyway; guarding keeps a cross-compile from failing on a flag its linker + // has never heard of. + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + let manifest = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("long-path-aware.manifest"); + println!("cargo::rerun-if-changed=long-path-aware.manifest"); + println!("cargo::rustc-link-arg-bin=probe-long-path-aware=/MANIFEST:EMBED"); + println!( + "cargo::rustc-link-arg-bin=probe-long-path-aware=/MANIFESTINPUT:{}", + manifest.display() + ); + } +} diff --git a/crates/windows-platform-probes/long-path-aware.manifest b/crates/windows-platform-probes/long-path-aware.manifest new file mode 100644 index 00000000..cca3cfd5 --- /dev/null +++ b/crates/windows-platform-probes/long-path-aware.manifest @@ -0,0 +1,9 @@ + + + + + + true + + + diff --git a/crates/windows-platform-probes/src/bin/long_path_aware.rs b/crates/windows-platform-probes/src/bin/long_path_aware.rs new file mode 100644 index 00000000..df3540cd --- /dev/null +++ b/crates/windows-platform-probes/src/bin/long_path_aware.rs @@ -0,0 +1,22 @@ +// Copyright (c) Mike Grier. + +//! Measures the long-path opt-in **with** `longPathAware` in the manifest. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. See this crate's DESIGN-NOTES.md. +//! +//! Its twin, `probe-long-path-unaware`, is the same code without the manifest. +//! Run both: one row of results proves nothing, because the difference between +//! them is the whole measurement. + +use windows_platform_probes::report::{Stdout, emit}; +use windows_platform_probes::{long_path, long_path_report}; + +fn main() { + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit( + &mut Stdout, + &long_path_report::render(&long_path::measure(true)), + ); +} diff --git a/crates/windows-platform-probes/src/bin/long_path_unaware.rs b/crates/windows-platform-probes/src/bin/long_path_unaware.rs new file mode 100644 index 00000000..1737b22b --- /dev/null +++ b/crates/windows-platform-probes/src/bin/long_path_unaware.rs @@ -0,0 +1,23 @@ +// Copyright (c) Mike Grier. + +//! Measures the long-path opt-in **without** `longPathAware` in the manifest. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. See this crate's DESIGN-NOTES.md. +//! +//! This is the case most consumers of this workspace actually have, which is +//! why it is measured rather than assumed: a library cannot add a manifest to +//! someone else's executable, so whatever this reports is what a caller who has +//! not opted in will meet. + +use windows_platform_probes::report::{Stdout, emit}; +use windows_platform_probes::{long_path, long_path_report}; + +fn main() { + // The only place that names the real stream. Everything below composes + // text; nothing below knows where it goes. + emit( + &mut Stdout, + &long_path_report::render(&long_path::measure(false)), + ); +} diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index 95795944..c59e1903 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -116,6 +116,8 @@ pub mod doorbell_cost; pub mod error_mode; pub mod handle_state; pub mod ioring; +pub mod long_path; +pub mod long_path_report; pub mod pool_growth; pub mod queue_contention; pub mod report; diff --git a/crates/windows-platform-probes/src/long_path.rs b/crates/windows-platform-probes/src/long_path.rs new file mode 100644 index 00000000..45dd8596 --- /dev/null +++ b/crates/windows-platform-probes/src/long_path.rs @@ -0,0 +1,372 @@ +// Copyright (c) Mike Grier. + +//! Does the long-path opt-in lift `MAX_PATH` for a **relative** path, and does +//! it change how that path is parsed? +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. Do not call them from production code, and +//! do not lift a technique out of here. See this crate's DESIGN-NOTES.md. +//! +//! # The question, and why reading could not settle it +//! +//! Microsoft's *Maximum Path Length Limitation* puts "relative paths are always +//! limited to a total of MAX_PATH characters" inside the `\\?\` **prefix** +//! section, where it is a consequence of that mechanism -- the prefix cannot be +//! applied to a relative path. Its separate long-path opt-in section says the +//! restriction is removed from a list of functions that includes `CreateFileW`, +//! and excludes nothing. So the documented answer is that the opt-in covers +//! relative paths. +//! +//! That reading produced two wrong answers in one PR #56 review cycle, in +//! opposite directions, which is the reason this exists as a measurement. +//! +//! # The hypothesis this is built to falsify +//! +//! A plausible implementation of the opt-in is to regularize the path and +//! prepend `\\?\` before proceeding as usual. That prefix is precisely what +//! disables `.`, `..` and forward-slash translation -- so if that is how it +//! works, a relative path using any of those could resolve **under** `MAX_PATH` +//! and fail **over** it. A discontinuity at a length boundary is the worst kind +//! to meet in production, and no page states it. +//! +//! So each shape is measured at both lengths. A shape that works short and +//! fails long is the sharp edge; a shape that works at both is evidence the +//! opt-in does not re-parse. +//! +//! # Reading the result +//! +//! Run both binaries. `probe-long-path-aware` carries `longPathAware` in its +//! manifest; `probe-long-path-unaware` is the same code without it, because the +//! un-opted-in case is what most consumers of this workspace actually have. +//! The registry half (`LongPathsEnabled`) is a machine setting and is reported +//! rather than assumed, since a result gathered without it says nothing. + +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, GetLastError, INVALID_HANDLE_VALUE, +}; +use windows_sys::Win32::Storage::FileSystem::{ + CREATE_ALWAYS, CreateDirectoryW, CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_READ, + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, +}; +// `SetCurrentDirectoryW` lives under Environment rather than FileSystem, +// because the current directory is per-process environment state rather than a +// file operation. +use windows_sys::Win32::System::Environment::SetCurrentDirectoryW; + +/// Windows's classic path ceiling. +const MAX_PATH: usize = 260; + +/// One directory level of the deep tree. Short, so the depth rather than the +/// width is what carries the length, and free of `.` so no segment is itself a +/// relative operator. +const SEGMENT: &str = "aaaaaaaa"; + +/// The file every attempt tries to open. +const TARGET: &str = "target.txt"; + +/// A path shape, and whether it is expected to survive `\\?\` parsing. +/// +/// The three differ only in features the prefix disables, which is what makes +/// the comparison a test of the hypothesis rather than of path length alone. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Shape { + /// Plain backslash-separated segments. Legal under `\\?\` too, so this is + /// the control: it isolates length from parsing. + Plain, + /// Contains `b\..`, which cancels to nothing -- but only if something + /// resolves it. `\\?\` does not. + DotDot, + /// Uses `/` as the separator. Win32 converts it; `\\?\` does not. + ForwardSlash, +} + +impl Shape { + /// A short word for a table. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Plain => "plain", + Self::DotDot => "with `..`", + Self::ForwardSlash => "forward slashes", + } + } + + /// Whether `\\?\` parsing would still resolve this shape. + /// + /// The prediction the hypothesis makes: if the opt-in prefixes internally, + /// the two shapes answering `false` here fail once the path grows past + /// `MAX_PATH`, while `Plain` keeps working. + #[must_use] + pub fn survives_verbatim_parsing(self) -> bool { + matches!(self, Self::Plain) + } +} + +/// What one attempt did. +#[derive(Clone, Debug)] +pub struct Attempt { + /// The shape tried. + pub shape: Shape, + /// Total length the call had to resolve: current directory plus the + /// relative path. This is the number `MAX_PATH` is compared against, not + /// the length of the relative part alone. + pub resolved_len: usize, + /// Whether that total exceeds `MAX_PATH`. + pub over_max_path: bool, + /// Whether `CreateFileW` opened the file. + pub opened: bool, + /// The Win32 error when it did not. + pub error: u32, +} + +/// Everything one run observed. +#[derive(Clone, Debug)] +pub struct Observation { + /// Whether this binary declares `longPathAware`. + pub manifest_aware: bool, + /// Whether the machine has `LongPathsEnabled` set to 1. + pub registry_enabled: bool, + /// Every attempt, short ones first. + pub attempts: Vec, + /// Set when the apparatus itself failed, in which case the attempts say + /// nothing about the machine. + pub apparatus_error: Option, +} + +/// A null-terminated wide string, as Win32 wants. +fn wide(path: &OsStr) -> Vec { + path.encode_wide().chain(std::iter::once(0)).collect() +} + +/// Read `LongPathsEnabled`, which is half the opt-in and is a machine setting +/// rather than anything this process controls. +/// +/// Reported rather than assumed: a run on a machine without it measures the +/// un-opted-in case whatever the manifest says, and reading the answer as +/// though the opt-in were active would invert the conclusion. +#[must_use] +pub fn registry_enabled() -> bool { + // Read through `reg.exe` rather than taking a registry dependency for one + // value in a probe. A missing key, a non-zero exit and an unparsable value + // all mean the same thing here: not enabled. + std::process::Command::new("reg") + .args([ + "query", + r"HKLM\SYSTEM\CurrentControlSet\Control\FileSystem", + "/v", + "LongPathsEnabled", + ]) + .output() + .ok() + .filter(|out| out.status.success()) + .map(|out| String::from_utf8_lossy(&out.stdout).contains("0x1")) + .unwrap_or(false) +} + +/// Create one directory by absolute `\\?\` path, so building the apparatus +/// never depends on the behaviour under test. +fn create_dir_verbatim(path: &Path) -> Result<(), String> { + let verbatim = PathBuf::from(format!(r"\\?\{}", path.display())); + let wide = wide(verbatim.as_os_str()); + // SAFETY: `wide` is a live null-terminated buffer for the duration of the + // call, and a null security descriptor requests the default. + let created = unsafe { CreateDirectoryW(wide.as_ptr(), std::ptr::null()) }; + if created == 0 { + // SAFETY: called immediately after the failing call. + let error = unsafe { GetLastError() }; + // 183 is ERROR_ALREADY_EXISTS, which is success for our purposes. + if error != 183 { + return Err(format!("CreateDirectoryW({verbatim:?}) failed: {error}")); + } + } + Ok(()) +} + +/// Build a directory chain `depth` levels deep under `root`, returning the +/// relative path that reaches the bottom. +fn build_tree(root: &Path, depth: usize) -> Result { + let mut absolute = root.to_path_buf(); + let mut relative = PathBuf::new(); + for _ in 0..depth { + absolute.push(SEGMENT); + relative.push(SEGMENT); + create_dir_verbatim(&absolute)?; + } + Ok(relative) +} + +/// Write the target file at the bottom of the chain, by absolute `\\?\` path. +fn create_target(bottom: &Path) -> Result<(), String> { + let verbatim = PathBuf::from(format!(r"\\?\{}\{TARGET}", bottom.display())); + let wide = wide(verbatim.as_os_str()); + // SAFETY: `wide` is live and null-terminated; the handle is closed below. + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + // SAFETY: called immediately after the failing call. + return Err(format!("could not create the target: {}", unsafe { + GetLastError() + })); + } + // SAFETY: `handle` is a live handle this function just opened. + unsafe { CloseHandle(handle) }; + Ok(()) +} + +/// Render one relative path of the requested shape reaching `depth` levels down. +fn relative_path(depth: usize, shape: Shape) -> String { + let mut parts: Vec = (0..depth).map(|_| SEGMENT.to_string()).collect(); + match shape { + Shape::Plain | Shape::ForwardSlash => {} + Shape::DotDot => { + // A descent that immediately cancels. Placed at the bottom so the + // path is at its longest when the operator appears -- the position + // where a prefix-then-parse implementation would be least able to + // resolve it. + parts.push("b".to_string()); + parts.push("..".to_string()); + } + } + parts.push(TARGET.to_string()); + let separator = if shape == Shape::ForwardSlash { + "/" + } else { + r"\" + }; + parts.join(separator) +} + +/// Try to open the target through one relative path, from the current +/// directory, with no prefix of any kind. +fn attempt(current_dir_len: usize, depth: usize, shape: Shape) -> Attempt { + let relative = relative_path(depth, shape); + // Plus one for the separator Windows inserts when it joins the two. + let resolved_len = current_dir_len + 1 + relative.len(); + let wide = wide(OsStr::new(&relative)); + // SAFETY: `wide` is a live null-terminated buffer for the duration of the + // call; the handle, if any, is closed below. + let handle = unsafe { + CreateFileW( + wide.as_ptr(), + FILE_GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + std::ptr::null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + std::ptr::null_mut(), + ) + }; + let opened = handle != INVALID_HANDLE_VALUE; + let error = if opened { + // SAFETY: `handle` is a live handle this call just opened. + unsafe { CloseHandle(handle) }; + 0 + } else { + // SAFETY: called immediately after the failing call. + unsafe { GetLastError() } + }; + Attempt { + shape, + resolved_len, + over_max_path: resolved_len > MAX_PATH, + opened, + error, + } +} + +/// Run the experiment. +/// +/// `manifest_aware` is what the *caller* knows about its own manifest -- the +/// process cannot ask Windows whether it opted in, so the two binaries pass +/// their own answer and are named for it. +#[must_use] +pub fn measure(manifest_aware: bool) -> Observation { + let mut observation = Observation { + manifest_aware, + registry_enabled: registry_enabled(), + attempts: Vec::new(), + apparatus_error: None, + }; + + let root = std::env::temp_dir().join(format!("long-path-probe-{}", std::process::id())); + if let Err(error) = create_dir_verbatim(&root) { + observation.apparatus_error = Some(error); + return observation; + } + + // Deep enough that the resolved path clears `MAX_PATH` with room to spare, + // and shallow enough that the short case stays well under it. + let deep = 40; + let shallow = 1; + + let deep_relative = match build_tree(&root, deep) { + Ok(relative) => relative, + Err(error) => { + observation.apparatus_error = Some(error); + return observation; + } + }; + // `b`, for the `..` shape to descend into and immediately leave. + for depth in [shallow, deep] { + let mut bottom = root.clone(); + for _ in 0..depth { + bottom.push(SEGMENT); + } + if let Err(error) = create_dir_verbatim(&bottom.join("b")) { + observation.apparatus_error = Some(error); + return observation; + } + if let Err(error) = create_target(&bottom) { + observation.apparatus_error = Some(error); + return observation; + } + } + let _ = deep_relative; + + // The current directory is the short root for every attempt, so the length + // under test lives in the relative path rather than in the cwd. + let root_wide = wide(root.as_os_str()); + // SAFETY: `root_wide` is a live null-terminated buffer for the call. + if unsafe { SetCurrentDirectoryW(root_wide.as_ptr()) } == 0 { + // SAFETY: called immediately after the failing call. + observation.apparatus_error = Some(format!("SetCurrentDirectoryW failed: {}", unsafe { + GetLastError() + })); + return observation; + } + let current_dir_len = root.as_os_str().len(); + + for depth in [shallow, deep] { + for shape in [Shape::Plain, Shape::DotDot, Shape::ForwardSlash] { + observation + .attempts + .push(attempt(current_dir_len, depth, shape)); + } + } + + observation +} + +/// Whether an error means "the path was rejected for length", as opposed to a +/// genuine absence. +/// +/// Windows reports an over-long path as `ERROR_PATH_NOT_FOUND` rather than +/// anything length-specific, which is why the apparatus creates every target +/// first: a `NOT_FOUND` from a file that provably exists is the length refusal. +#[must_use] +pub fn is_refusal(attempt: &Attempt) -> bool { + !attempt.opened && matches!(attempt.error, ERROR_PATH_NOT_FOUND | ERROR_FILE_NOT_FOUND) +} diff --git a/crates/windows-platform-probes/src/long_path_report.rs b/crates/windows-platform-probes/src/long_path_report.rs new file mode 100644 index 00000000..51768cfc --- /dev/null +++ b/crates/windows-platform-probes/src/long_path_report.rs @@ -0,0 +1,140 @@ +// Copyright (c) Mike Grier. + +//! Renders one long-path run. Shared by the two binaries, which differ only in +//! whether their manifest declares `longPathAware`. +//! +//! **An experiment, not a component.** These probes measure platform behaviour +//! and are not for production use. See this crate's DESIGN-NOTES.md. + +use std::fmt::Write as _; + +use crate::long_path::{Observation, Shape, is_refusal}; + +/// The probe's whole report, as text. +#[must_use] +pub fn render(observation: &Observation) -> String { + let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); + let _ = writeln!( + out, + "== does the long-path opt-in lift MAX_PATH for a relative path? ==\n" + ); + + let _ = writeln!( + out, + "manifest longPathAware : {}", + if observation.manifest_aware { + "yes" + } else { + "no" + } + ); + let _ = writeln!( + out, + "LongPathsEnabled : {}", + if observation.registry_enabled { + "1" + } else { + "unset or 0" + } + ); + + if !observation.registry_enabled { + let _ = writeln!( + out, + "\n*** The machine half of the opt-in is absent, so this run measures the\n\ + *** un-opted-in case whatever the manifest says. The rows below are still\n\ + *** real, but they answer a different question than the one intended." + ); + } + + if let Some(error) = &observation.apparatus_error { + let _ = writeln!( + out, + "\n*** APPARATUS FAILED: {error}\n\ + *** Nothing below says anything about the machine." + ); + return out; + } + + let _ = writeln!( + out, + "\n{:<18} {:>8} {:>8} {:<10} error", + "shape", "resolved", "> MAX", "result" + ); + for attempt in &observation.attempts { + let _ = writeln!( + out, + "{:<18} {:>8} {:>8} {:<10} {}", + attempt.shape.label(), + attempt.resolved_len, + if attempt.over_max_path { "yes" } else { "no" }, + if attempt.opened { "opened" } else { "REFUSED" }, + if attempt.opened { + String::new() + } else { + format!( + "{}{}", + attempt.error, + if is_refusal(attempt) { + " (not-found; the target provably exists, so this is the length refusal)" + } else { + "" + } + ) + } + ); + } + + let _ = writeln!(out, "\n{}", verdict(observation)); + out +} + +/// What the rows mean, stated rather than left for the reader to infer. +fn verdict(observation: &Observation) -> String { + let long: Vec<_> = observation + .attempts + .iter() + .filter(|attempt| attempt.over_max_path) + .collect(); + if long.is_empty() { + return "-- no attempt exceeded MAX_PATH, so this run tested nothing.".to_string(); + } + + let plain_long_opened = long + .iter() + .any(|attempt| attempt.shape == Shape::Plain && attempt.opened); + let reparsing: Vec<&str> = long + .iter() + .filter(|attempt| !attempt.shape.survives_verbatim_parsing() && !attempt.opened) + .map(|attempt| attempt.shape.label()) + .collect(); + + if !plain_long_opened { + return "-- MAX_PATH was NOT lifted for a relative path: even the plain shape was\n\ + refused past the ceiling. The documented reading is wrong for this\n\ + configuration." + .to_string(); + } + if reparsing.is_empty() { + "-- MAX_PATH was lifted for a relative path, and the path was still parsed\n\ + normally: `..` and forward slashes resolved past the ceiling exactly as\n\ + they do below it. No evidence of a prefix-then-parse implementation." + .to_string() + } else { + format!( + "-- SHARP EDGE. Length was lifted, but these shapes stopped resolving past\n\ + the ceiling while working below it: {}.\n\ + That is the signature of regularize-then-prefix: `\\\\?\\` disables exactly\n\ + these features, so a relative path changes meaning at MAX_PATH.", + reparsing.join(", ") + ) + } +} From 05b908b09208baf9e81a582edd7808951512f289 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:42:34 -0400 Subject: [PATCH 321/361] fix(platform-probes): emit the host fingerprint from the three probes that never did `doorbell_cost`, `request_cost` and `topology` composed their reports without a banner line. Unlike the two fixed earlier in this review cycle they were not bypassing a sink -- they simply never had one, so their output carried no statement of which machine produced it. That matters most for exactly these three: two are timing measurements and one describes the machine, so a number pasted into a discussion with no host line can be compared against anything. The banner also carries the build's taint marker, which is what says whether an unofficial build produced the figure. All seven binaries named in M34.2 now lead their report with the fingerprint, and it is part of the returned text rather than written separately, so a captured report keeps it. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-platform-probes/src/bin/doorbell_cost.rs | 9 +++++++++ crates/windows-platform-probes/src/bin/request_cost.rs | 9 +++++++++ crates/windows-platform-probes/src/bin/topology.rs | 9 +++++++++ 3 files changed, 27 insertions(+) diff --git a/crates/windows-platform-probes/src/bin/doorbell_cost.rs b/crates/windows-platform-probes/src/bin/doorbell_cost.rs index 25af1cb1..80d0c429 100644 --- a/crates/windows-platform-probes/src/bin/doorbell_cost.rs +++ b/crates/windows-platform-probes/src/bin/doorbell_cost.rs @@ -29,6 +29,15 @@ fn main() { /// The probe's whole report, as text. fn render(observation: &Observation, park: Option) -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // timing number can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!( out, "== what does a doorbell cost, against the syscall it guards? ==\n" diff --git a/crates/windows-platform-probes/src/bin/request_cost.rs b/crates/windows-platform-probes/src/bin/request_cost.rs index ceaec81a..48d7345a 100644 --- a/crates/windows-platform-probes/src/bin/request_cost.rs +++ b/crates/windows-platform-probes/src/bin/request_cost.rs @@ -29,6 +29,15 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // timing number can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!(out, "== what does a namespace request cost to build? ==\n"); let observation = measure(); diff --git a/crates/windows-platform-probes/src/bin/topology.rs b/crates/windows-platform-probes/src/bin/topology.rs index 03c85787..a61f80e1 100644 --- a/crates/windows-platform-probes/src/bin/topology.rs +++ b/crates/windows-platform-probes/src/bin/topology.rs @@ -26,6 +26,15 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // timing number can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!( out, "== processor topology, and what each partitioning policy would yield ==\n" From 1f37988eafee1eba730447928bb219364bfa9f67 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:42:34 -0400 Subject: [PATCH 322/361] fix(waitable-queues)!: stop permit_mpsc losing a reserved item to a departed consumer `permit_mpsc::Reservation::send` returned `()` and published unconditionally, so redeeming against a dropped consumer put the item into a ring nobody would ever read. It was destroyed at teardown and the caller was never told -- a silent loss of precisely the message a reservation exists to guarantee. `reserving_mpsc::Reservation::send` has always returned `Disconnected` here, and this module's documentation claims only the *admission* protocol differs between the two shapes, so the divergence was undisclosed as well as wrong. `send` now checks `consumer_live` and hands the item back. The refusal path drops the reservation with `spent` still false, which returns the permit and the producer count, so a refused redemption gives the room back rather than leaking the slot -- asserted separately, because a refusal bought by leaking would pass the first test alone. Sabotage-verified. Disabling the check fails both new tests; it also failed two pre-existing ones, because the pattern matched all three `consumer_live` guards in the file rather than the one intended -- the same whole-file-replace hazard this workspace's sabotage tooling guards with a match-count check, met here by hand. Breaking, and deliberately so: `send`'s signature changes. The shape sits behind the non-default `experimental-permit-claim` feature and is exempt from the crate's semver promise, and SH-4.6 still asks whether it should ship at all -- but shipping it silently dropping messages was not an option while that question is open. Closes the contract half of SH-4.7; its memory-ordering half was settled earlier in this cycle. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 4 +- .../src/permit_mpsc.rs | 21 ++++++++- .../src/permit_mpsc/tests.rs | 46 +++++++++++++++++-- 3 files changed, 64 insertions(+), 7 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 8ffd42bb..35963947 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -558,7 +558,9 @@ that previously stood in the way are gone: observable API; or keep it out of the published crate entirely and measure it from a path dependency. The disclosure in the module docs is honest but does not settle the semver question. -- [ ] **SH-4.7** -- **Two `permit_mpsc` findings from the PR #56 review, one of which contradicts a +- [x] **SH-4.7** -- **Two `permit_mpsc` findings from the PR #56 review, both now settled. The + contract half was fixed on 2026-09-04: `Reservation::send` returns `Disconnected` instead of + publishing into a ring nobody will read. Originally: one of which contradicts a prior review.** Both are in the experimental module, so neither blocks the release, and both should be settled before `SH-15.6` decides the module''s fate. **Contract:** `Reservation::send` publishes unconditionally even when the consumer is already diff --git a/crates/windows-waitable-queues/src/permit_mpsc.rs b/crates/windows-waitable-queues/src/permit_mpsc.rs index f6491fa4..14f182b0 100644 --- a/crates/windows-waitable-queues/src/permit_mpsc.rs +++ b/crates/windows-waitable-queues/src/permit_mpsc.rs @@ -64,7 +64,7 @@ use std::sync::Arc; use crate::CacheAligned; use crate::capacity::{Bounds, MAX_ADMISSIBLE_CAPACITY, validate_capacity}; use crate::doorbell::Doorbell; -use crate::error::{CapacityError, PushError}; +use crate::error::{CapacityError, Disconnected, PushError}; use crate::metrics::Metrics; /// A ticket, and the slot sequence numbers compared against one. @@ -474,7 +474,23 @@ impl Reservation { /// /// Cannot fail for want of room: the permit taken at `reserve` is still /// held, so a slot is guaranteed. - pub fn send(mut self, item: T) { + pub fn send(mut self, item: T) -> Result<(), Disconnected> { + // **Checked, and the item comes back.** This used to publish + // unconditionally and return `()`, so redeeming against a departed + // consumer put the item into a ring nobody would ever read; it was + // destroyed at teardown and the caller was never told. That is a silent + // loss of exactly the message a reservation exists to guarantee. + // + // `reserving_mpsc::Reservation::send` has always returned + // `Disconnected` here. This module's documentation claims only the + // *admission* protocol differs between the two, so the divergence was + // undisclosed as well as wrong. Raised in the PR #56 review. + if !self.shared.consumer_live.load(Ordering::Acquire) { + // `self` is dropped on the way out with `spent` still false, which + // releases the permit and the producer count -- the right outcome, + // because this message is never being delivered. + return Err(Disconnected(item)); + } self.spent = true; let position = self.shared.tail.0.fetch_add(1, Ordering::Relaxed); // SAFETY: the permit taken at `reserve` is still held and this ticket @@ -482,6 +498,7 @@ impl Reservation { unsafe { self.shared.publish(position, item); } + Ok(()) } } diff --git a/crates/windows-waitable-queues/src/permit_mpsc/tests.rs b/crates/windows-waitable-queues/src/permit_mpsc/tests.rs index 890e277a..f57e3bcc 100644 --- a/crates/windows-waitable-queues/src/permit_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/permit_mpsc/tests.rs @@ -182,7 +182,7 @@ fn a_reservation_delivers_even_when_the_queue_is_otherwise_full() { } assert!(tx.push(99).is_err()); // Reserved is guaranteed: this cannot fail. - reservation.send(42); + reservation.send(42).expect("the consumer is still here"); assert_eq!(rx.len(), 4); for value in 0..3 { assert_eq!(rx.pop(), Some(value)); @@ -216,7 +216,7 @@ fn an_outstanding_reservation_does_not_block_the_consumer() { assert_eq!(rx.pop(), Some(1)); assert_eq!(rx.pop(), Some(2)); assert_eq!(rx.pop(), None); - reservation.send(3); + reservation.send(3).expect("the consumer is still here"); assert_eq!(rx.pop(), Some(3)); } @@ -226,7 +226,9 @@ fn every_reservation_the_capacity_allows_can_be_taken_at_once() { let reservations: Vec<_> = (0..4).map(|_| tx.reserve().expect("room")).collect(); assert!(tx.push(99).is_err(), "every slot is spoken for"); for (value, reservation) in reservations.into_iter().enumerate() { - reservation.send(value as u32); + reservation + .send(value as u32) + .expect("the consumer is still here"); } for value in 0..4 { assert_eq!(rx.pop(), Some(value)); @@ -382,8 +384,44 @@ fn a_producer_and_a_reservation_contend_for_the_same_room_without_overdrawing() let claims = usize::from(reserved.is_some()) + usize::from(pushed); assert!(claims <= 1, "both claimants took the same single slot"); if let Some(reservation) = reserved { - reservation.send(2); + reservation.send(2).expect("the consumer is still here"); } drop(rx); } } + +#[test] +fn redeeming_against_a_departed_consumer_hands_the_item_back() { + // The whole point of a reservation is that the message it stands for is + // not lost. This used to publish into a ring nobody would ever read: the + // item was destroyed at teardown and the caller was never told, which is a + // silent loss of exactly the message the reservation guaranteed. + // + // `reserving_mpsc::Reservation::send` has always answered `Disconnected` + // here, and this module claims only the *admission* protocol differs, so + // the divergence was undisclosed as well as wrong. Raised in the PR #56 + // review. + let (tx, rx) = bounded::(4).expect("4 is a valid capacity"); + let reservation = tx.reserve().expect("an empty queue has room"); + drop(rx); + + let returned = reservation + .send(7) + .expect_err("a departed consumer must not swallow the item"); + assert_eq!(returned.0, 7, "the item itself must come back, not a copy"); +} + +#[test] +fn a_refused_redemption_gives_the_room_back() { + // The complement, so the refusal cannot be bought by leaking the slot the + // reservation was holding: the permit must return to the pool exactly as + // it does when a reservation is simply dropped. + let (tx, rx) = bounded::(2).expect("2 is a valid capacity"); + let reservation = tx.reserve().expect("an empty queue has room"); + drop(rx); + let _ = reservation.send(7); + + // Both slots are free again, so both may be reserved. + assert!(tx.reserve().is_ok(), "the refused slot must be reusable"); + assert!(tx.reserve().is_ok(), "and the queue's other slot with it"); +} From cdc82c3896433d611224b7500b22a4ec09f25828 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 14:57:12 -0400 Subject: [PATCH 323/361] fix(ioring): stop ring_copy calling a node remote when the local one is unknown `remote_numa_node` compared `Some(id) != local`, and against a `None` local that is true for every named node -- so a domain whose own NUMA node the topology never stated had the first named node returned as its *remote* one, on no evidence at all. That is the same unknown-promoted-to-a-finding the function was rewritten to stop doing at the other end, left standing at this one. Remoteness is a relationship and needs both ends, so a missing local node is now its own answer, `LocalUnknown`, and the run refuses. It is kept distinct from `Unnamed` because the two are different situations -- one machine names no nodes, the other names some but not this domain's -- and merging them would hide a partially-named topology inside a story about descriptions. The first attempt at this regressed the live path, and running all three combinations caught it. The machine-level check ("does anything name a node?") had been implemented by calling the per-domain function with `local = None`, which stopped meaning "ask globally" the moment an unknown local node became its own answer: every run refused, including on a real machine. The two questions are now two functions, and the per-domain classification is computed once and reused rather than recomputed inside the thread scope. Measured after the fix: live+local exit 0 silent, live+remote exit 0 with the single-node note, restored+remote exit 2 refused. Also fixes a clippy error my earlier guard-alloc doc edit introduced -- a blank line between the doc comment and the item -- which the workspace lint caught here rather than in that crate's own commit. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-guard-alloc/src/lib.rs | 1 - .../examples/ring_copy/main.rs | 68 +++++++++++++------ .../examples/ring_copy/plan.rs | 32 +++++++++ 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/crates/windows-guard-alloc/src/lib.rs b/crates/windows-guard-alloc/src/lib.rs index 4fb7f8c7..ae49c158 100644 --- a/crates/windows-guard-alloc/src/lib.rs +++ b/crates/windows-guard-alloc/src/lib.rs @@ -153,7 +153,6 @@ fn seed_from_environment() -> Option { /// reach them, and they remain unexercised. Closing those needs the /// `GetEnvironmentVariableW` call to be injectable, which is a larger change /// than this split and is not pretended to be done. Raised in the PR #56 review. - fn parse_seed(digits: &[u16]) -> Option { const ZERO: u16 = b'0' as u16; const LOWER_X: u16 = b'x' as u16; diff --git a/crates/windows-ioring-sys/examples/ring_copy/main.rs b/crates/windows-ioring-sys/examples/ring_copy/main.rs index 52d810bc..fffa3880 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/main.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/main.rs @@ -237,21 +237,39 @@ fn main() -> io::Result<()> { // the topology rather than about a domain -- and because the refusal must // happen before the copy rather than per-chunk inside it. if args.remote_placement { - // Two separate questions, and answering only the first was a defect - // caught by running this rather than by reading it. + // Three questions, asked at the level each belongs to. Answering only + // the first was a defect caught by running this; then routing the first + // through the per-domain function was a second one, which refused every + // run including on a live machine. // - // `None` as the local node asks the *global* form -- is there any - // memory domain carrying an operational node number at all? -- because - // every named node differs from "no node". That detects a restored - // description, and nothing else: an `Other` here says the nodes are - // named, never that a remote one exists. + // Machine level: does anything name a node at all? A restored + // description names none, because deserialization drops the + // observations that carry them. // - // Whether a genuinely different node exists is per-domain, so it is - // asked of the plans below against their real local nodes. Skipping - // that left a single-node machine silently measuring local placement - // under `--placement remote`, which is the same substitution this - // whole block exists to prevent. - match plan::remote_numa_node(&topology, None) { + // Domain level: is this domain's own node known, and is there a + // different one? Neither can be asked of the machine, because both are + // relative to one domain. + let classified: Vec = plans + .iter() + .map(|domain_plan| plan::remote_numa_node(&topology, domain_plan.local_numa_node)) + .collect(); + let machine_names_nodes = plan::names_any_numa_node(&topology); + let outcome = if !machine_names_nodes { + plan::RemoteNode::Unnamed + } else if let Some(unknown) = classified + .iter() + .find(|node| matches!(node, plan::RemoteNode::LocalUnknown)) + { + *unknown + } else if classified + .iter() + .any(|node| matches!(node, plan::RemoteNode::Other(_))) + { + plan::RemoteNode::Other(0) + } else { + plan::RemoteNode::SameAsLocal + }; + match outcome { plan::RemoteNode::Unnamed => { report.error_line(format_args!( "--placement remote needs a topology that names its NUMA nodes, and this one \ @@ -263,13 +281,19 @@ fn main() -> io::Result<()> { )); std::process::exit(2); } + plan::RemoteNode::LocalUnknown => { + report.error_line(format_args!( + "--placement remote needs to know which NUMA node each domain is local to, and \ + this topology does not say. A node cannot be shown to be remote without one \ + to be remote from, so proceeding would report a remote run on no evidence. \ + Use --placement local, or a topology that names its nodes." + )); + std::process::exit(2); + } plan::RemoteNode::SameAsLocal | plan::RemoteNode::Other(_) => { - let any_remote = plans.iter().any(|domain_plan| { - matches!( - plan::remote_numa_node(&topology, domain_plan.local_numa_node), - plan::RemoteNode::Other(_) - ) - }); + let any_remote = classified + .iter() + .any(|node| matches!(node, plan::RemoteNode::Other(_))); if !any_remote { report.line(format_args!( "note: no domain has a NUMA node other than its own, so there is nothing \ @@ -297,9 +321,9 @@ fn main() -> io::Result<()> { // Both already reported above -- `Unnamed` exited, and // `SameAsLocal` said that local is the only node there // is. Neither may reach here as a silent substitution. - plan::RemoteNode::SameAsLocal | plan::RemoteNode::Unnamed => { - domain_plan.local_numa_node - } + plan::RemoteNode::SameAsLocal + | plan::RemoteNode::Unnamed + | plan::RemoteNode::LocalUnknown => domain_plan.local_numa_node, } } else { domain_plan.local_numa_node diff --git a/crates/windows-ioring-sys/examples/ring_copy/plan.rs b/crates/windows-ioring-sys/examples/ring_copy/plan.rs index 0c5ebb97..028d3745 100644 --- a/crates/windows-ioring-sys/examples/ring_copy/plan.rs +++ b/crates/windows-ioring-sys/examples/ring_copy/plan.rs @@ -130,6 +130,14 @@ pub enum RemoteNode { /// `label_from` answers `None` for every domain in a description, however /// many nodes that description describes. Unnamed, + /// The machine names its nodes, but *this domain's own* node is unknown, so + /// there is nothing for a candidate to be remote from. + /// + /// Distinct from [`RemoteNode::Unnamed`], which is about the machine, where + /// this is about one domain. Both refuse, but conflating them would hide + /// that a partially-named topology is a different situation from an + /// entirely unnamed one. + LocalUnknown, } /// A NUMA node other than `local`, for the sample's `--placement remote` @@ -140,7 +148,31 @@ pub enum RemoteNode { /// node: a run that measures local placement while reporting itself as remote /// would show no placement effect, and the reader would conclude there is /// none. A refused run says less than a wrong one, and says it honestly. +/// Whether any memory domain carries an operational node number at all. +/// +/// The *machine-level* question, kept separate from [`remote_numa_node`]'s +/// per-domain one. Asking the latter with `local = None` used to serve as this, +/// and stopped working the moment an unknown local node became its own answer +/// -- the global check then refused every run, including on a live machine. +/// Two questions, two functions. +#[must_use] +pub fn names_any_numa_node(topology: &MachineMemoryTopology) -> bool { + topology.domains.iter().any(|domain| { + matches!(domain.kind, DomainKind::Memory { .. }) + && domain.label_from(Source::RelationshipWalk).is_some() + }) +} + pub fn remote_numa_node(topology: &MachineMemoryTopology, local: Option) -> RemoteNode { + // **Remoteness is a relationship, so it needs both ends.** Without a local + // node there is nothing for a candidate to be remote *from*: the comparison + // below is `Some(id) != local`, and against `None` that is true for every + // named node, so the first one would be returned as remote on no evidence + // at all. That is the same unknown-promoted-to-a-finding this function was + // rewritten to stop doing at the other end. Raised in the PR #56 review. + if local.is_none() { + return RemoteNode::LocalUnknown; + } let mut any_named = false; for domain in &topology.domains { if !matches!(domain.kind, DomainKind::Memory { .. }) { From c072a8a15a0eaced8c34f7126179dd05f370e272 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 15:06:11 -0400 Subject: [PATCH 324/361] fix(topology)!: stop proximity panicking on a publicly constructible processor id `MachineMemoryTopology::proximity` passed each caller-supplied `ProcessorId` straight to `ProcessorSet::insert`, which asserts that the number fits the group mask. `ProcessorId`'s fields are public and `number` is a `u8`, so any value up to 255 is constructible -- and a public query aborted the process over its own argument. The ceiling is `usize::BITS`, which is what makes this sharp rather than merely wrong: 64 on x86-64 and 32 on `i686-pc-windows-msvc`, a target D-18 deliberately keeps supported. A `number` of 40 is an ordinary query on one and an abort on the other, from identical source. Reported rather than skipped. Dropping the id would answer a question about fewer processors than were asked about, and "these share nothing" would become indistinguishable from "one of these cannot exist on this machine" -- the conflation `Observed` exists to remove, arriving by a different door. `Proximity` gains `unrepresentable`, empty in every ordinary case, and `ProcessorSet::can_represent` is how a caller asks before inserting. The panic on `insert` stays, and its doc now says why: it is for a caller building a set it controls, not a way to reject a query's argument. Sabotage-verified. With the guard removed the new test panics with `a processor group has at most 64 processors, got 255`, which is the reported defect reproduced exactly. Breaking: `Proximity` gains a public field. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-topology-sys/src/granularity.rs | 40 ++++++++++++- .../src/granularity/tests.rs | 57 +++++++++++++++++++ .../windows-topology-sys/src/processor_set.rs | 15 +++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/crates/windows-topology-sys/src/granularity.rs b/crates/windows-topology-sys/src/granularity.rs index 1e586d69..98e05e96 100644 --- a/crates/windows-topology-sys/src/granularity.rs +++ b/crates/windows-topology-sys/src/granularity.rs @@ -180,15 +180,39 @@ impl MachineMemoryTopology { /// it has never heard of would be an invention. #[must_use] pub fn proximity(&self, processors: &[ProcessorId]) -> Proximity<'_> { + // **A query may not abort over its own argument.** `ProcessorId`'s + // fields are public and `number` is a `u8`, so any value up to 255 is + // constructible, while a `ProcessorSet` holds one bit per processor in + // a `usize` -- and `ProcessorSet::insert` asserts on anything wider. + // Passing such an id here used to panic, which is a poor answer to a + // question about a machine that simply does not have that processor. + // + // The threshold is target-dependent, which is what makes it sharp: + // `usize::BITS` is 64 on x86-64 and 32 on `i686-pc-windows-msvc`, a + // target [D-18](../DESIGN-NOTES.md#d-18) deliberately keeps supported. + // A `number` of 40 is an ordinary query on one and an abort on the + // other. Raised in the PR #56 review. + // + // Reported rather than skipped: dropping the id would answer a question + // about fewer processors than were asked about, and "these share + // nothing" would become indistinguishable from "one of these cannot + // exist here". That is the conflation `Observed` exists to remove, + // arriving by a different door. let mut set = ProcessorSet::empty(); + let mut unrepresentable = Vec::new(); for id in processors { - set.insert(id.group, id.number); + if ProcessorSet::can_represent(id.number) { + set.insert(id.group, id.number); + } else { + unrepresentable.push(*id); + } } Proximity { shared: self.minimal_shared(&set), finer_unobserved: processors .iter() .any(|id| !self.kinds_covering(*id).is_empty()), + unrepresentable, } } @@ -289,6 +313,20 @@ pub struct Proximity<'a> { /// /// [EP-D-2]: ../../topology-planner/DESIGN-NOTES.md pub finer_unobserved: bool, + /// Processors the query named that this platform cannot express, and which + /// therefore took no part in [`Self::shared`]. + /// + /// Empty in every ordinary case. Non-empty means a caller passed a + /// `ProcessorId` whose `number` is at least `usize::BITS` -- constructible, + /// because the field is public and it is a `u8`, and target-dependent, + /// because that ceiling is 64 on x86-64 and 32 on `i686-pc-windows-msvc`. + /// + /// Carried rather than skipped so "these processors share nothing" stays + /// distinguishable from "one of these could not exist on this machine". + /// A caller that ignores this field gets the first reading of the second + /// situation, which is exactly the conflation this crate's `Observed` + /// exists to prevent elsewhere. + pub unrepresentable: Vec, } impl<'a> Proximity<'a> { diff --git a/crates/windows-topology-sys/src/granularity/tests.rs b/crates/windows-topology-sys/src/granularity/tests.rs index affec81b..fbdd5ae4 100644 --- a/crates/windows-topology-sys/src/granularity/tests.rs +++ b/crates/windows-topology-sys/src/granularity/tests.rs @@ -488,3 +488,60 @@ fn an_unknown_processor_yields_an_empty_answer_not_the_machine() { let t = topology(2, vec![core(&[0, 1])]); assert!(t.proximity(&[id(0), id(7)]).shared.is_empty()); } + +#[test] +fn a_processor_number_too_wide_for_this_platform_is_reported_not_panicked() { + // `ProcessorId`'s fields are public and `number` is a `u8`, so any value up + // to 255 is constructible; a `ProcessorSet` holds one bit per processor in + // a `usize`, and `insert` asserts on anything wider. `proximity` used to + // pass the id straight in, so a public query aborted the process over its + // own argument. Raised in the PR #56 review. + // + // The ceiling is `usize::BITS`, so this test's id is out of range on every + // supported target: 64 on x86-64, 32 on `i686-pc-windows-msvc`, and 255 is + // beyond both. That target-dependence is the sharp part -- a `number` of 40 + // is an ordinary query on one and an abort on the other. + let topology = topology(2, Vec::new()); + let impossible = ProcessorId { + group: 0, + number: 255, + }; + + let proximity = topology.proximity(&[impossible]); + + assert_eq!( + proximity.unrepresentable, + vec![impossible], + "an id this platform cannot express must be named, not silently dropped" + ); +} + +#[test] +fn an_unrepresentable_processor_does_not_corrupt_the_answer_for_the_others() { + // The complement: the representable processors must still be answered + // about. Skipping the bad id and saying nothing would answer a question + // about fewer processors than were asked, and a caller comparing two + // processors would read "nothing shared" from an argument it got wrong. + let topology = topology(2, Vec::new()); + let real = ProcessorId { + group: 0, + number: 0, + }; + let impossible = ProcessorId { + group: 0, + number: 255, + }; + + let alone = topology.proximity(&[real]); + let mixed = topology.proximity(&[real, impossible]); + + assert!( + mixed.unrepresentable.contains(&impossible), + "the bad id must be reported" + ); + assert_eq!( + mixed.shared.len(), + alone.shared.len(), + "the representable processor's answer must be unchanged by its company" + ); +} diff --git a/crates/windows-topology-sys/src/processor_set.rs b/crates/windows-topology-sys/src/processor_set.rs index 0b20e8ed..2b5f8763 100644 --- a/crates/windows-topology-sys/src/processor_set.rs +++ b/crates/windows-topology-sys/src/processor_set.rs @@ -40,12 +40,27 @@ impl ProcessorSet { set } + /// Whether a processor number fits this platform's group width. + /// + /// A `ProcessorSet` holds one bit per processor in a `usize`, so the + /// ceiling is `usize::BITS` -- 64 on x86-64, 32 on `i686-pc-windows-msvc`. + /// `ProcessorId::number` is a `u8` and its field is public, so a caller can + /// build an id this set cannot hold; [`Self::insert`] asserts on one, and + /// this is how a caller asks *before* rather than being aborted. + #[must_use] + pub fn can_represent(number: u8) -> bool { + u32::from(number) < MAX_PROCESSORS_PER_GROUP + } + /// Add `number` within `group` to the set. /// /// # Panics /// /// Panics if `number` is `MAX_PROCESSORS_PER_GROUP` or greater: a /// processor group cannot hold more processors than a mask has bits. + /// [`Self::can_represent`] is how a caller holding a publicly-constructed + /// [`crate::ProcessorId`] asks first -- the panic is for a caller building + /// a set it controls, not a way to reject a query's argument. pub fn insert(&mut self, group: u16, number: u8) { assert!( u32::from(number) < MAX_PROCESSORS_PER_GROUP, From e78154b813cba09b2cae4287121535dd906d0d1e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 15:13:04 -0400 Subject: [PATCH 325/361] docs(topology): say that a CPU-set-only processor is reported, not listed `Coherence::Disagreed` can name a processor the CPU-set enumeration saw and the relationship walk did not, and `processors` does not gain an entry for it. The PR #56 review raised that as an omission. It is one, and it is deliberate -- but neither side said so, which is what made it read as an oversight. `processors` carries the walk's view, and every `Processor` in it has a core, a class and domain memberships because the walk described them. A processor the walk never mentioned has none of those, so synthesizing an entry would put a record in the list with fabricated or empty fields, indistinguishable by shape from one the platform actually described. That is the confusion D-13 and `Observed` exist to remove, arriving through a field rather than a value. The outlet already existed and is now cited from both ends: the `cpu_sets_only` field says these are absent from `processors`, and `processors` says they are named in `coherence`. A caller reading only `processors` gets the walk's view, which is what that field has always been. Recorded as D-26, which closes the question rather than deferring it: no checklist item, because the shape is correct and the documentation was what was missing. A merged list, if one is ever wanted, belongs as a derived view over both sources rather than as a mutation of the walk's. Rarity is deliberately not the argument. Reaching this state needs a disagreement surviving every pass of the retry, which D-17 attributes to prerelease hardware and defective firmware tables -- and D-17 also says those are exactly the machines a user most needs to get right. The argument is that a fabricated `Processor` is worse than an absent one, because an absent processor is visible in `cpu_sets_only` and a fabricated one is visible nowhere. Raised in the PR #56 review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/DESIGN-NOTES.md | 32 +++++++++++++++++++++ crates/windows-topology-sys/src/topology.rs | 24 ++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 8d0eed2c..350418e4 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -824,3 +824,35 @@ listed re-opens exactly this hole. A test that derived one from the other was pr asking the next editor to remember. Raised in the PR #56 review. + +## D-26: a processor only CPU Sets saw is reported, not synthesized + +[D-16](#d-16)'s retry can end in `Coherence::Disagreed`, and one shape of that disagreement is a +processor the CPU-set enumeration reported and the relationship walk did not. `MachineMemoryTopology::processors` +does **not** gain an entry for it. The PR #56 review raised this as an omission; it is one, and it is +deliberate. + +**Synthesizing the entry is the option that looks helpful and is not.** `processors` carries the walk's +view, and every `Processor` in it has a core, an efficiency class and domain memberships *because the +walk described them*. A processor the walk never mentioned has none of those. Adding it would put a +record in the list with fabricated or empty fields, indistinguishable by shape from one the platform +actually described -- the precise confusion [D-13](#d-13) and `Observed` exist to remove, arriving +through a field rather than through a value. + +**The outlet already exists, which is what makes the omission tolerable.** `Coherence::Disagreed` +carries `cpu_sets_only`, so the processors are named, and both sides now say so: the field documents +that they are absent from `processors`, and `processors` documents that they are named in coherence. +A caller reading only `processors` gets the walk's view -- which is what that field has always been -- +and one that needs the disagreement has it in full. + +**On likelihood, and why that is not the reason.** Reaching this state needs a disagreement that +survives every pass of the retry, which [D-17](#d-17) says comes from prerelease hardware, defective +firmware tables, or a topology feature landing in one enumeration before the other. Rare, and rare is +not the argument: [D-17](#d-17) also says those are exactly the machines a user most needs to get +right. The argument is that a fabricated `Processor` would be *worse* than an absent one, because an +absent processor is visible in `cpu_sets_only` while a fabricated one is visible nowhere. + +**Not scheduled as work.** This decision closes the question rather than deferring it: there is no +checklist item, because the answer is that the current shape is correct and the documentation was what +was missing. Should a consumer ever need a merged list, it belongs as a derived view built from both +sources, not as a mutation of the walk's. diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index e45f252d..943ce13d 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -64,6 +64,12 @@ pub enum Coherence { /// Processors the relationship walk reported and CPU Sets did not. walk_only: Vec, /// Processors CPU Sets reported and the relationship walk did not. + /// + /// **These are absent from [`MachineMemoryTopology::processors`]**, + /// which carries the walk's view alone. This field is where they are + /// named, and reading it is the only way to learn they exist -- see + /// [D-26](../DESIGN-NOTES.md#d-26) for why they are reported here + /// rather than synthesized into a list that has one source. cpu_sets_only: Vec, /// How many passes were made before giving up. attempts: u32, @@ -84,6 +90,24 @@ pub enum Coherence { pub struct MachineMemoryTopology { /// Every logical processor, including one for each inactive slot up to a /// group's maximum processor count. + /// + /// **As the relationship walk reported them.** A processor that only the + /// CPU-set enumeration saw is *not* here, and that is a real omission + /// rather than an oversight: this list has one source, and inventing an + /// entry for a processor the walk never described would put a `Processor` + /// here with no `core`, no class and no domain membership -- a fabricated + /// record of exactly the kind [`Observed`] exists to prevent. + /// + /// The disagreement is not lost, it is [reported](Self::coherence): + /// [`Coherence::Disagreed`] names such processors in its `cpu_sets_only` + /// field. A caller that needs them can read them there, and one that + /// ignores coherence sees the walk's view, which is what this field has + /// always been. + /// + /// Reaching that state takes a disagreement surviving every pass of + /// [`Self::discover`]'s retry, so it is not the ordinary case -- but the + /// ordinary case is not what a topology crate is for. See + /// [D-26](../DESIGN-NOTES.md#d-26). pub processors: Vec, /// Every domain. pub domains: Vec, From f30682d9be38ebc6e498ea97cfc4ba748c8805b9 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 15:30:24 -0400 Subject: [PATCH 326/361] docs: record that PR #56 is out of draft (SH-3.1.2) Verified from the API rather than from having been told: the PR reports `isDraft=false` and `mergeable=MERGEABLE`. The item's whole point was that "who decides it is ready" had no owner, so its outcome is recorded the same way -- observed, not assumed. Leaves SH-3.2 (the full gate on the merge result) and SH-3.4 (the merge itself) as the only items still gating the merge. Completed item: SH-3.1.2: The engineer takes PR #56 out of draft, after reading the diff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 35963947..6ab49f29 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -275,7 +275,11 @@ that previously stood in the way are gone: [crates/windows-topology-sys/CHECKLIST.md](crates/windows-topology-sys/CHECKLIST.md) rather than folded into a merging PR. Two release-please surprises became SH-3.4.1 and SH-3.4.2. -- [ ] **SH-3.1.2** -- **The engineer takes PR #56 out of draft**, after reading the diff. Assigned by +- [x] **SH-3.1.2** -- **The engineer takes PR #56 out of draft**, after reading the diff. + **Done 2026-09-04: the PR reports `isDraft=false` and `mergeable=MERGEABLE`.** Recorded here from + the API rather than from having been told, because the item's whole point was that the decision has + an owner and a visible outcome. + Originally: assigned by decision on 2026-09-03 rather than left unowned, which is the gap SH-3.1.1 named. A draft cannot be merged, so this gates SH-3.4 whether or not anything else is outstanding. Not blocking on SH-3.2/SH-3.3 by necessity -- those can run against an open PR -- but they were From 3cb02723ade97e7e86f02fc53d3564a55c732f29 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 15:57:06 -0400 Subject: [PATCH 327/361] fix(placement-probe): floor a submitted record's timestamp to the minute, in UTC A submitted record describes someone's own machine, and a second-precision timestamp is close to a serial number: it links two submissions from one host to each other even after every identifying field has been withheld. Nothing in the analysis needs finer than a minute -- these measure a machine's shape, not an ordering of events. UTC with no local offset, because an offset narrows the submitter to a band of longitudes and buys nothing. `recorded_at_subsecond_millis` is untouched. It is already `serde(skip)` and exists only so two runs in one second get distinct file names, so it never reached the record in the first place. The test pins that the *flooring* does the work rather than the renderer, by showing `iso8601_utc` faithfully reports a non-zero seconds field when given one. A test that only checked the floored case would still pass if someone made the renderer truncate instead -- which would leave `recorded_at_epoch_seconds` disagreeing with the string beside it. No `SCHEMA_VERSION` bump: the freeze starts at the first release and this crate has not had one. Queues M36.2-M36.4 for the rest of the engineer's design -- redact the secondary metadata by default, state in the README what redaction costs, and ask for an unredacted record privately when the topology's two sources disagreed. Recorded as checklist items rather than left in a chat thread, because a decision nobody transcribed is one nothing will pick up. Completed item: M36.1: Floor the record's timestamp to the minute, in UTC Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 43 +++++++++++++++++++ crates/windows-placement-probe/src/record.rs | 38 ++++++++++++++-- .../src/record/tests.rs | 29 +++++++++++++ 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 7d0d0ec2..6da75a87 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -653,3 +653,46 @@ actionable is identifying and therefore belongs behind the review this tool alre So the honest framing is unchanged rather than weakened -- if the hardware is confidential, the right answer remains not to send it -- but the README's list of what is collected must grow to match, per `PT-4.3`, and the runner must still see the real values before deciding, per `PT-4.5`. +## M36 -- Redact the secondary metadata by default + +- [x] **M36.1** -- **Floor the record's timestamp to the minute, in UTC.** Done 2026-09-04. A + second-precision stamp links two submissions from one host to each other even after every + identifying field is withheld, and nothing in the analysis needs finer -- these measure a machine's + shape, not an ordering of events. UTC with no local offset, because an offset narrows the submitter + to a band of longitudes for no gain. `recorded_at_subsecond_millis` is untouched: it is + `serde(skip)` and exists only so two runs in one second get distinct file names. + +- [ ] **M36.2** -- **Redact the secondary metadata by default, with an opt-in to include it.** + Engineer's decision, 2026-09-04. The secondary metadata is the timestamp, the OS build, and the + hypervisor name/hint -- everything in `MachineDescription` and the `recorded_at*` fields that is + *context* rather than *measurement*. The topology is excluded from this by construction: it is the + measurement, and the README already says so plainly. + **Default flips to redacted.** `--no-cpu-model` becomes one case of a general rule rather than the + only switch. Decide whether the opt-in is one flag or per-field; a single `--include-metadata` is + the smaller surface and is the recommendation unless a per-field need appears. + **Suppression must stay distinguishable from absence**, which the existing `model_suppressed` flag + already does for the model: a field withheld by the runner and a field the host would not answer are + different facts, and a collector that cannot tell them apart will read one as the other. Every newly + redactable field needs the same treatment. + **No `SCHEMA_VERSION` bump**: the freeze starts at the first release and this crate has not had one. + +- [ ] **M36.3** -- **Say in the README what redaction costs.** There is real value in correlating + metadata anomalies with specific platform versions -- a defect that shows up only on one OS build, + or only under one hypervisor, is exactly what the secondary metadata is for. A reader choosing to + include it should understand they are helping, and a reader choosing not to should understand what + they are withholding. State the trade rather than presenting redaction as free. + +- [ ] **M36.4** -- **On `Coherence::Disagreed`, ask for the unredacted record privately.** The report + emits extra text when the topology's two sources disagreed past the retry: say that the metadata was + inconsistent, and ask the runner to contact the `windows-threadpool-sys` maintainers through the + discussions or issues boards and share an **unredacted** probe file **privately**, so the + inconsistency can be verified -- or the probe fixed -- and a bug logged with Windows. + **This is the point of the whole design.** Redaction is the default because most records do not need + the context; the one case where the context matters most is a disagreement, which + [D-17](crates/windows-topology-sys/DESIGN-NOTES.md#d-17) attributes to prerelease hardware, + defective firmware tables, or a feature landing in one enumeration before the other -- the + bug-worthy cases. So the request is made exactly there, and privately, rather than by collecting + everything from everyone against the possibility. + Depends on M36.2 (there must be something to un-redact) and on `Coherence` being reachable from + the record, which it is: `topology_provenance` is already carried, and `Fingerprint` is built from + the topology. diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index e5c0ba45..afb2fc09 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -68,15 +68,30 @@ use crate::machine::MachineDescription; /// at the first release, not before". pub const SCHEMA_VERSION: u32 = 1; +/// Seconds in a minute, which is the resolution a submitted record keeps. +const SECONDS_PER_MINUTE: u64 = 60; + /// One run's complete output. #[derive(Clone, Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize))] pub struct SubmissionRecord { /// Which shape this record has. See [`SCHEMA_VERSION`]. pub schema_version: u32, - /// When the run finished, as an ISO-8601 UTC timestamp. + /// When the run finished, as an ISO-8601 UTC timestamp **floored to the + /// minute**. + /// + /// The seconds field is therefore always `00`, and that is deliberate + /// rather than an artefact. A submitted record describes someone's own + /// machine, and a second-precision timestamp links two submissions from one + /// host to each other even after every identifying field is withheld. + /// Nothing in the analysis needs finer: these measure a machine's shape, + /// not an ordering of events. + /// + /// UTC with no local offset, because an offset narrows the submitter to a + /// band of longitudes and buys nothing. pub recorded_at: String, - /// The same instant in seconds since the Unix epoch. + /// The same minute in seconds since the Unix epoch, and so always a + /// multiple of 60. /// /// Carried beside the formatted form because a collector should never have /// to parse prose to sort records, and because it survives any later change @@ -287,11 +302,26 @@ impl SubmissionRecord { .duration_since(UNIX_EPOCH) .unwrap_or_default(); let now = since_epoch.as_secs(); + // **Floored to the minute, and the record carries only the floored + // value.** A submitted record is a thing someone hands over about their + // own machine, and a second-precision timestamp is close to a serial + // number: it links two submissions from one host to each other even + // when every other identifying field has been withheld. Nothing in the + // analysis needs finer than a minute -- these are measurements of a + // machine's shape, not of an event ordering. + // + // UTC, and no local offset anywhere, because an offset narrows the + // submitter to a band of longitudes for no analytical gain. + // + // `recorded_at_subsecond_millis` is unaffected: it is `serde(skip)` and + // exists only so two runs in one second get distinct *file names*. It + // never reaches the record. + let recorded_minute = now - (now % SECONDS_PER_MINUTE); Ok(Self { schema_version: SCHEMA_VERSION, - recorded_at: iso8601_utc(now), - recorded_at_epoch_seconds: now, + recorded_at: iso8601_utc(recorded_minute), + recorded_at_epoch_seconds: recorded_minute, recorded_at_subsecond_millis: since_epoch.subsec_millis(), build: BuildIdentity::current(), machine, diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index 6fe85631..36eb9641 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -351,3 +351,32 @@ fn a_record_assembles_when_the_announced_and_measured_hosts_agree() { assert_eq!(record.host, host); } + +#[test] +fn flooring_to_the_minute_is_what_zeroes_the_seconds_field() { + // A submitted record describes someone's own machine, and a + // second-precision timestamp is close to a serial number: it links two + // submissions from one host to each other even after every identifying + // field has been withheld. Nothing in the analysis needs finer than a + // minute -- these measure a machine's shape, not an ordering of events. + // + // Pins that the *flooring* does the work rather than the renderer, by + // showing the renderer faithfully reports a non-zero seconds field when + // given one. A test that only checked the floored case would still pass if + // someone made `iso8601_utc` truncate, which would leave + // `recorded_at_epoch_seconds` disagreeing with the string beside it. + let unaligned = 1_788_177_637; // ...:00:37Z + let floored = unaligned - (unaligned % 60); + + assert!( + super::iso8601_utc(unaligned).ends_with(":37Z"), + "the renderer must report the seconds it is given: {}", + super::iso8601_utc(unaligned) + ); + assert!( + super::iso8601_utc(floored).ends_with(":00Z"), + "a floored instant renders a zero seconds field: {}", + super::iso8601_utc(floored) + ); + assert_eq!(floored % 60, 0, "and the epoch value is a whole minute"); +} From e451b4036a0cda847a9085eb670d7c099e7cca22 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 16:18:18 -0400 Subject: [PATCH 328/361] feat(placement-probe)!: withhold the secondary metadata unless the runner opts in The record's context -- the run's minute, the CPU model, the OS build, the virtualisation hint -- is now withheld by default and sent only with --include-metadata. The measurement (topology, placements, timings) is not redactable and never was: it is the reason the record exists, and the README says so plainly rather than implying a redaction story that does not exist. --no-cpu-model survives as a subtraction from --include-metadata, because the confidential-part case it was built for is not covered by the general opt-in. Passed alone it withholds something already withheld, which is redundant rather than wrong, and a test pins that it stays harmless. Every withheld field records that it was withheld, so a collector can always tell "the runner did not send this" from "the host would not answer": os_build_suppressed and recorded_at_suppressed beside their Options, and a VirtualisationHint::Suppressed variant rather than a flag, since that enum's other variants are all claims about what was observed and NotDetected would assert a negative finding nobody made. A withheld field is not read at all rather than read and discarded, so the module's commitment about what it does not touch is kept by control flow. The backup file's name drops the stamp with the record, which is the one place a withheld minute could still escape. No SCHEMA_VERSION bump: the freeze starts at the first release and this crate has not had one. schema/v1.txt was regenerated in place. All six guards are sabotage-verified in a new crates/windows-placement-probe/sabotage.json. Completed item: M36.2: Redact the secondary metadata by default, with an opt-in to include it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 11 +- .../windows-placement-probe/DESIGN-NOTES.md | 94 +++++++++++ crates/windows-placement-probe/README.md | 33 +++- crates/windows-placement-probe/sabotage.json | 85 ++++++++++ crates/windows-placement-probe/schema/v1.txt | 2 + .../src/bin/placement_probe/main.rs | 145 +++++++++++++---- .../src/bin/placement_probe/tests.rs | 146 ++++++++++++++++-- crates/windows-placement-probe/src/lib.rs | 2 + crates/windows-placement-probe/src/machine.rs | 78 ++++++++-- .../src/machine/tests.rs | 70 +++++++-- .../src/paste_json/tests.rs | 1 + crates/windows-placement-probe/src/record.rs | 38 ++++- .../src/record/tests.rs | 72 ++++++++- .../windows-placement-probe/src/redaction.rs | 134 ++++++++++++++++ .../src/redaction/tests.rs | 73 +++++++++ crates/windows-placement-probe/src/report.rs | 41 +++-- .../src/report/tests.rs | 50 +++++- .../windows-placement-probe/src/submission.rs | 40 +++-- .../src/submission/tests.rs | 43 +++++- 19 files changed, 1042 insertions(+), 116 deletions(-) create mode 100644 crates/windows-placement-probe/sabotage.json create mode 100644 crates/windows-placement-probe/src/redaction.rs create mode 100644 crates/windows-placement-probe/src/redaction/tests.rs diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 6da75a87..e075343c 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -662,7 +662,16 @@ actionable is identifying and therefore belongs behind the review this tool alre to a band of longitudes for no gain. `recorded_at_subsecond_millis` is untouched: it is `serde(skip)` and exists only so two runs in one second get distinct file names. -- [ ] **M36.2** -- **Redact the secondary metadata by default, with an opt-in to include it.** +- [x] **M36.2** -- **Redact the secondary metadata by default, with an opt-in to include it.** + Done 2026-09-04, with a single `--include-metadata` as recommended; `--no-cpu-model` survives as a + subtraction from it, because the confidential-part case it was built for is not covered by the + general opt-in and passing it alone is redundant rather than wrong. Suppression is recorded for + every newly redactable field: `os_build_suppressed` and `recorded_at_suppressed` beside their + `Option`s, and a `VirtualisationHint::Suppressed` variant rather than a flag, since that enum's + other variants are all claims about what was observed. The backup file's name drops the stamp with + the record, so the withheld minute cannot escape through a file a runner attaches. See + [DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md) -> "The measurement is not + redactable; the context is, and is withheld by default". Engineer's decision, 2026-09-04. The secondary metadata is the timestamp, the OS build, and the hypervisor name/hint -- everything in `MachineDescription` and the `recorded_at*` fields that is *context* rather than *measurement*. The topology is excluded from this by construction: it is the diff --git a/crates/windows-placement-probe/DESIGN-NOTES.md b/crates/windows-placement-probe/DESIGN-NOTES.md index 43c5c926..d8c73003 100644 --- a/crates/windows-placement-probe/DESIGN-NOTES.md +++ b/crates/windows-placement-probe/DESIGN-NOTES.md @@ -208,3 +208,97 @@ one. No schema version bump: the freeze starts at the first release and this crate has not had one. Raised in the PR #56 review, as two suppressed comments on the same defect. + +## The measurement is not redactable; the context is, and is withheld by default + +A record holds two kinds of thing, and they earn opposite defaults. + +The **measurement** -- the topology, the placements, the timings -- is the reason +the record exists. Redacting it leaves a file that says nothing, and it is also +the part that most identifies unusual hardware, so no switch will ever withhold +it. The README says that plainly rather than implying a redaction story that +does not exist: an unreleased part is identified by an unusual core count and a +novel cache arrangement at least as well as by its name. + +The **context** -- the minute the run finished, the CPU model, the OS build, the +virtualisation hint -- explains a measurement without being one. All of it is +now withheld unless the runner passes `--include-metadata`. + +**The default flipped, and that is the whole change.** The tool began by +collecting the context and offering `--no-cpu-model` as the single escape hatch, +which asks a stranger doing a favour to recognise in advance which field they +would rather not send. Defaulting to redacted asks nothing of them. + +`--no-cpu-model` survives as a *subtraction* from `--include-metadata`, because +the case it was built for is real and is not covered by the general opt-in: a +runner willing to send an OS build and a hypervisor name may still be sitting in +front of a part whose name is not theirs to publish. Passed on its own it +withholds something already withheld, which is redundant rather than an error and +is tested to stay harmless -- a cautious runner who passes both flags must get +the same record as one who passes neither. + +**One policy, decided once.** `MetadataPolicy` is built from the flags in +`Options::metadata_policy` and then handed to `MachineDescription::read` and +`SubmissionRecord::new`. Every consumer -- the disclosure notice, the machine +read, the record, the report -- reads that one answer instead of re-deriving it +from the flags, so the notice cannot describe a policy the record does not +implement. + +**A withheld field is not read at all**, rather than read and then dropped. The +registry call does not happen, so the module's commitment about what it does not +touch is kept by control flow rather than by a discard a later refactor could +lose. The notice therefore shows a withheld row as withheld instead of previewing +a value that will not be sent; there is nothing for a runner to judge in a value +nobody is asking for. + +**Suppression is recorded, never merely absent.** "The runner did not send this" +and "the host would not answer" are different facts, and a collector that cannot +tell them apart will eventually read one as the other. The mechanism differs by +field only because the types do: + +- `cpu_model` and `os_build` are `Option` beside a `*_suppressed` flag, + following the pattern `model_suppressed` already established. +- `virtualisation` gained a `Suppressed` **variant** rather than a flag, because + every other variant is a claim about what was observed and a withheld hint has + no honest value to fall back on. `NotDetected` would assert a negative finding + nobody made -- on the field that decides whether a submission could ever have + shown NUMA rows -- and `Unknown` would blame the firmware. Carrying it in the + enum also states the fact once, where a variant plus a boolean could disagree. +- `recorded_at` is `Option` beside `recorded_at_suppressed`, which is + **redundant by construction today**: a clock cannot decline to answer the way a + registry key can, so the timestamp is absent only when withheld. It is carried + anyway, because that is a fact about the implementation and a collector should + not have to know it -- every other withheld field says so in the data, and a + hand-assembled record (every field is public) can drop the timestamp without + meaning to claim anything. + +**The file name loses the stamp too.** `submission::file_name` is derived from +the record, so a record with no timestamp yields `placement-probe-v1-250.json`. +Reaching past the record for the clock would put the withheld minute back into a +file name the runner may well attach, which is the one place it could still +escape. Nothing is lost but convenience: the collision *guarantee* was always the +exclusive create and the numbered suffix in the writer, and the milliseconds that +keep that suffix from being needed in practice are still there. + +**This supersedes nothing about the minute-flooring**, which still applies to the +value that survives an opt-in. Flooring answers "how precise may a timestamp we +do send be"; this answers "do we send one at all". + +No `SCHEMA_VERSION` bump: the freeze starts at the first release and this crate +has not had one. `schema/v1.txt` was regenerated in place, per the decision +above. + +**Every guard above is sabotage-verified**, in +[sabotage.json](sabotage.json): six defects -- a withheld hint falling back to +the enum default, each of the two registry reads happening regardless of policy, +a record stamping a timestamp regardless of policy, a file name carrying a stamp +the record withheld, and `--no-cpu-model` ceasing to subtract -- and all six turn +the suite red. The mirror-image claim, that an *opted-in* field is present, is +deliberately not sabotaged there, because it depends on what the host will +answer. + +Engineer's decision, 2026-09-04. Queued as `M36.2` in +[CHECKLIST-placement-tool.md](../../CHECKLIST-placement-tool.md); `M36.3` states +in the README what redaction costs, and `M36.4` asks for an unredacted record +privately when the topology's sources disagreed -- the one case where the context +matters most. diff --git a/crates/windows-placement-probe/README.md b/crates/windows-placement-probe/README.md index be76b937..705013e1 100644 --- a/crates/windows-placement-probe/README.md +++ b/crates/windows-placement-probe/README.md @@ -35,6 +35,10 @@ placement-probe --preview see exactly what it collects, measure nothing placement-probe measure, and print a result to paste ``` +By default a result carries the machine's **shape and its timings and nothing +else**. `--include-metadata` adds the context described below, and is worth +reading about before you decide either way. + The run states its own worst-case duration before starting. It is usually a second or two, and grows with the number of NUMA nodes. @@ -45,24 +49,37 @@ paste -- it will render correctly without you doing anything else. ## What it collects, and what it does not -**Collected:** the shape of the machine (logical processors, cores, cache -domains, efficiency classes, NUMA nodes), the CPU model, the OS build, whether -virtualisation was detected, and the timings it measures. +**Always collected:** the shape of the machine (logical processors, cores, cache +domains, efficiency classes, NUMA nodes) and the timings it measures. That is +the measurement -- a result without it says nothing -- so it is not redactable. + +**Collected only with `--include-metadata`:** the CPU model, the OS build, +whether virtualisation was detected, and the minute the run finished. These +explain a measurement without being one, so they are **withheld by default**. -**Not collected:** your host name, your user name, file paths, environment +**Never collected:** your host name, your user name, file paths, environment variables, serial numbers, or anything about installed software. That list is a commitment, not a description of the current implementation. **It makes no network connections.** It writes a file and prints text; sending either is your decision and your action. -`--preview` shows the values it would collect **before** measuring, so you can -decide with the real values in front of you rather than a promise about them. -`--no-cpu-model` withholds the processor name. +`--preview` shows what it would collect **before** measuring, so you can decide +with the real values in front of you rather than a promise about them. Under the +default it shows those rows as withheld, because a field it will not send is one +it does not read. + +`--no-cpu-model` withholds the processor name. It subtracts from +`--include-metadata`, so on its own it changes nothing -- the model is already +withheld. + +A withheld field is recorded as withheld rather than merely left blank, so +somebody reading a submission can always tell "the runner did not send this" +from "this host would not say". ### If the hardware is confidential, do not send the result -`--no-cpu-model` reduces incidental leakage and nothing more. An unreleased part +Redaction reduces incidental leakage and nothing more. An unreleased part is identified by its **topology** -- an unusual core count, a novel cache arrangement -- at least as well as by its name, and the topology is the measurement. No switch fixes that, and it would be dishonest to imply otherwise. diff --git a/crates/windows-placement-probe/sabotage.json b/crates/windows-placement-probe/sabotage.json new file mode 100644 index 00000000..34057d0d --- /dev/null +++ b/crates/windows-placement-probe/sabotage.json @@ -0,0 +1,85 @@ +{ + "package": "windows-placement-probe", + "description": "Sabotages for the redaction policy: the default that withholds the secondary metadata, the per-field way each withholding is recorded, and the file name that must not leak a withheld timestamp. Run with tools/run-sabotage.ps1; see tools/README-sabotage.md for the format and for why the results are read the way they are.", + "notCoveredHere": "These sabotages assert that a *withheld* field is absent, which is a claim about this tool's policy and holds on any host. The mirror-image claim -- that an opted-in field is present -- is deliberately NOT sabotaged here, because it depends on what the host will answer: a machine whose registry declines to name its processor would report the sabotage as caught for the wrong reason. The opted-in direction is covered by the machine tests, which assert shape and policy rather than any particular value.", + "sabotages": [ + { + "name": "a withheld virtualisation hint falls back to the enum default", + "file": "src/machine.rs", + "expect": "caught", + "why": "The exact trap the Suppressed variant exists to close. `NotDetected` is the enum's default, so a withheld hint that fell back to it would tell a reader this machine had been examined and found to be bare metal -- a claim nobody made, on the field that decides whether a submission could ever have shown NUMA rows.", + "find": [ + " (VirtualisationHint::Suppressed, None)" + ], + "replace": [ + " (VirtualisationHint::default(), None)" + ] + }, + { + "name": "the cpu model is read whatever the policy says", + "file": "src/machine.rs", + "expect": "caught", + "why": "The module promises that a field the policy withholds is not read at all, rather than read and then dropped. This is the shape that promise fails in: the registry call happens and the value lands in the record under the default policy.", + "find": [ + " cpu_model: policy.includes_cpu_model().then(read_cpu_model).flatten()," + ], + "replace": [ + " cpu_model: read_cpu_model()," + ] + }, + { + "name": "the os build is read whatever the policy says", + "file": "src/machine.rs", + "expect": "caught", + "why": "The same defect on the field M36.2 made redactable, kept separate because os_build_suppressed is a different flag from model_suppressed and a guard covering only the model would leave this open.", + "find": [ + " os_build: policy.includes_os_build().then(read_os_build).flatten()," + ], + "replace": [ + " os_build: read_os_build()," + ] + }, + { + "name": "the record stamps a timestamp whatever the policy says", + "file": "src/record.rs", + "expect": "caught", + "why": "Even a minute correlates two submissions from one host, which is why the timestamp is context rather than measurement. Note that this leaves recorded_at_suppressed saying true beside a populated timestamp, so the record would also contradict itself.", + "find": [ + " let recorded_minute = policy", + " .includes_timestamp()", + " .then(|| now - (now % SECONDS_PER_MINUTE));" + ], + "replace": [ + " let recorded_minute = Some(now - (now % SECONDS_PER_MINUTE));" + ] + }, + { + "name": "the file name carries a stamp the record withheld", + "file": "src/submission.rs", + "expect": "caught", + "why": "The one place a withheld minute could still escape. A runner who attaches the backup file rather than pasting the text would hand over, in the name, exactly the value the record was built to omit.", + "find": [ + " None => String::new()," + ], + "replace": [ + " None => \"2026-01-01T00-00-00Z-\".to_owned()," + ] + }, + { + "name": "--no-cpu-model stops subtracting from --include-metadata", + "file": "src/bin/placement_probe/main.rs", + "expect": "caught", + "why": "The composition that makes the second switch worth keeping. Without it, a runner who opted in and then withheld the model would silently send the model anyway -- the one case the flag was built for, and the one where being wrong costs the most.", + "find": [ + " if self.suppress_model {", + " policy.without_cpu_model()", + " } else {", + " policy", + " }" + ], + "replace": [ + " policy" + ] + } + ] +} diff --git a/crates/windows-placement-probe/schema/v1.txt b/crates/windows-placement-probe/schema/v1.txt index 3a7cd4e3..00df2626 100644 --- a/crates/windows-placement-probe/schema/v1.txt +++ b/crates/windows-placement-probe/schema/v1.txt @@ -57,6 +57,7 @@ machine machine.cpu_model machine.model_suppressed machine.os_build +machine.os_build_suppressed machine.virtualisation machine.virtualisation_name node_hops @@ -97,5 +98,6 @@ placements[].slice placements[].strategy recorded_at recorded_at_epoch_seconds +recorded_at_suppressed schema_version topology_provenance diff --git a/crates/windows-placement-probe/src/bin/placement_probe/main.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs index 5ea7b379..ee390ca1 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/main.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -17,8 +17,9 @@ use sink::{Sink, Stdio, emit}; use windows_placement_probe::build_identity::BuildIdentity; use windows_placement_probe::core_affinity::{self, RunPlan}; use windows_placement_probe::fingerprint::{Fingerprint, places_from_topology}; -use windows_placement_probe::machine::MachineDescription; +use windows_placement_probe::machine::{MachineDescription, VirtualisationHint}; use windows_placement_probe::record::SubmissionRecord; +use windows_placement_probe::redaction::MetadataPolicy; use windows_placement_probe::submission::{self, DISCUSSION_URL}; use windows_topology_sys::MachineMemoryTopology; @@ -26,7 +27,15 @@ use windows_topology_sys::MachineMemoryTopology; struct Options { /// Show what would be collected, and measure nothing. preview: bool, + /// Include the secondary metadata, which is withheld by default. + include_metadata: bool, /// Withhold the CPU model. + /// + /// Kept as a separate switch rather than folded into + /// [`Self::include_metadata`] because it subtracts from it: a runner + /// willing to send an OS build and a hypervisor name may still be sitting + /// in front of a part whose name is not theirs to publish. Redundant on its + /// own, and harmless -- see [`Options::metadata_policy`]. suppress_model: bool, /// Skip writing the backup file. no_file: bool, @@ -41,6 +50,31 @@ struct Options { version: bool, } +impl Options { + /// Which secondary metadata this run will collect. + /// + /// The one place the switches become a policy, so every consumer -- the + /// notice, the machine read, the record -- is deciding from the same + /// answer rather than re-deriving it from the flags. + /// + /// `--no-cpu-model` without `--include-metadata` withholds something + /// already withheld. That is redundant rather than an error, and stays + /// harmless on purpose: a cautious runner who passes both must get the same + /// record as one who passes neither, not a worse one. + fn metadata_policy(&self) -> MetadataPolicy { + let policy = if self.include_metadata { + MetadataPolicy::included() + } else { + MetadataPolicy::redacted() + }; + if self.suppress_model { + policy.without_cpu_model() + } else { + policy + } + } +} + fn main() -> ExitCode { run(&mut Stdio) } @@ -82,7 +116,8 @@ fn run(out: &mut impl Sink) -> ExitCode { return ExitCode::SUCCESS; } - let machine = MachineDescription::read(options.suppress_model); + let policy = options.metadata_policy(); + let machine = MachineDescription::read(policy); // **One discovery, two derivations.** The announced plan and the recorded // fingerprint used to come from separate `MachineMemoryTopology::discover()` calls, so a @@ -123,10 +158,7 @@ fn run(out: &mut impl Sink) -> ExitCode { // recorded. The seam stays closed; the skew it left behind is checked. let host = Fingerprint::from_topology(&topology); - emit( - out, - &render_collection_notice(&machine, &host, options.suppress_model), - ); + emit(out, &render_collection_notice(&machine, &host, policy)); emit(out, &render_plan(&plan)); if options.preview { @@ -172,7 +204,7 @@ fn run(out: &mut impl Sink) -> ExitCode { // Cannot fail: the equality was just checked above. Handled rather than // unwrapped anyway, because the constructor owns that invariant and a panic // here would discard a measurement the runner has already paid for. - let record = match SubmissionRecord::new(&observation, host, machine) { + let record = match SubmissionRecord::new(&observation, host, machine, policy) { Ok(record) => record, Err(error) => { out.problem(&format!("the record could not be assembled: {error}")); @@ -200,10 +232,19 @@ fn run(out: &mut impl Sink) -> ExitCode { /// A person deciding whether to do this a favour should be able to decide with /// the real values in front of them rather than a promise about them, which is /// why the preview exists and why this prints what was actually read. +/// +/// # A withheld field shows as withheld, and no value is read to show it +/// +/// Under the default policy the secondary rows say they are withheld rather +/// than showing what they would have contained. Reading a value only to preview +/// something the record will not carry would contradict the module's promise +/// that a withheld field is never read at all -- and there is nothing for the +/// runner to judge in a value that is not being sent. The row a runner does +/// need to judge, the topology, is always shown, because it is always sent. fn render_collection_notice( machine: &MachineDescription, host: &Fingerprint, - suppressed: bool, + policy: MetadataPolicy, ) -> String { let mut out = String::new(); let _ = writeln!(out, "== windows-placement-probe =="); @@ -223,32 +264,47 @@ fn render_collection_notice( let _ = writeln!(out); let _ = writeln!( out, - "What it collects about this machine, as read just now:" + "What this run will put in the record, as read just now:" ); let _ = writeln!( out, " cpu model {}", - match (&machine.cpu_model, suppressed) { + match (&machine.cpu_model, machine.model_suppressed) { (Some(model), _) => model.as_str(), - (None, true) => "(withheld: --no-cpu-model)", + (None, true) => "(withheld)", (None, false) => "(this host would not say)", } ); let _ = writeln!( out, " os build {}", - machine.os_build.as_deref().unwrap_or("(unknown)") + match (&machine.os_build, machine.os_build_suppressed) { + (Some(build), _) => build.as_str(), + (None, true) => "(withheld)", + (None, false) => "(this host would not say)", + } ); + // Parenthesised when withheld, so the column reads the same way as the two + // rows above it. The hint's own `Display` stays a plain word, because it is + // the rendering of a value rather than of this table's cell. let _ = writeln!( out, - " virtualisation {}{}", - machine.virtualisation, - match &machine.virtualisation_name { - Some(name) => format!(" ({name})"), - None => String::new(), + " virtualisation {}", + match (machine.virtualisation, &machine.virtualisation_name) { + (VirtualisationHint::Suppressed, _) => "(withheld)".to_owned(), + (hint, Some(name)) => format!("{hint} ({name})"), + (hint, None) => hint.to_string(), } ); - // **The value, not the category.** Every other row here shows what was + let _ = writeln!( + out, + " run time {}", + if policy.includes_timestamp() { + "the minute this run finished, in UTC" + } else { + "(withheld)" + } + ); // **The value, not the category.** Every other row here shows what was // actually read, and this one named a subject instead -- while the // paragraph below warns that the topology identifies the part whether or // not the model is named. A runner asked to judge that could not see the @@ -277,21 +333,46 @@ fn render_collection_notice( "software. Read the printed record before sending it -- if you are not" ); let _ = writeln!(out, "happy with something in it, do not send it."); - if !suppressed { - let _ = writeln!(out); + let _ = writeln!(out); + if policy.includes_anything() { + let _ = writeln!( + out, + "You passed --include-metadata, so the rows above that this machine" + ); let _ = writeln!( out, - "Pass --no-cpu-model to withhold the model. Note that it does not make" + "would answer are being sent. Thank you -- they are what lets a result" + ); + let _ = writeln!(out, "be tied to an OS build or a hypervisor."); + if !machine.model_suppressed { + let _ = writeln!(out); + let _ = writeln!( + out, + "Pass --no-cpu-model to withhold just the model. Note that it does not" + ); + let _ = writeln!( + out, + "make confidential hardware safe to submit: the topology describes the" + ); + let _ = writeln!(out, "part whether or not it is named."); + } + } else { + let _ = writeln!( + out, + "Everything above except the topology and the timings is withheld by" ); let _ = writeln!( out, - "confidential hardware safe to submit: the topology describes the part" + "default. Pass --include-metadata to send it too: a defect that appears" ); - let _ = writeln!(out, "whether or not it is named."); + let _ = writeln!( + out, + "only on one OS build, or only under one hypervisor, can only be found" + ); + let _ = writeln!(out, "when somebody sends that context."); } out } - fn render_plan(plan: &RunPlan) -> String { let mut out = String::new(); let _ = writeln!(out); @@ -538,6 +619,7 @@ fn publish(temporary: &str, final_name: &str) -> std::io::Result<()> { fn parse_arguments() -> Result { let mut options = Options { preview: false, + include_metadata: false, suppress_model: false, no_file: false, version: false, @@ -547,6 +629,7 @@ fn parse_arguments() -> Result { for argument in std::env::args().skip(1) { match argument.as_str() { "--preview" => options.preview = true, + "--include-metadata" => options.include_metadata = true, "--no-cpu-model" => options.suppress_model = true, "--no-file" => options.no_file = true, "--version" | "-V" => options.version = true, @@ -568,11 +651,15 @@ fn help() -> String { \x20 placement-probe [OPTIONS]\n\ \n\ OPTIONS:\n\ - \x20 --preview Show what would be collected and measure nothing.\n\ - \x20 --no-cpu-model Withhold the CPU model from the record.\n\ - \x20 --no-file Do not write the backup copy of the record.\n\ - \x20 -V, --version Print this build's identity and exit.\n\ - \x20 -h, --help Print this message.\n\ + \x20 --preview Show what would be collected and measure nothing.\n\ + \x20 --include-metadata Also send the run time, the CPU model, the OS\n\ + \x20 build and the virtualisation hint, all of which\n\ + \x20 are withheld by default.\n\ + \x20 --no-cpu-model Withhold the CPU model from the record. Only\n\ + \x20 does anything beside --include-metadata.\n\ + \x20 --no-file Do not write the backup copy of the record.\n\ + \x20 -V, --version Print this build's identity and exit.\n\ + \x20 -h, --help Print this message.\n\ \n\ Results are collected at:\n\ \x20 {DISCUSSION_URL}\n" diff --git a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs index 350a11c2..c153f361 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/tests.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/tests.rs @@ -309,20 +309,36 @@ fn a_stale_temporary_from_a_recycled_pid_does_not_fail_the_backup() { // --------------------------------------------------------------------------- use super::sink::{Captured, Sink, emit}; -use super::{render_collection_notice, render_plan}; +use super::{Options, render_collection_notice, render_plan}; use windows_placement_probe::fingerprint::Fingerprint; use windows_placement_probe::machine::MachineDescription; +use windows_placement_probe::redaction::MetadataPolicy; /// A description with every field known, so a test can tell "withheld" from /// "this host would not say" -- which the notice renders differently and which /// a real machine may not offer both of. fn described() -> MachineDescription { - let mut machine = MachineDescription::read(false); + let mut machine = MachineDescription::read(MetadataPolicy::included()); machine.cpu_model = Some("Test CPU 9000".to_owned()); machine.os_build = Some("10.0.99999".to_owned()); machine } +/// The options a runner produces by passing the given switches. +/// +/// Built through [`Options`] rather than by naming a policy directly, so these +/// tests exercise the mapping from flags to policy that the tool actually uses. +fn options(include_metadata: bool, suppress_model: bool) -> Options { + Options { + preview: false, + include_metadata, + suppress_model, + no_file: false, + help: false, + version: false, + } +} + fn host() -> Fingerprint { Fingerprint::from_topology(&windows_topology_sys::MachineMemoryTopology::default()) } @@ -331,7 +347,11 @@ fn host() -> Fingerprint { fn the_notice_names_the_model_it_is_about_to_publish() { // The disclosure's central promise: what it says it collects is what it // collects. A runner judging the model has to be shown the model. - let notice = render_collection_notice(&described(), &host(), false); + let notice = render_collection_notice( + &described(), + &host(), + options(true, false).metadata_policy(), + ); assert!( notice.contains("Test CPU 9000"), @@ -347,10 +367,16 @@ fn suppressing_the_model_says_so_rather_than_going_quiet() { let mut machine = described(); machine.cpu_model = None; - let withheld = render_collection_notice(&machine, &host(), true); - let unknown = render_collection_notice(&machine, &host(), false); + let mut unknown = machine.clone(); + unknown.model_suppressed = false; + let mut withheld = machine; + withheld.model_suppressed = true; - assert!(withheld.contains("(withheld: --no-cpu-model)")); + let policy = options(true, false).metadata_policy(); + let withheld = render_collection_notice(&withheld, &host(), policy); + let unknown = render_collection_notice(&unknown, &host(), policy); + + assert!(withheld.contains("(withheld)")); assert!(unknown.contains("(this host would not say)")); assert_ne!( withheld, unknown, @@ -358,6 +384,42 @@ fn suppressing_the_model_says_so_rather_than_going_quiet() { ); } +#[test] +fn the_default_notice_says_the_secondary_rows_are_withheld() { + // **What M36.2 changed, seen from the disclosure.** A runner who passes + // nothing must be told the context is not being sent, and must not be shown + // values the record will not carry. + let machine = MachineDescription::read(MetadataPolicy::default()); + let notice = + render_collection_notice(&machine, &host(), options(false, false).metadata_policy()); + + assert!( + notice.contains("--include-metadata"), + "the default notice must name the opt-in: {notice}" + ); + assert!( + !notice.contains("Test CPU 9000"), + "a withheld row must not preview a value that will not be sent" + ); + assert!( + notice.matches("(withheld)").count() >= 3, + "every withheld row must say so: {notice}" + ); +} + +#[test] +fn the_default_notice_still_shows_the_topology_and_the_timings() { + // The measurement is not redactable, and the notice must keep saying so -- + // a runner who read "everything is withheld" and inferred that the topology + // was too would have consented to the wrong thing. + let host = host(); + let machine = MachineDescription::read(MetadataPolicy::default()); + let notice = render_collection_notice(&machine, &host, MetadataPolicy::default()); + + assert!(notice.contains(&host.to_string()), "got {notice}"); + assert!(notice.contains("timings"), "got {notice}"); +} + #[test] fn the_notice_shows_the_topology_value_not_a_description_of_it() { // A correction that is easy to undo. Every other row shows what was read; @@ -365,7 +427,8 @@ fn the_notice_shows_the_topology_value_not_a_description_of_it() { // that the topology identifies the hardware whether or not the model is // named. A runner asked to judge that could not see the thing being judged. let host = host(); - let notice = render_collection_notice(&described(), &host, false); + let notice = + render_collection_notice(&described(), &host, options(true, false).metadata_policy()); assert!( notice.contains(&host.to_string()), @@ -376,14 +439,66 @@ fn the_notice_shows_the_topology_value_not_a_description_of_it() { #[test] fn the_suppression_hint_is_offered_only_when_it_would_do_something() { // Advising --no-cpu-model to somebody who already passed it is noise that - // reads as though the flag did not take effect. + // reads as though the flag did not take effect -- and so is advising it to + // somebody who is sending no metadata at all, where it would do nothing. + let machine = |suppress| { + let mut machine = described(); + machine.model_suppressed = suppress; + if suppress { + machine.cpu_model = None; + } + machine + }; + assert!( - render_collection_notice(&described(), &host(), false) - .contains("--no-cpu-model to withhold") + render_collection_notice( + &machine(false), + &host(), + options(true, false).metadata_policy() + ) + .contains("--no-cpu-model to withhold") ); assert!( - !render_collection_notice(&described(), &host(), true) - .contains("--no-cpu-model to withhold") + !render_collection_notice( + &machine(true), + &host(), + options(true, true).metadata_policy() + ) + .contains("--no-cpu-model to withhold") + ); + assert!( + !render_collection_notice( + &machine(true), + &host(), + options(false, false).metadata_policy() + ) + .contains("--no-cpu-model to withhold"), + "a run sending no metadata has nothing for --no-cpu-model to withhold" + ); +} + +#[test] +fn the_model_switch_subtracts_from_the_opt_in_rather_than_cancelling_it() { + // The composition that makes `--no-cpu-model` worth keeping as a separate + // flag. A runner willing to send an OS build and a hypervisor name may + // still be in front of a part whose name is not theirs to publish, so the + // two switches must compose rather than one overriding the other. + let policy = options(true, true).metadata_policy(); + + assert!(!policy.includes_cpu_model()); + assert!(policy.includes_os_build()); + assert!(policy.includes_virtualisation()); + assert!(policy.includes_timestamp()); +} + +#[test] +fn withholding_the_model_without_opting_in_changes_nothing() { + // The redundant combination must be harmless: a cautious runner who passes + // both flags is owed the same record as one who passes neither, not a + // narrower one and not a different notice. + assert_eq!( + options(false, true).metadata_policy(), + options(false, false).metadata_policy() ); } @@ -391,8 +506,11 @@ fn the_suppression_hint_is_offered_only_when_it_would_do_something() { fn the_notice_keeps_promising_what_it_does_not_collect() { // The half of the disclosure a reader is most likely to be reassured by, // and the half most likely to be quietly dropped in an edit. - let notice = render_collection_notice(&described(), &host(), false); - + let notice = render_collection_notice( + &described(), + &host(), + options(true, false).metadata_policy(), + ); for promise in [ "host name", "user name", diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index 610e37a3..92589c6f 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -72,6 +72,8 @@ pub mod paste_json; pub mod peer_index_cache; /// The record a run produces and a runner sends back. pub mod record; +/// Which secondary metadata a submission carries. +pub mod redaction; /// The human-readable report, rendered from the record. pub mod report; /// Turning a run into something a person can paste into a discussion thread. diff --git a/crates/windows-placement-probe/src/machine.rs b/crates/windows-placement-probe/src/machine.rs index c9060b64..ccb3034b 100644 --- a/crates/windows-placement-probe/src/machine.rs +++ b/crates/windows-placement-probe/src/machine.rs @@ -20,12 +20,23 @@ //! commitment about this module, not a description of what it happens to do //! today. //! +//! # None of it is collected unless the runner says so +//! +//! Every field here is *context* rather than measurement, so +//! [`MetadataPolicy`] withholds all of it by default and +//! [`MachineDescription::read`] does not even ask the host for a field it will +//! not carry. The paragraph above therefore describes the shape of what an +//! opted-in submission contains, not what a default one does. +//! //! # Every field is optional, and absence is honest //! //! A host that will not answer produces a record missing a field rather than a //! failed run or a fabricated value. A registry key can be absent, a policy can //! deny a read, and a future Windows can rename something. None of those is a //! reason to stop measuring, and none is a reason to invent an answer. +//! +//! Withheld and unanswerable are kept apart wherever they can both occur, so a +//! collector never has to guess which one an empty field means. use std::fmt; @@ -34,6 +45,8 @@ use windows_sys::Win32::System::Registry::{ HKEY_LOCAL_MACHINE, RRF_RT_REG_DWORD, RRF_RT_REG_SZ, RegGetValueW, }; +use crate::redaction::MetadataPolicy; + /// Whether the machine looks virtualised. /// /// **A hint, and named one on purpose.** There is no user-mode call that @@ -61,6 +74,16 @@ pub enum VirtualisationHint { Detected, /// The question could not be asked -- the firmware strings were unreadable. Unknown, + /// The question was not asked, because the runner did not send this. + /// + /// **A variant rather than a flag beside the field**, unlike the optional + /// strings on [`MachineDescription`], and for a reason particular to this + /// type: every other variant here is a claim about what was observed, so a + /// withheld hint has no honest value to fall back to. `NotDetected` would + /// assert a negative finding nobody made and `Unknown` would blame the + /// firmware. Carrying the fact in the enum also keeps it stated once, + /// rather than in a variant and a boolean that could disagree. + Suppressed, } impl fmt::Display for VirtualisationHint { @@ -69,6 +92,7 @@ impl fmt::Display for VirtualisationHint { Self::NotDetected => "not detected", Self::Detected => "detected", Self::Unknown => "unknown", + Self::Suppressed => "withheld", }) } } @@ -77,18 +101,33 @@ impl fmt::Display for VirtualisationHint { #[derive(Clone, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct MachineDescription { - /// The processor's marketing name, or `None` when unreadable or suppressed. + /// The processor's marketing name, or `None` when unreadable or withheld. /// /// Suppression is recorded in [`Self::model_suppressed`] rather than left /// to be inferred from absence: a field withheld by the runner and a field /// the host would not answer are different facts, and a collector that /// cannot tell them apart will eventually read one as the other. pub cpu_model: Option, - /// Whether the runner asked for the model to be withheld. + /// Whether the model was withheld rather than unreadable. + /// + /// **True by default**, because the policy is + /// [redacted](crate::redaction::MetadataPolicy::redacted) unless the runner + /// opts in. It does not distinguish "withheld by the default" from + /// "withheld by `--no-cpu-model`", and deliberately: the record's job is to + /// say the value was not sent, not to explain which switch did it. pub model_suppressed: bool, - /// The OS build, as `10.0.22631.4460` or similar. + /// The OS build, as `10.0.22631.4460` or similar, or `None` when unreadable + /// or withheld. pub os_build: Option, - /// Whether the machine looks virtualised. + /// Whether the OS build was withheld rather than unreadable. + /// + /// Same distinction, same reason, as [`Self::model_suppressed`]. + pub os_build_suppressed: bool, + /// Whether the machine looks virtualised, or was not asked about. + /// + /// Withholding is carried by + /// [`VirtualisationHint::Suppressed`] rather than by a flag beside this + /// field -- see that variant for why. pub virtualisation: VirtualisationHint, /// The firmware's system manufacturer, when it names a known hypervisor. /// @@ -99,24 +138,29 @@ pub struct MachineDescription { } impl MachineDescription { - /// Read what this machine will say about itself. + /// Read what this machine will say about itself, as far as `policy` allows. /// - /// `suppress_model` withholds the CPU model at the runner's request. It - /// does not make confidential hardware safe to submit -- the topology + /// **A field the policy withholds is not read**, rather than read and then + /// dropped. The registry call never happens, so the commitment in this + /// module's documentation is kept by the control flow rather than by a + /// later discard that a refactor could lose. + /// + /// None of this makes confidential hardware safe to submit -- the topology /// identifies an unreleased part at least as well as its name does, and the - /// topology is the measurement -- so the switch reduces incidental leakage + /// topology is the measurement -- so redaction reduces incidental leakage /// and nothing more. #[must_use] - pub fn read(suppress_model: bool) -> Self { - let (virtualisation, virtualisation_name) = detect_virtualisation(); + pub fn read(policy: MetadataPolicy) -> Self { + let (virtualisation, virtualisation_name) = if policy.includes_virtualisation() { + detect_virtualisation() + } else { + (VirtualisationHint::Suppressed, None) + }; Self { - cpu_model: if suppress_model { - None - } else { - read_cpu_model() - }, - model_suppressed: suppress_model, - os_build: read_os_build(), + cpu_model: policy.includes_cpu_model().then(read_cpu_model).flatten(), + model_suppressed: !policy.includes_cpu_model(), + os_build: policy.includes_os_build().then(read_os_build).flatten(), + os_build_suppressed: !policy.includes_os_build(), virtualisation, virtualisation_name, } diff --git a/crates/windows-placement-probe/src/machine/tests.rs b/crates/windows-placement-probe/src/machine/tests.rs index 818e3c23..ea27997b 100644 --- a/crates/windows-placement-probe/src/machine/tests.rs +++ b/crates/windows-placement-probe/src/machine/tests.rs @@ -8,6 +8,7 @@ //! value, which would be an assertion about whatever host ran the suite. use super::{MachineDescription, VirtualisationHint, classify_virtualisation}; +use crate::redaction::MetadataPolicy; #[test] fn the_default_hint_is_not_a_claim_of_bare_metal() { @@ -31,7 +32,7 @@ fn reading_this_machine_answers_something() { // A smoke test with teeth: if the registry reads were wrong -- bad key // path, wrong value type, mishandled buffer -- every field would come back // empty at once, and that is what this catches. - let described = MachineDescription::read(false); + let described = MachineDescription::read(MetadataPolicy::included()); assert!( described.cpu_model.is_some() || described.os_build.is_some(), @@ -42,7 +43,7 @@ fn reading_this_machine_answers_something() { #[test] fn the_cpu_model_when_present_looks_like_a_processor_name() { - let described = MachineDescription::read(false); + let described = MachineDescription::read(MetadataPolicy::included()); if let Some(model) = &described.cpu_model { assert!(!model.is_empty(), "an empty model must be reported as None"); @@ -73,7 +74,7 @@ fn the_os_build_reports_the_real_major_version_and_not_the_legacy_string() { return; }; - let build = MachineDescription::read(false) + let build = MachineDescription::read(MetadataPolicy::included()) .os_build .expect("a host that reports a major version must yield a build"); @@ -87,7 +88,7 @@ fn the_os_build_reports_the_real_major_version_and_not_the_legacy_string() { fn the_os_build_when_present_is_dotted_numbers() { // Guards the assembly in `read_os_build`, which stitches several registry // values together and could silently produce something like "..". - let described = MachineDescription::read(false); + let described = MachineDescription::read(MetadataPolicy::included()); if let Some(build) = &described.os_build { let parts: Vec<&str> = build.split('.').collect(); @@ -113,7 +114,7 @@ fn the_os_build_when_present_is_dotted_numbers() { fn suppressing_the_model_withholds_it_and_records_that_it_was_withheld() { // The distinction that a bare `Option` would have lost. A collector must be // able to tell "the runner withheld this" from "the host would not say". - let suppressed = MachineDescription::read(true); + let suppressed = MachineDescription::read(MetadataPolicy::included().without_cpu_model()); assert!(suppressed.cpu_model.is_none()); assert!(suppressed.model_suppressed); @@ -121,24 +122,67 @@ fn suppressing_the_model_withholds_it_and_records_that_it_was_withheld() { #[test] fn not_suppressing_records_that_nothing_was_withheld() { - let described = MachineDescription::read(false); + let described = MachineDescription::read(MetadataPolicy::included()); assert!( !described.model_suppressed, "an unsuppressed read must not claim the model was withheld" ); + assert!( + !described.os_build_suppressed, + "an unsuppressed read must not claim the os build was withheld" + ); + assert_ne!( + described.virtualisation, + VirtualisationHint::Suppressed, + "an unsuppressed read must not claim the hint was withheld" + ); } #[test] -fn suppression_withholds_only_the_model() { - // Suppression is a privacy switch, not a mute button: the fields it does - // not cover must still be collected, or a suppressed submission would be - // far less useful than the runner intended. - let open = MachineDescription::read(false); - let suppressed = MachineDescription::read(true); +fn suppressing_the_model_withholds_only_the_model() { + // The subtraction is a scalpel, not a mute button: the fields it does not + // cover must still be collected, or an opted-in submission that withheld a + // name would be far less useful than the runner intended. + let open = MachineDescription::read(MetadataPolicy::included()); + let suppressed = MachineDescription::read(MetadataPolicy::included().without_cpu_model()); assert_eq!(open.os_build, suppressed.os_build); assert_eq!(open.virtualisation, suppressed.virtualisation); + assert!(!suppressed.os_build_suppressed); +} + +#[test] +fn the_default_policy_withholds_every_secondary_field() { + // **The behaviour M36.2 exists for.** A run that asks for nothing must send + // nothing but the measurement, and must say so in every field rather than + // leaving a reader to infer it from a blank. + let described = MachineDescription::read(MetadataPolicy::default()); + + assert_eq!(described.cpu_model, None); + assert!(described.model_suppressed); + assert_eq!(described.os_build, None); + assert!(described.os_build_suppressed); + assert_eq!(described.virtualisation, VirtualisationHint::Suppressed); + assert_eq!(described.virtualisation_name, None); +} + +#[test] +fn a_withheld_hint_is_not_reported_as_a_negative_finding() { + // The trap this variant exists to close. `NotDetected` is the enum's + // default, so a withheld hint that fell back to it would tell a reader this + // machine had been examined and found to be bare metal -- a claim nobody + // made, on the field that decides whether a submission could ever have + // shown NUMA rows. + let described = MachineDescription::read(MetadataPolicy::redacted()); + + assert_ne!(described.virtualisation, VirtualisationHint::NotDetected); + assert_ne!( + described.virtualisation, + VirtualisationHint::Unknown, + "a withheld hint must not blame the firmware either" + ); + assert_eq!(VirtualisationHint::Suppressed.to_string(), "withheld"); } #[test] @@ -146,7 +190,7 @@ fn a_detected_hypervisor_names_itself() { // A bare "detected" would ask the reader to trust the heuristic. Naming the // string that matched lets them judge it -- which matters, because the // markers include manufacturer names that also ship real hardware. - let described = MachineDescription::read(false); + let described = MachineDescription::read(MetadataPolicy::included()); match described.virtualisation { VirtualisationHint::Detected => assert!( diff --git a/crates/windows-placement-probe/src/paste_json/tests.rs b/crates/windows-placement-probe/src/paste_json/tests.rs index 371b75cb..a42a94ba 100644 --- a/crates/windows-placement-probe/src/paste_json/tests.rs +++ b/crates/windows-placement-probe/src/paste_json/tests.rs @@ -239,6 +239,7 @@ fn a_record_keeps_the_order_its_fields_are_declared_in() { "schema_version", "recorded_at", "recorded_at_epoch_seconds", + "recorded_at_suppressed", "build", "machine", "host", diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index afb2fc09..c6d4409f 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -26,6 +26,7 @@ use crate::build_identity::BuildIdentity; use crate::core_affinity::{Measurement, Observation}; use crate::fingerprint::Fingerprint; use crate::machine::MachineDescription; +use crate::redaction::MetadataPolicy; /// The version of the record's shape. /// @@ -78,7 +79,7 @@ pub struct SubmissionRecord { /// Which shape this record has. See [`SCHEMA_VERSION`]. pub schema_version: u32, /// When the run finished, as an ISO-8601 UTC timestamp **floored to the - /// minute**. + /// minute**, or `None` when the runner withheld it. /// /// The seconds field is therefore always `00`, and that is deliberate /// rather than an artefact. A submitted record describes someone's own @@ -89,14 +90,30 @@ pub struct SubmissionRecord { /// /// UTC with no local offset, because an offset narrows the submitter to a /// band of longitudes and buys nothing. - pub recorded_at: String, + /// + /// **Absent by default**: the timestamp is context rather than + /// measurement, so [`MetadataPolicy`] withholds it unless the runner opts + /// in. Flooring still applies to the value that survives the opt-in, and is + /// not a substitute for it. + pub recorded_at: Option, /// The same minute in seconds since the Unix epoch, and so always a - /// multiple of 60. + /// multiple of 60. Absent exactly when [`Self::recorded_at`] is. /// /// Carried beside the formatted form because a collector should never have /// to parse prose to sort records, and because it survives any later change /// to how the string is rendered. - pub recorded_at_epoch_seconds: u64, + pub recorded_at_epoch_seconds: Option, + /// Whether the timestamp was withheld. + /// + /// **Redundant by construction today, and carried anyway.** A clock cannot + /// decline to answer the way a registry key can, so today the two fields + /// above are `None` only when this is true. That is a fact about the + /// implementation, and a collector should not have to know it to read a + /// record -- the other withheld fields say so in the data, and this one + /// says it the same way. Every field of this struct is public, so a + /// hand-assembled record can also drop the timestamp without meaning to + /// claim anything, and this distinguishes that too. + pub recorded_at_suppressed: bool, /// Milliseconds past that second, for naming a file and nothing else. /// /// **Deliberately not serialized, and deliberately not a second clock @@ -284,6 +301,7 @@ impl SubmissionRecord { observation: &Observation, host: Fingerprint, machine: MachineDescription, + policy: MetadataPolicy, ) -> std::io::Result { if host != observation.host { return Err(std::io::Error::new( @@ -316,12 +334,20 @@ impl SubmissionRecord { // `recorded_at_subsecond_millis` is unaffected: it is `serde(skip)` and // exists only so two runs in one second get distinct *file names*. It // never reaches the record. - let recorded_minute = now - (now % SECONDS_PER_MINUTE); + // + // **And the whole timestamp is withheld unless `policy` says otherwise**, + // because a minute is still a correlator and the timestamp is context + // rather than measurement. The flooring above is what the opted-in value + // gets, not a stand-in for the choice not to send one. + let recorded_minute = policy + .includes_timestamp() + .then(|| now - (now % SECONDS_PER_MINUTE)); Ok(Self { schema_version: SCHEMA_VERSION, - recorded_at: iso8601_utc(recorded_minute), + recorded_at: recorded_minute.map(iso8601_utc), recorded_at_epoch_seconds: recorded_minute, + recorded_at_suppressed: !policy.includes_timestamp(), recorded_at_subsecond_millis: since_epoch.subsec_millis(), build: BuildIdentity::current(), machine, diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index 36eb9641..7204480e 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -12,6 +12,7 @@ use super::{MeasurementRecord, SCHEMA_VERSION, SubmissionRecord, civil_from_days use crate::build_identity::{BuildIdentity, BuildSource}; use crate::fingerprint::Fingerprint; use crate::machine::{MachineDescription, VirtualisationHint}; +use crate::redaction::MetadataPolicy; /// A record with **every** optional field populated. /// @@ -51,8 +52,9 @@ pub(crate) fn fully_populated() -> SubmissionRecord { SubmissionRecord { schema_version: SCHEMA_VERSION, - recorded_at: "2026-08-31T12:00:00Z".to_owned(), - recorded_at_epoch_seconds: 1_788_177_600, + recorded_at: Some("2026-08-31T12:00:00Z".to_owned()), + recorded_at_epoch_seconds: Some(1_788_177_600), + recorded_at_suppressed: false, recorded_at_subsecond_millis: 250, build: BuildIdentity { crate_version: "2026.902.0", @@ -64,6 +66,7 @@ pub(crate) fn fully_populated() -> SubmissionRecord { cpu_model: Some("Example CPU".to_owned()), model_suppressed: false, os_build: Some("10.0.26200.9168".to_owned()), + os_build_suppressed: false, virtualisation: VirtualisationHint::Detected, virtualisation_name: Some("Example Hypervisor".to_owned()), }, @@ -325,7 +328,8 @@ fn a_record_cannot_splice_an_announced_host_onto_another_machines_rows() { let error = SubmissionRecord::new( &observation_on(measured), announced, - MachineDescription::read(true), + MachineDescription::read(MetadataPolicy::redacted()), + MetadataPolicy::redacted(), ) .expect_err("a record spanning two machines must not be assembled"); @@ -345,13 +349,73 @@ fn a_record_assembles_when_the_announced_and_measured_hosts_agree() { let record = SubmissionRecord::new( &observation_on(host.clone()), host.clone(), - MachineDescription::read(true), + MachineDescription::read(MetadataPolicy::redacted()), + MetadataPolicy::redacted(), ) .expect("identical hosts are the ordinary case"); assert_eq!(record.host, host); } +#[test] +fn the_default_policy_leaves_a_record_with_no_timestamp() { + // **The behaviour M36.2 exists for**, at the record's own boundary: even a + // minute is a correlator between two submissions from one host, so it is + // sent only when the runner asks for it to be. + let host = measured_host(); + + let record = SubmissionRecord::new( + &observation_on(host.clone()), + host, + MachineDescription::read(MetadataPolicy::default()), + MetadataPolicy::default(), + ) + .expect("identical hosts are the ordinary case"); + + assert_eq!(record.recorded_at, None); + assert_eq!(record.recorded_at_epoch_seconds, None); + assert!( + record.recorded_at_suppressed, + "an absent timestamp must say it was withheld rather than leave a reader guessing" + ); +} + +#[test] +fn opting_in_carries_a_timestamp_floored_to_the_minute() { + // The other half, so the withholding above is known to be a policy rather + // than a constructor that stopped reading the clock -- and the flooring + // from M36.1 is still in force on the value that survives the opt-in. + let host = measured_host(); + + let record = SubmissionRecord::new( + &observation_on(host.clone()), + host, + MachineDescription::read(MetadataPolicy::included()), + MetadataPolicy::included(), + ) + .expect("identical hosts are the ordinary case"); + + let seconds = record + .recorded_at_epoch_seconds + .expect("an opted-in record carries the epoch value"); + let rendered = record + .recorded_at + .as_deref() + .expect("an opted-in record carries the rendered form"); + + assert!(!record.recorded_at_suppressed); + assert_eq!(seconds % 60, 0, "the minute floor still applies: {seconds}"); + assert!( + rendered.ends_with(":00Z"), + "the two forms must agree about the seconds field: {rendered}" + ); + assert_eq!( + rendered, + iso8601_utc(seconds), + "the rendered form must be the epoch value, not a second clock reading" + ); +} + #[test] fn flooring_to_the_minute_is_what_zeroes_the_seconds_field() { // A submitted record describes someone's own machine, and a diff --git a/crates/windows-placement-probe/src/redaction.rs b/crates/windows-placement-probe/src/redaction.rs new file mode 100644 index 00000000..66d05980 --- /dev/null +++ b/crates/windows-placement-probe/src/redaction.rs @@ -0,0 +1,134 @@ +// Copyright (c) 2026 Mike Grier +//! Which secondary metadata a submission carries. +//! +//! # The measurement is not negotiable; the context is +//! +//! A record holds two different kinds of thing. The **measurement** -- the +//! topology, the placements, the timings -- is the reason the record exists, +//! and redacting it would leave a file that says nothing. The **context** -- +//! when the run happened, what CPU it was, which OS build, whether a hypervisor +//! was detected -- explains a measurement without being one. +//! +//! Only the context is redactable, and it is redacted by default. A submission +//! is a favour done by a stranger, and the default should ask for the least +//! that still answers the question. +//! +//! # Why the default flipped +//! +//! The tool began by collecting all of it and offering `--no-cpu-model` as the +//! single escape hatch. That is the wrong way round: it asks a runner to +//! recognise, in advance, which field they would rather not send. Defaulting to +//! redacted asks nothing, and a runner who wants to help more can say so. +//! +//! What that costs is real and is stated rather than glossed: a defect that +//! appears only on one OS build, or only under one hypervisor, is exactly what +//! the context is for, and a corpus without it cannot show that correlation. +//! See this crate's `README.md`. +//! +//! # Suppression is recorded, never merely absent +//! +//! Every field this policy can withhold carries a way to say it was withheld, +//! because "the runner did not send this" and "the host would not answer" are +//! different facts and a collector that cannot tell them apart will eventually +//! read one as the other. The mechanism differs by field only because the types +//! differ: a `None` beside a `*_suppressed` flag for the optional strings, and a +//! dedicated [`Suppressed`](crate::machine::VirtualisationHint::Suppressed) +//! variant for the virtualisation hint, whose other variants are all claims +//! about what was observed. + +/// Which secondary metadata a record includes. +/// +/// Constructed by the tool from what the runner asked for, then carried into +/// [`MachineDescription::read`](crate::machine::MachineDescription::read) and +/// [`SubmissionRecord::new`](crate::record::SubmissionRecord::new) so that one +/// decision governs every field rather than each site deciding again. +/// +/// [`Default`] is [`redacted`](Self::redacted), so a caller that does not think +/// about this collects the least. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MetadataPolicy { + include_timestamp: bool, + include_cpu_model: bool, + include_os_build: bool, + include_virtualisation: bool, +} + +impl MetadataPolicy { + /// Withhold every piece of secondary metadata. The default. + #[must_use] + pub const fn redacted() -> Self { + Self { + include_timestamp: false, + include_cpu_model: false, + include_os_build: false, + include_virtualisation: false, + } + } + + /// Include every piece of secondary metadata, at the runner's request. + #[must_use] + pub const fn included() -> Self { + Self { + include_timestamp: true, + include_cpu_model: true, + include_os_build: true, + include_virtualisation: true, + } + } + + /// The same policy with the CPU model withheld. + /// + /// The one subtraction the tool offers, and it is offered because the case + /// is real: an unreleased part has a name that must not travel while its OS + /// build and hypervisor are as ordinary as anyone else's. It does not make + /// confidential hardware safe to submit -- the topology describes the part + /// whether or not it is named, and the topology is the measurement. + #[must_use] + pub const fn without_cpu_model(self) -> Self { + Self { + include_cpu_model: false, + ..self + } + } + + /// Whether the record carries when the run happened. + #[must_use] + pub const fn includes_timestamp(self) -> bool { + self.include_timestamp + } + + /// Whether the record carries the processor's marketing name. + #[must_use] + pub const fn includes_cpu_model(self) -> bool { + self.include_cpu_model + } + + /// Whether the record carries the OS build. + #[must_use] + pub const fn includes_os_build(self) -> bool { + self.include_os_build + } + + /// Whether the record carries the virtualisation hint and its name. + #[must_use] + pub const fn includes_virtualisation(self) -> bool { + self.include_virtualisation + } + + /// Whether anything at all is included. + /// + /// Used by the collection notice to decide whether to describe an opt-in or + /// a subtraction: advising `--include-metadata` to a runner who has already + /// passed it is noise, and so is advising `--no-cpu-model` to one who is + /// sending no metadata at all. + #[must_use] + pub const fn includes_anything(self) -> bool { + self.include_timestamp + || self.include_cpu_model + || self.include_os_build + || self.include_virtualisation + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/windows-placement-probe/src/redaction/tests.rs b/crates/windows-placement-probe/src/redaction/tests.rs new file mode 100644 index 00000000..39cd9397 --- /dev/null +++ b/crates/windows-placement-probe/src/redaction/tests.rs @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`MetadataPolicy`](super::MetadataPolicy). + +use super::MetadataPolicy; + +#[test] +fn the_default_withholds_everything() { + // The point of the whole design: a caller who does not think about this + // collects the least, rather than the most. + let policy = MetadataPolicy::default(); + + assert!(!policy.includes_timestamp()); + assert!(!policy.includes_cpu_model()); + assert!(!policy.includes_os_build()); + assert!(!policy.includes_virtualisation()); + assert!(!policy.includes_anything()); +} + +#[test] +fn the_default_is_the_redacted_policy() { + // Two spellings of one policy, and a test rather than a comment, because a + // `Default` that drifted from `redacted` would silently change what the + // tool collects without any call site changing. + assert_eq!(MetadataPolicy::default(), MetadataPolicy::redacted()); +} + +#[test] +fn including_covers_every_field() { + // Every field this crate can withhold must be reachable by the one opt-in, + // or a runner who asked to help would be silently sending less than they + // agreed to. + let policy = MetadataPolicy::included(); + + assert!(policy.includes_timestamp()); + assert!(policy.includes_cpu_model()); + assert!(policy.includes_os_build()); + assert!(policy.includes_virtualisation()); + assert!(policy.includes_anything()); +} + +#[test] +fn withholding_the_model_subtracts_only_the_model() { + // The subtraction is a scalpel, not a switch back to the default: a runner + // who opted in and then withheld the name is still sending the rest. + let policy = MetadataPolicy::included().without_cpu_model(); + + assert!(!policy.includes_cpu_model()); + assert!(policy.includes_timestamp()); + assert!(policy.includes_os_build()); + assert!(policy.includes_virtualisation()); + assert!(policy.includes_anything()); +} + +#[test] +fn withholding_the_model_from_a_redacted_policy_changes_nothing() { + // `--no-cpu-model` without `--include-metadata` is redundant rather than + // wrong, and must stay harmless: a cautious runner passing both should not + // get a different record from one passing neither. + assert_eq!( + MetadataPolicy::redacted().without_cpu_model(), + MetadataPolicy::redacted() + ); +} + +#[test] +fn a_policy_with_one_field_still_counts_as_including_something() { + // `includes_anything` decides which advice the notice prints, so it must + // not be a synonym for `included`. + let policy = MetadataPolicy::included().without_cpu_model(); + + assert_ne!(policy, MetadataPolicy::included()); + assert!(policy.includes_anything()); +} diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index 8577c994..edfccfa5 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -17,6 +17,7 @@ use std::fmt::Write as _; +use crate::machine::VirtualisationHint; use crate::record::SubmissionRecord; /// Render the report a runner sees. @@ -40,7 +41,20 @@ fn render_header(out: &mut String, record: &SubmissionRecord) { let _ = writeln!(out); let _ = writeln!(out, "host: {}", record.host); let _ = writeln!(out, "build: {}", record.build); - let _ = writeln!(out, "recorded: {}", record.recorded_at); + let _ = writeln!( + out, + "recorded: {}", + match (&record.recorded_at, record.recorded_at_suppressed) { + (Some(stamp), _) => stamp.as_str(), + (None, true) => "(withheld)", + // Unreachable from `SubmissionRecord::new`, which only drops the + // timestamp by withholding it. Rendered rather than unwrapped + // because every field of the record is public, and a report that + // panicked on a hand-assembled record would be worse than one that + // says what it found. + (None, false) => "(unknown)", + } + ); let _ = writeln!(out, "schema: {}", record.schema_version); } @@ -62,17 +76,24 @@ fn render_machine(out: &mut String, record: &SubmissionRecord) { let _ = writeln!( out, "os build: {}", - machine.os_build.as_deref().unwrap_or("(unknown)") - ); - let _ = write!(out, "virtualisation: {}", machine.virtualisation); - match &machine.virtualisation_name { - Some(name) => { - let _ = writeln!(out, " ({name})"); + match (&machine.os_build, machine.os_build_suppressed) { + (Some(build), _) => build.as_str(), + (None, true) => "(withheld by the runner)", + (None, false) => "(this host would not say)", } - None => { - let _ = writeln!(out); + ); + // Parenthesised when withheld, so this column reads the same way as the two + // rows above it. The hint's own `Display` stays a plain word, because it is + // the rendering of a value rather than of this table's cell. + let _ = writeln!( + out, + "virtualisation: {}", + match (machine.virtualisation, &machine.virtualisation_name) { + (VirtualisationHint::Suppressed, _) => "(withheld by the runner)".to_owned(), + (hint, Some(name)) => format!("{hint} ({name})"), + (hint, None) => hint.to_string(), } - } + ); } fn render_placements(out: &mut String, record: &SubmissionRecord) { diff --git a/crates/windows-placement-probe/src/report/tests.rs b/crates/windows-placement-probe/src/report/tests.rs index aa2d312e..e6d4eb93 100644 --- a/crates/windows-placement-probe/src/report/tests.rs +++ b/crates/windows-placement-probe/src/report/tests.rs @@ -20,7 +20,15 @@ fn the_report_shows_the_values_the_record_carries() { let record = fully_populated(); let text = render(&record); - assert!(text.contains(&record.recorded_at), "timestamp missing"); + assert!( + text.contains( + record + .recorded_at + .as_deref() + .expect("the fixture carries a timestamp") + ), + "timestamp missing" + ); assert!( text.contains(&record.schema_version.to_string()), "schema version missing" @@ -66,6 +74,46 @@ fn a_withheld_model_reads_differently_from_an_unreadable_one() { assert!(!unreadable.contains("withheld"), "got {unreadable}"); } +#[test] +fn a_withheld_os_build_reads_differently_from_an_unreadable_one() { + // The same distinction, on the field M36.2 made redactable. A report that + // flattened the two would undo in the text what the record keeps apart. + let mut withheld = fully_populated(); + withheld.machine.os_build = None; + withheld.machine.os_build_suppressed = true; + + let mut unreadable = fully_populated(); + unreadable.machine.os_build = None; + unreadable.machine.os_build_suppressed = false; + + let withheld = render(&withheld); + let unreadable = render(&unreadable); + + assert!(withheld.contains("os build: (withheld"), "{withheld}"); + assert!( + unreadable.contains("os build: (this host would not say)"), + "{unreadable}" + ); +} + +#[test] +fn a_withheld_timestamp_says_so_rather_than_showing_a_blank() { + // The default record carries no timestamp, so this is what most reports + // will show. A bare blank would read as a rendering fault. + let mut redacted = fully_populated(); + redacted.recorded_at = None; + redacted.recorded_at_epoch_seconds = None; + redacted.recorded_at_suppressed = true; + + let text = render(&redacted); + + assert!(text.contains("recorded: (withheld)"), "got {text}"); + assert!( + !text.contains("2026-08-31"), + "the withheld minute must not survive anywhere in the report: {text}" + ); +} + #[test] fn a_single_node_machine_says_why_there_are_no_hops() { // Every host measured so far is single-node, so this is the common case and diff --git a/crates/windows-placement-probe/src/submission.rs b/crates/windows-placement-probe/src/submission.rs index ee29defd..4fb4f109 100644 --- a/crates/windows-placement-probe/src/submission.rs +++ b/crates/windows-placement-probe/src/submission.rs @@ -95,15 +95,16 @@ pub fn render_submission(record: &SubmissionRecord) -> Result Result String { - let stamp: String = record - .recorded_at - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) - .collect(); + // The trailing separator belongs to the stamp rather than to the format + // string, so an absent stamp leaves no doubled hyphen behind. + let stamp: String = match &record.recorded_at { + Some(at) => at + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .chain(std::iter::once('-')) + .collect(), + None => String::new(), + }; format!( - "placement-probe-v{}-{}-{:03}.json", + "placement-probe-v{}-{}{:03}.json", record.schema_version, stamp, record.recorded_at_subsecond_millis ) } diff --git a/crates/windows-placement-probe/src/submission/tests.rs b/crates/windows-placement-probe/src/submission/tests.rs index b706592d..b916fea6 100644 --- a/crates/windows-placement-probe/src/submission/tests.rs +++ b/crates/windows-placement-probe/src/submission/tests.rs @@ -208,7 +208,48 @@ fn two_runs_do_not_collide_on_one_file_name() { // Overwriting a previous result silently is a data loss nobody notices. let first = fully_populated(); let mut second = fully_populated(); - second.recorded_at = "2026-09-01T13:00:00Z".to_owned(); + second.recorded_at = Some("2026-09-01T13:00:00Z".to_owned()); + + assert_ne!(file_name(&first), file_name(&second)); +} + +#[test] +fn a_record_with_no_timestamp_names_a_file_without_one() { + // The name is derived from the record, so a withheld timestamp must not + // reappear in a file name the runner may well attach. This is the one place + // the redacted minute could still escape. + let mut redacted = fully_populated(); + redacted.recorded_at = None; + redacted.recorded_at_epoch_seconds = None; + redacted.recorded_at_suppressed = true; + + let name = file_name(&redacted); + + assert!( + !name.contains("2026"), + "the withheld minute leaked into the file name: {name}" + ); + assert!( + !name.contains("--"), + "an absent stamp must not leave a doubled separator: {name}" + ); + assert!( + name.ends_with("-250.json"), + "the milliseconds that avoid a collision must survive: {name}" + ); +} + +#[test] +fn two_records_with_no_timestamp_still_get_different_names() { + // The collision the milliseconds exist to avoid, in the case that lost the + // rest of the stamp. The exclusive create and its numbered suffix are what + // make the guarantee, but they must not be reached on every ordinary run. + let mut first = fully_populated(); + first.recorded_at = None; + first.recorded_at_suppressed = true; + let mut second = first.clone(); + first.recorded_at_subsecond_millis = 120; + second.recorded_at_subsecond_millis = 890; assert_ne!(file_name(&first), file_name(&second)); } From 9a7393a8097d6db727b9c9f6783aa19983b7cc0f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 16:28:34 -0400 Subject: [PATCH 329/361] docs(placement-probe): say in the README what redaction costs Redaction is now the default, and presenting it as free would be a way of deciding for the runner. The new "What redaction costs" section states the trade. It rests on the asymmetry PT-1.2 already established: withheld context cannot be recovered later -- the machine belongs to someone else and the question that needed it gets asked months afterwards -- while over-collection can at least be corrected going forward by collecting less. Then what each of the four fields buys, ordered by explanatory value for this dataset rather than by sensitivity: the virtualisation hint decides whether a submission can answer the question at all, since a VM slice flattens the topology that the missing NUMA rows depend on; the OS build explains a disagreement that is otherwise indistinguishable from two schedulers disagreeing; the model makes a result citable; and the minute is named as the weakest of the four, because saying so is more useful than pretending otherwise. Two guards against reading the new default as more than it is. Redaction does not make a submitter anonymous -- the topology is always sent and is the most identifying thing in the record. And a redacted submission is still a good submission, because the gap between sending one and sending nothing dwarfs everything else on the page. Completed item: M36.3: Say in the README what redaction costs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 9 ++++- crates/windows-placement-probe/README.md | 47 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index e075343c..261b27b0 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -685,7 +685,14 @@ actionable is identifying and therefore belongs behind the review this tool alre redactable field needs the same treatment. **No `SCHEMA_VERSION` bump**: the freeze starts at the first release and this crate has not had one. -- [ ] **M36.3** -- **Say in the README what redaction costs.** There is real value in correlating +- [x] **M36.3** -- **Say in the README what redaction costs.** Done 2026-09-04, as a + "What redaction costs" section. Built on the asymmetry `PT-1.2` already established -- withheld + context cannot be recovered later, while over-collection can be corrected going forward -- then + what each of the four fields buys, ordered by explanatory value rather than by sensitivity, with + the minute named as the weakest of them. Two guards against mis-reading the new default: redaction + does not make a submitter anonymous, because the topology is always sent and is the most + identifying thing in the record; and a redacted submission is still a good submission, because + sending nothing is by far the worse outcome. There is real value in correlating metadata anomalies with specific platform versions -- a defect that shows up only on one OS build, or only under one hypervisor, is exactly what the secondary metadata is for. A reader choosing to include it should understand they are helping, and a reader choosing not to should understand what diff --git a/crates/windows-placement-probe/README.md b/crates/windows-placement-probe/README.md index 705013e1..0fd79a43 100644 --- a/crates/windows-placement-probe/README.md +++ b/crates/windows-placement-probe/README.md @@ -77,6 +77,53 @@ A withheld field is recorded as withheld rather than merely left blank, so somebody reading a submission can always tell "the runner did not send this" from "this host would not say". +### What redaction costs + +Redaction is the default because most results do not need the context. It is not +free, though, and presenting it as free would be a way of deciding for you. So +here is what you are withholding, and either choice is a reasonable one to make +once you have read it. + +**Withheld context cannot be recovered later.** Everything below follows from +that asymmetry. A field nobody sent is gone: the machine belongs to someone else, +the run is over, and the question that needed it usually gets asked months +afterwards. Collecting too much is a privacy cost that can at least be corrected +going forward by collecting less; collecting too little cannot be corrected at +all. + +What each field buys, in the order that actually matters for this dataset: + +- **The virtualisation hint decides whether a submission can answer the question + at all.** A VM slice *flattens topology* -- the machine this tool was developed + on reports one L3 domain and one NUMA node for silicon that has eight and two. + The rows this project is missing, the cost of crossing between NUMA nodes, are + missing for exactly that reason. Without the hint, a flat topology from a small + bare machine and a flat slice of a large virtual one arrive as the same + submission. +- **The OS build explains a disagreement.** Placement cost is a scheduler + behaviour, and the scheduler changes between Windows builds. Two results that + disagree are otherwise indistinguishable from two builds disagreeing -- and + once results are pooled without it, nothing recovers the distinction. +- **The CPU model makes a result citable.** A detailed topology narrows the part + to a small class; the name identifies one. It is also what lets two submissions + from the same part be pooled with confidence rather than by inference. +- **The minute is the weakest of the four**, and saying so is more useful than + pretending otherwise. Alongside an OS build it adds little; without one it is + the only thing that places a result relative to a known change. Its cost is + precisely its benefit read backwards: a timestamp is what would let two + submissions from one host be recognised as one host's, which is why it is + withheld at all despite being the least explanatory of the four. + +**Redaction does not make you anonymous, and it is not offered as though it +does.** The topology is always sent, it is the most identifying thing in the +record, and an unusual machine is recognisable from it alone. What redaction does +is keep context out of a record that was never about the context. + +**A redacted submission is still a good submission.** Every withheld field says +it was withheld, so a reader can weigh the result rather than guess at it. If the +choice is between sending a redacted result and sending nothing, send it -- that +difference is far larger than everything on this page. + ### If the hardware is confidential, do not send the result Redaction reduces incidental leakage and nothing more. An unreleased part From dad5ce4f2f5fb80117e1664bc24cb76ffb4b6453 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 16:58:16 -0400 Subject: [PATCH 330/361] fix(topology): spell Coherence's serialized variants in snake_case Every serialized name this crate emits is lowercase. Provenance, Source and ProcessorAttribute carry rename_all = "snake_case", and Domain's hand-written serializer writes "group", "package", "cache". Coherence was derived without the attribute and so wrote "NotCollected", "Agreed" and {"Disagreed": ...}. Found while adding the coherence to windows-placement-probe's submission record, whose schema freezes at that crate's first release -- so this is close to the last moment the spelling can be corrected without archiving it. D-8 puts this crate's JSON shape outside the semver contract, which is what makes the correction available; the odd spelling was an oversight when D-16 added the type, not a decision. Two tests, and they guard different things. The first pins the exact strings and fails without the attribute, verified by removing it. The second round-trips every variant, which passes either way today and exists for the asymmetric case: an attribute on Serialize alone would write a name Deserialize then refused to read, and no test of serialization alone would notice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/src/topology.rs | 10 ++++ .../src/topology/tests.rs | 56 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index 943ce13d..e9a9bce4 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -40,8 +40,18 @@ const COHERENCE_ATTEMPTS: u32 = 3; /// retried: [D-17](../DESIGN-NOTES.md#d-17) establishes those are expected in /// the field and persistent, so re-reading cannot settle them and they are /// carried as separate per-source observations instead. +/// +/// # Serialized spelling +/// +/// `snake_case`, like [`Provenance`](crate::Provenance), +/// [`Source`](crate::Source) and [`ProcessorAttribute`](crate::ProcessorAttribute), +/// and like the lowercase names `Domain`'s hand-written serializer emits for +/// [`DomainKind`](crate::DomainKind). Every serialized name this crate produces +/// is lowercase; this one was derived without the attribute and so spelled its +/// variants `NotCollected` and `Disagreed` until that was corrected. #[derive(Clone, Debug, Default, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub enum Coherence { /// Not collected from a running system, so the question does not arise. /// diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index bb793d6b..f713afb6 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -432,6 +432,62 @@ mod serde_tests { ); } + #[test] + #[cfg(feature = "serde")] + fn coherence_serializes_in_the_lowercase_spelling_the_rest_of_this_crate_uses() { + // Every serialized name this crate emits is lowercase -- `Provenance`, + // `Source` and `ProcessorAttribute` carry `rename_all`, and `Domain`'s + // hand-written serializer writes "group", "package", "cache". This enum + // was derived without the attribute and spelled its variants + // `NotCollected` and `Disagreed`, which is not a cosmetic difference: + // a consumer archiving a document containing one freezes that spelling. + let agreed = serde_json::to_string(&Coherence::Agreed).expect("serialize"); + let not_collected = serde_json::to_string(&Coherence::NotCollected).expect("serialize"); + let disagreed = serde_json::to_string(&Coherence::Disagreed { + walk_only: vec![ProcessorId { + group: 0, + number: 1, + }], + cpu_sets_only: Vec::new(), + attempts: 3, + }) + .expect("serialize"); + + assert_eq!(agreed, "\"agreed\""); + assert_eq!(not_collected, "\"not_collected\""); + assert!( + disagreed.starts_with("{\"disagreed\":"), + "the data-carrying variant must be spelled the same way: {disagreed}" + ); + } + + #[test] + #[cfg(feature = "serde")] + fn every_coherence_variant_round_trips_through_its_serialized_form() { + // The rename is only safe because it is applied to both halves. An + // attribute on `Serialize` alone would write a name `Deserialize` then + // refused to read, which no test of serialization alone would notice. + for coherence in [ + Coherence::NotCollected, + Coherence::Agreed, + Coherence::Disagreed { + walk_only: vec![ProcessorId { + group: 1, + number: 7, + }], + cpu_sets_only: vec![ProcessorId { + group: 0, + number: 2, + }], + attempts: 3, + }, + ] { + let text = serde_json::to_string(&coherence).expect("serialize"); + let back: Coherence = serde_json::from_str(&text).expect("deserialize"); + assert_eq!(back, coherence, "round trip changed {text}"); + } + } + /// A CPU-set record for one processor in group 0. fn cpu_set(index: u8, core: u8, node: u8, efficiency_class: u8) -> crate::cpu_set::CpuSet { crate::cpu_set::CpuSet { From c8fc72174c7934d0d9ea52352c3d367c67e78c31 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 17:05:30 -0400 Subject: [PATCH 331/361] feat(placement-probe): report a topology disagreement and offer a way to help When the two Win32 topology sources never agree within the retry bound, Coherence::Disagreed is the conclusion that the difference is real. Nothing in the workspace looked at it. The record now carries the whole Coherence, walk_only and cpu_sets_only lists included, rather than a boolean summary: the report attached to it asks a runner whether they would help investigate, and a record saying only "something disagreed" would make that ask hollow. It is a field of the record and deliberately not of the Fingerprint, which is compared for equality to catch a record spliced from two machines -- an announced reading that agreed and a measured one that did not would trip that check and throw away a good measurement over a difference in no shape at all. The M36.4 checklist item claimed Coherence was already reachable from the record. It was not, and the item now records the correction: the fingerprint carries only the provenance. The wording is informative rather than coercive. It reports what was detected, says plainly that the measurements are unaffected, names BOTH possible causes -- inconsistent platform metadata or a defect in this tool -- as undecidable from the runner's machine, offers a way to make contact, and closes with "None of that is required. The result you already have is a valid submission." A test asserts the release is present, that both causes are offered as undecided, and that no pressure word appears. Agreed and NotCollected print nothing at all: a line saying the two sources agreed would appear on every run that happens, and a reader skips whatever is always there. "Privately" is an offer to arrange, not a channel that exists. Discussions and issues are public, so the text asks for contact there and says the maintainers can arrange a way to share the file that does not post it publicly. Three of the nine sabotages now in sabotage.json cover this section, and all nine turn the suite red. Completed item: M36.4: On Coherence::Disagreed, ask for the unredacted record privately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-placement-tool.md | 33 ++- .../windows-placement-probe/DESIGN-NOTES.md | 96 +++++++++ crates/windows-placement-probe/sabotage.json | 42 ++++ crates/windows-placement-probe/schema/v1.txt | 11 + .../src/bin/placement_probe/main.rs | 12 +- .../src/paste_json/tests.rs | 1 + crates/windows-placement-probe/src/record.rs | 24 ++- .../src/record/tests.rs | 24 ++- crates/windows-placement-probe/src/report.rs | 176 ++++++++++++++++ .../src/report/tests.rs | 188 +++++++++++++++++- .../src/submission/tests.rs | 14 ++ 11 files changed, 615 insertions(+), 6 deletions(-) diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 261b27b0..7032980a 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -630,6 +630,21 @@ actionable is identifying and therefore belongs behind the review this tool alre - [ ] **PT-7.1** -- **Surface the topology's recorded inconsistencies**, in the tool's output and in the submission record. This is the only place they become visible to anyone: the topology crate keeps what each source said, and nothing in the workspace currently looks at it. + **Partly done by `M36.4` on 2026-09-04, and what remains is narrower than the text below.** The + **record** half is complete: `topology_coherence` carries the whole `Coherence`, so a submission + names the disagreeing processors individually. The **output** half is partly done: the report has + a section that appears only on `Disagreed`, gives the counts on each side and the retry number, + and says the measurements are unaffected. Two things are still open, and both are decisions rather + than plumbing: + (a) **Name the processors in the printed text**, not only in the record -- "what each source + claimed", which is what this item asks for and what counts alone do not give. + (b) **Decide whether an inconsistent run is marked in "how far to trust this".** + `is_fully_trusted` is deliberately untouched, so that section currently reads "an official build, + reading this machine's real topology" directly above the disagreement section. That is not a + contradiction -- the build *is* official and the topology *was* read -- but a reader may feel one. + This item's own two-sided framing below is the guidance for settling it: mark it plainly enough + that neither the runner nor a later reader is left guessing, without dressing up a nuisance as a + prize. Report what disagreed and what each source claimed, not merely that something did -- "incoherent" is not actionable, and the point of collecting from strangers' machines is to learn something specific about hardware nobody here can buy. @@ -698,7 +713,23 @@ actionable is identifying and therefore belongs behind the review this tool alre include it should understand they are helping, and a reader choosing not to should understand what they are withholding. State the trade rather than presenting redaction as free. -- [ ] **M36.4** -- **On `Coherence::Disagreed`, ask for the unredacted record privately.** The report +- [x] **M36.4** -- **On `Coherence::Disagreed`, ask for the unredacted record privately.** Done + 2026-09-04. **The dependency below was mis-stated and is corrected here**: `Coherence` was *not* + reachable from the record. `topology_provenance` is carried and `Fingerprint` is built from the + topology, but the fingerprint carries only the provenance, so the record had no way to know its + two sources had disagreed. The record gained `topology_coherence`, carrying the whole `Coherence` + including the processor lists -- a boolean would have made the ask hollow, since the record a + maintainer is offered has to contain what they would look at. It is a field of the *record* and + deliberately not of the `Fingerprint`, which is compared for equality to catch a spliced record + and would then discard a good measurement over a difference in no shape at all. + The wording is informative rather than coercive, per the engineer's direction: it reports what was + detected, says the measurements are unaffected, names both possible causes -- inconsistent + platform metadata *or* a defect in this tool -- as undecidable from the runner's machine, offers a + way to help, and closes with "None of that is required." A test asserts the release is present and + that no pressure word appears. See + [DESIGN-NOTES.md](crates/windows-placement-probe/DESIGN-NOTES.md) -> "A disagreement is reported + where it happens, and the ask attached to it is an offer". + The report emits extra text when the topology's two sources disagreed past the retry: say that the metadata was inconsistent, and ask the runner to contact the `windows-threadpool-sys` maintainers through the discussions or issues boards and share an **unredacted** probe file **privately**, so the diff --git a/crates/windows-placement-probe/DESIGN-NOTES.md b/crates/windows-placement-probe/DESIGN-NOTES.md index d8c73003..0d2744b3 100644 --- a/crates/windows-placement-probe/DESIGN-NOTES.md +++ b/crates/windows-placement-probe/DESIGN-NOTES.md @@ -302,3 +302,99 @@ Engineer's decision, 2026-09-04. Queued as `M36.2` in in the README what redaction costs, and `M36.4` asks for an unredacted record privately when the topology's sources disagreed -- the one case where the context matters most. + +## A disagreement is reported where it happens, and the ask attached to it is an offer + +`MachineMemoryTopology::discover` reads two independent Win32 sources and +compares which processors they name. When they never agree within the retry +bound, `Coherence::Disagreed` is the *conclusion* that the difference is real +rather than a moment caught mid-change. Nothing in this workspace looked at it. + +**The record now carries the whole `Coherence`**, `walk_only` and +`cpu_sets_only` lists included, not a boolean summary. A record saying only +"something disagreed" cannot be investigated, and the report attached to it asks +a runner whether they would help investigate -- so a lossy field would make that +ask hollow. Whatever a maintainer would need to look at, the record they are +offered has to contain. + +**It is a field of the record, not of the `Fingerprint`.** Fingerprints are +compared for equality to catch a record spliced from two machines, and coherence +is not a fact about a machine's *shape*: an announced reading that agreed and a +measured one that did not would trip that check and throw away a good +measurement over a difference in no shape at all. Provenance flows through the +fingerprint because it *is* a property of the reading the shape came from; +coherence rides beside it instead, from the same reading. + +### Informative, not coercive -- and that is a tested property + +This is the only part of the report that asks the reader for anything, and the +wording is a requirement rather than a matter of taste. The person running this +is already doing the project a favour on hardware nobody here can buy, and the +result they are holding is a valid submission whether or not they do anything +else. A section that reads as a demand costs exactly the submission it was +trying to improve. + +So the text states what was detected, says plainly that the measurements are +unaffected, names **both** possible causes -- the platform's description of this +hardware may be inconsistent, or this tool may be reading it wrongly -- and notes +that neither can be identified from the runner's machine. Then it offers a way +to help and closes by releasing the reader: "None of that is required." + +Naming this tool's own possible defect first-class matters. An ask that implied +the platform must be at fault would be asking the runner to help confirm a +conclusion rather than to help reach one, which is both discourteous and wrong: +this tool has been the defective party before. + +`the_disagreement_section_informs_rather_than_pressures` asserts the release is +present, that both causes are offered as undecided, and that no pressure word +("please", "you should", "we need", "make sure") appears. Tone cannot be pinned +completely by a test; these are the parts of it that can be, and they are the +parts an ordinary edit would lose. + +### The quiet path stays quiet + +`Agreed` and `NotCollected` print nothing at all. A line reporting that the two +sources agreed would appear on every run that ever happens, and a reader learns +to skip whatever is always there -- including on the one run where this section +is the most interesting thing in the file. + +### The advice matches what the reader is already holding + +The ask is for a record naming the OS build and hypervisor, which after M36.2 +means one run with `--include-metadata`. When the record already carries those, +the text says so instead of advising a flag whose output the reader has in hand: +that advice reads as though the flag had not worked, the same failure the +collection notice avoids with `--no-cpu-model`. + +**"Privately" is an offer to arrange, not a channel that exists.** Discussions +and issues are public, so the text asks the runner to make contact there and +says the maintainers can arrange a way to share the file that does not post it +publicly. Promising a private channel the project does not have would be a +worse defect than asking for nothing. + +The link is the repository rather than the results thread: a disagreement +between the platform's own tables is not a result, and posting it into the +collection thread would bury it among measurements. That is a second URL +constant, because `report` is not gated behind `serde` and `DISCUSSION_URL` is; +a test pins that the two agree about the repository, so the pair cannot drift +into sending a runner to a dead link. + +### What this deliberately does not do + +**`is_fully_trusted` is untouched.** A disagreement is not a doubt about +provenance -- the build is official and the topology really was read from this +machine -- so the "how far to trust this" section still says so, and the +disagreement is reported in its own section below. Whether an inconsistent +machine should additionally be *marked* there, and how to mark it without +dressing up a nuisance as a prize, is `PT-7.1`'s decision and is left to it. + +**The printed text gives counts, not processor identities.** The record names +them individually and says so. Naming them in the report is the rest of +`PT-7.1`, which wants what each source claimed; counts are what M36.4 needs to +make "a mismatch" a concrete thing rather than a word. + +Engineer's decision on the wording, 2026-09-04. Queued as `M36.4` in +[CHECKLIST-placement-tool.md](../../CHECKLIST-placement-tool.md), completing M36. +Three of the nine sabotages in [sabotage.json](sabotage.json) cover this section: +printing it on every run, dropping its closing release, and advising +`--include-metadata` to a record that already carries it. diff --git a/crates/windows-placement-probe/sabotage.json b/crates/windows-placement-probe/sabotage.json index 34057d0d..d6224cdc 100644 --- a/crates/windows-placement-probe/sabotage.json +++ b/crates/windows-placement-probe/sabotage.json @@ -65,6 +65,48 @@ " None => \"2026-01-01T00-00-00Z-\".to_owned()," ] }, + { + "name": "the disagreement section is printed on every run", + "file": "src/report.rs", + "expect": "caught", + "why": "A section that appears when the two sources agreed is noise on every run that ever happens, and noise is what a reader learns to skip -- including on the run where this matters. The quiet path has to stay quiet for the loud one to be worth anything. NOTE the shape: the heading is hoisted ABOVE the guard rather than the guard being deleted, because a `let ... else` must diverge and a replacement that did not would fail to compile and prove nothing.", + "find": [ + " let Coherence::Disagreed {" + ], + "replace": [ + " let _ = writeln!(out, \"-- this machine described itself two ways --\");", + " let Coherence::Disagreed {" + ] + }, + { + "name": "the disagreement section drops its release of the reader", + "file": "src/report.rs", + "expect": "caught", + "why": "This section reports something detected and then offers a way to help. Without the closing release it reads as a request, and a runner who feels chased is a runner who does not send the result they already have -- which is a valid submission either way.", + "find": [ + " let _ = writeln!(", + " out,", + " \" None of that is required. The result you already have is a valid\"", + " );", + " let _ = writeln!(", + " out,", + " \" submission and is worth sending exactly as it stands.\"", + " );" + ], + "replace": [] + }, + { + "name": "a record that already names its OS build is told to re-run for one", + "file": "src/report.rs", + "expect": "caught", + "why": "Advising a flag whose output the reader is already holding reads as though the flag had not worked. The report can tell which case it is in, because the record says so.", + "find": [ + " if record.machine.os_build_suppressed {" + ], + "replace": [ + " if true {" + ] + }, { "name": "--no-cpu-model stops subtracting from --include-metadata", "file": "src/bin/placement_probe/main.rs", diff --git a/crates/windows-placement-probe/schema/v1.txt b/crates/windows-placement-probe/schema/v1.txt index 00df2626..34db6130 100644 --- a/crates/windows-placement-probe/schema/v1.txt +++ b/crates/windows-placement-probe/schema/v1.txt @@ -100,4 +100,15 @@ recorded_at recorded_at_epoch_seconds recorded_at_suppressed schema_version +topology_coherence +topology_coherence.disagreed +topology_coherence.disagreed.attempts +topology_coherence.disagreed.cpu_sets_only +topology_coherence.disagreed.cpu_sets_only[] +topology_coherence.disagreed.cpu_sets_only[].group +topology_coherence.disagreed.cpu_sets_only[].number +topology_coherence.disagreed.walk_only +topology_coherence.disagreed.walk_only[] +topology_coherence.disagreed.walk_only[].group +topology_coherence.disagreed.walk_only[].number topology_provenance diff --git a/crates/windows-placement-probe/src/bin/placement_probe/main.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs index ee390ca1..3d665f28 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/main.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -204,7 +204,17 @@ fn run(out: &mut impl Sink) -> ExitCode { // Cannot fail: the equality was just checked above. Handled rather than // unwrapped anyway, because the constructor owns that invariant and a panic // here would discard a measurement the runner has already paid for. - let record = match SubmissionRecord::new(&observation, host, machine, policy) { + // The coherence of the reading `host` came from -- the one the runner was + // shown in the notice -- rather than of the one `measure` took internally, + // so the record's account of how this machine described itself matches the + // shape it reports. + let record = match SubmissionRecord::new( + &observation, + host, + machine, + policy, + topology.coherence.clone(), + ) { Ok(record) => record, Err(error) => { out.problem(&format!("the record could not be assembled: {error}")); diff --git a/crates/windows-placement-probe/src/paste_json/tests.rs b/crates/windows-placement-probe/src/paste_json/tests.rs index a42a94ba..8aac21d4 100644 --- a/crates/windows-placement-probe/src/paste_json/tests.rs +++ b/crates/windows-placement-probe/src/paste_json/tests.rs @@ -244,6 +244,7 @@ fn a_record_keeps_the_order_its_fields_are_declared_in() { "machine", "host", "topology_provenance", + "topology_coherence", "placements", "node_hops", "by_class", diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index c6d4409f..317046d6 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -20,7 +20,7 @@ use std::fmt; use std::time::{SystemTime, UNIX_EPOCH}; -use windows_topology_sys::Provenance; +use windows_topology_sys::{Coherence, Provenance}; use crate::build_identity::BuildIdentity; use crate::core_affinity::{Measurement, Observation}; @@ -141,6 +141,26 @@ pub struct SubmissionRecord { /// filtering out synthetic submissions should not have to know that the /// fingerprint carries it, and this is the field they will look for. pub topology_provenance: Provenance, + /// Whether the two Win32 sources agreed about which processors exist, when + /// this machine's topology was read. + /// + /// Carried in full, including the processors each source named alone, + /// because a [`Disagreed`](Coherence::Disagreed) record is the one this + /// project most wants to look at and a bare "something disagreed" cannot be + /// investigated. The report asks a runner who sees one whether they are + /// willing to help work out which side is wrong; that ask would be hollow + /// if the record they were asked for did not say what differed. + /// + /// **Not part of [`Fingerprint`], deliberately.** Fingerprints are compared + /// for equality to catch a record spliced from two machines, and coherence + /// is not a fact about a machine's *shape*: an announced reading that + /// agreed and a measured one that did not would trip that check and discard + /// a perfectly good measurement over a difference that is not a difference + /// in shape. + /// + /// This is the coherence of the reading [`Self::host`] came from, which is + /// the reading the runner was shown in the notice. + pub topology_coherence: Coherence, /// One entry per placement this machine could express, per strategy. pub placements: Vec, /// One entry per *directed* node pair, per ring placement, per strategy. @@ -302,6 +322,7 @@ impl SubmissionRecord { host: Fingerprint, machine: MachineDescription, policy: MetadataPolicy, + coherence: Coherence, ) -> std::io::Result { if host != observation.host { return Err(std::io::Error::new( @@ -352,6 +373,7 @@ impl SubmissionRecord { build: BuildIdentity::current(), machine, topology_provenance: host.provenance, + topology_coherence: coherence, host, placements: observation.measurements.iter().map(Into::into).collect(), node_hops: observation.by_node_pair.iter().map(Into::into).collect(), diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index 7204480e..37cbc04c 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -6,7 +6,7 @@ #[cfg(feature = "serde")] use std::collections::BTreeSet; -use windows_topology_sys::Provenance; +use windows_topology_sys::{Coherence, ProcessorId, Provenance}; use super::{MeasurementRecord, SCHEMA_VERSION, SubmissionRecord, civil_from_days, iso8601_utc}; use crate::build_identity::{BuildIdentity, BuildSource}; @@ -82,6 +82,24 @@ pub(crate) fn fully_populated() -> SubmissionRecord { provenance: Provenance::Measured, }, topology_provenance: Provenance::Measured, + // **`Disagreed`, because this fixture derives the schema golden**, and + // it is the only variant carrying fields. `Agreed` would archive a + // shape with no `walk_only`/`cpu_sets_only`/`attempts` paths in it, so + // the guard would pass while describing less than the record can emit. + // A measured topology whose two sources disagreed is a real + // combination, not a contrived one -- it is the case the report's + // closing section exists for. + topology_coherence: Coherence::Disagreed { + walk_only: vec![ProcessorId { + group: 0, + number: 14, + }], + cpu_sets_only: vec![ProcessorId { + group: 0, + number: 15, + }], + attempts: 3, + }, placements: vec![measurement.clone()], node_hops: vec![measurement.clone()], by_class: vec![measurement], @@ -330,6 +348,7 @@ fn a_record_cannot_splice_an_announced_host_onto_another_machines_rows() { announced, MachineDescription::read(MetadataPolicy::redacted()), MetadataPolicy::redacted(), + Coherence::Agreed, ) .expect_err("a record spanning two machines must not be assembled"); @@ -351,6 +370,7 @@ fn a_record_assembles_when_the_announced_and_measured_hosts_agree() { host.clone(), MachineDescription::read(MetadataPolicy::redacted()), MetadataPolicy::redacted(), + Coherence::Agreed, ) .expect("identical hosts are the ordinary case"); @@ -369,6 +389,7 @@ fn the_default_policy_leaves_a_record_with_no_timestamp() { host, MachineDescription::read(MetadataPolicy::default()), MetadataPolicy::default(), + Coherence::Agreed, ) .expect("identical hosts are the ordinary case"); @@ -392,6 +413,7 @@ fn opting_in_carries_a_timestamp_floored_to_the_minute() { host, MachineDescription::read(MetadataPolicy::included()), MetadataPolicy::included(), + Coherence::Agreed, ) .expect("identical hosts are the ordinary case"); diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index edfccfa5..e54b15fc 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -17,9 +17,25 @@ use std::fmt::Write as _; +use windows_topology_sys::Coherence; + use crate::machine::VirtualisationHint; use crate::record::SubmissionRecord; +/// Where a runner is pointed when something needs a conversation. +/// +/// **The repository, not the results thread.** A disagreement between the +/// platform's own tables is not a result, and posting it into the collection +/// thread would bury it among measurements. Both a discussion and an issue are +/// one click from here, so the runner picks whichever they are comfortable +/// with rather than being told which one this is. +/// +/// Deliberately its own constant rather than reusing +/// [`submission::DISCUSSION_URL`](crate::submission::DISCUSSION_URL), which is +/// gated behind the `serde` feature that this module is not; a test pins that +/// the two agree about the repository so the pair cannot drift apart silently. +pub const REPOSITORY_URL: &str = "https://github.com/MikeGrier/windows-threadpool-sys"; + /// Render the report a runner sees. #[must_use] pub fn render(record: &SubmissionRecord) -> String { @@ -30,6 +46,7 @@ pub fn render(record: &SubmissionRecord) -> String { render_by_class(&mut out, record); render_node_hops(&mut out, record); render_trust(&mut out, record); + render_topology_disagreement(&mut out, record); out } @@ -364,5 +381,164 @@ fn render_trust(out: &mut String, record: &SubmissionRecord) { ); } +/// Say that this machine described itself two ways, and offer a way to help. +/// +/// # Nothing at all on the ordinary run +/// +/// [`Coherence::Agreed`] and [`Coherence::NotCollected`] print nothing. A +/// section saying "the two sources agreed" would be noise on every run that +/// ever happens, and noise is what a reader learns to skip -- including on the +/// run where this matters. +/// +/// # Informative, not coercive +/// +/// This is a **report of something detected**, followed by an offer. It is +/// deliberately not a request, not a prompt, and not phrased so that declining +/// feels like a failure to help: the runner is already doing this project a +/// favour, and the result they have is a valid submission exactly as it stands. +/// The text says so in as many words, because a reader who feels chased is a +/// reader who closes the window. +/// +/// What it does say is what would be *learned*. Neither side can be identified +/// from here -- the platform's description of this hardware may be +/// inconsistent, or this tool may read it wrongly -- and telling those apart +/// needs a second pair of eyes on a record from the machine that showed it. +/// That is a fact about the situation, so it can be stated plainly without +/// asking for anything. +fn render_topology_disagreement(out: &mut String, record: &SubmissionRecord) { + /// `"s"` unless there is exactly one. A count printed as "1 processor(s)" + /// is the sort of thing a reader notices instead of the sentence. + const fn plural(count: usize) -> &'static str { + if count == 1 { "" } else { "s" } + } + + let Coherence::Disagreed { + walk_only, + cpu_sets_only, + attempts, + } = &record.topology_coherence + else { + return; + }; + + let _ = writeln!(out); + let _ = writeln!(out, "-- this machine described itself two ways --"); + let _ = writeln!(out); + let _ = writeln!( + out, + " Windows describes processors through two independent interfaces, and" + ); + let _ = writeln!( + out, + " on this machine they disagreed about which processors exist. The two" + ); + let _ = writeln!( + out, + " readings were repeated {attempts} times and never agreed, so this is a standing" + ); + let _ = writeln!( + out, + " difference between the platform's own tables rather than one reading" + ); + let _ = writeln!(out, " catching the machine mid-change."); + let _ = writeln!(out); + // Counts, not identities. Enough that "a mismatch" is a concrete thing a + // reader can see rather than a word, and the record beside this carries the + // processors themselves for anyone who goes looking. + let _ = writeln!( + out, + " The relationship walk reported {} processor{} the CPU-set enumeration", + walk_only.len(), + plural(walk_only.len()) + ); + let _ = writeln!( + out, + " did not; the CPU-set enumeration reported {} the walk did not. The", + cpu_sets_only.len() + ); + let _ = writeln!(out, " record below names them individually."); + let _ = writeln!(out); + let _ = writeln!( + out, + " Your measurements are unaffected: every row above was timed on" + ); + let _ = writeln!( + out, + " processors this run pinned and verified, and the numbers are real." + ); + let _ = writeln!(out); + let _ = writeln!( + out, + " Two things could produce this, and they cannot be told apart from" + ); + let _ = writeln!( + out, + " here: the platform's description of this hardware may be inconsistent," + ); + let _ = writeln!( + out, + " or this tool may be reading it wrongly. Either is worth knowing, and" + ); + let _ = writeln!( + out, + " the answer would apply to everyone with hardware like yours." + ); + let _ = writeln!(out); + let _ = writeln!( + out, + " If you would like to help work out which, you can start a discussion" + ); + let _ = writeln!(out, " or open an issue at"); + let _ = writeln!(out, " {REPOSITORY_URL}"); + let _ = writeln!( + out, + " and mention that you saw this message. What helps most is a record" + ); + // The advice a runner cannot act on is the advice not to give. A record that + // already names its OS build does not need to be produced again, and saying + // otherwise would read as though the flag they passed had not worked. + if record.machine.os_build_suppressed { + let _ = writeln!( + out, + " run with --include-metadata, because the OS build and hypervisor are" + ); + let _ = writeln!( + out, + " what tie a disagreement like this to a particular platform version." + ); + let _ = writeln!( + out, + " That is a separate run and a separate decision; the maintainers can" + ); + let _ = writeln!( + out, + " arrange a way to share it that does not post it publicly." + ); + } else { + let _ = writeln!( + out, + " like this one, which already names the OS build and hypervisor -- the" + ); + let _ = writeln!( + out, + " things that tie a disagreement to a particular platform version. The" + ); + let _ = writeln!( + out, + " maintainers can arrange a way to share it that does not post it" + ); + let _ = writeln!(out, " publicly."); + } + let _ = writeln!(out); + let _ = writeln!( + out, + " None of that is required. The result you already have is a valid" + ); + let _ = writeln!( + out, + " submission and is worth sending exactly as it stands." + ); +} + #[cfg(test)] mod tests; diff --git a/crates/windows-placement-probe/src/report/tests.rs b/crates/windows-placement-probe/src/report/tests.rs index e6d4eb93..c24c51ad 100644 --- a/crates/windows-placement-probe/src/report/tests.rs +++ b/crates/windows-placement-probe/src/report/tests.rs @@ -6,13 +6,23 @@ //! and would not check the property that matters, which is that the two cannot //! disagree. -use windows_topology_sys::Provenance; +use windows_topology_sys::{Coherence, ProcessorId, Provenance}; -use super::render; +use super::{REPOSITORY_URL, render}; use crate::build_identity::BuildSource; use crate::machine::VirtualisationHint; use crate::record::tests::fully_populated; +/// A record whose two topology sources agreed, which is the ordinary case. +/// +/// The shared fixture carries `Disagreed` because it derives the schema golden, +/// so a test about the *quiet* path has to say so explicitly. +fn coherent() -> crate::record::SubmissionRecord { + let mut record = fully_populated(); + record.topology_coherence = Coherence::Agreed; + record +} + #[test] fn the_report_shows_the_values_the_record_carries() { // The core property: a reader comparing the printed text against the file @@ -475,3 +485,177 @@ fn a_heterogeneous_machine_is_not_told_its_cores_are_all_one_class() { {text}" ); } + +// --------------------------------------------------------------------------- +// The topology-disagreement section. +// +// This is the one part of the report that asks the reader for something, so its +// tone is a property worth testing rather than a matter of taste: a runner who +// feels chased is a runner who closes the window, and the result they already +// have is a valid submission. +// --------------------------------------------------------------------------- + +#[test] +fn an_agreeing_topology_says_nothing_at_all() { + // Noise on every run that ever happens is what a reader learns to skip -- + // including on the run where this section matters. + let text = render(&coherent()); + + assert!( + !text.contains("described itself two ways"), + "the quiet path must stay quiet: {text}" + ); + assert!(!text.contains(REPOSITORY_URL), "got {text}"); +} + +#[test] +fn an_uncollected_coherence_also_says_nothing() { + // A hand-built or deserialized topology never asked the question, so it has + // no disagreement to report. Reporting one would be a claim about a machine + // nobody read. + let mut record = fully_populated(); + record.topology_coherence = Coherence::NotCollected; + + assert!(!render(&record).contains("described itself two ways")); +} + +#[test] +fn a_disagreement_is_reported_with_what_was_seen() { + // "Incoherent" is not actionable. The counts and the retry number are what + // make this a concrete thing the reader can see rather than a word. + let record = fully_populated(); + + let text = render(&record); + + assert!(text.contains("described itself two ways"), "got {text}"); + assert!( + text.contains("repeated 3 times"), + "the retry count says why this is not a machine caught mid-change: {text}" + ); + assert!( + text.contains("relationship walk reported 1 processor the CPU-set enumeration"), + "got {text}" + ); + assert!( + text.contains("CPU-set enumeration reported 1 the walk did not"), + "got {text}" + ); +} + +#[test] +fn the_counts_come_from_the_record_rather_than_being_fixed() { + // The property that makes the numbers above worth printing: they are a + // function of the record, like everything else in this report. + let mut record = fully_populated(); + record.topology_coherence = Coherence::Disagreed { + walk_only: vec![ + ProcessorId { + group: 0, + number: 3, + }, + ProcessorId { + group: 1, + number: 4, + }, + ], + cpu_sets_only: Vec::new(), + attempts: 7, + }; + + let text = render(&record); + + assert!( + text.contains("reported 2 processors the CPU-set enumeration"), + "a count of two must be pluralised: {text}" + ); + assert!(text.contains("reported 0 the walk did not"), "got {text}"); + assert!(text.contains("repeated 7 times"), "got {text}"); +} + +#[test] +fn the_disagreement_section_informs_rather_than_pressures() { + // **The tone is the requirement, not a nicety.** This section reports + // something detected and then offers a way to help; it must not read as a + // request, and must say plainly that the result in hand is already a valid + // submission. Checked as an absence of pressure words and a presence of the + // release, because both halves can be lost independently in an edit. + let text = render(&fully_populated()); + + assert!( + text.contains("None of that is required"), + "the offer must release the reader: {text}" + ); + assert!( + text.contains("valid") && text.contains("worth sending"), + "the result in hand must be affirmed: {text}" + ); + assert!( + text.contains("cannot be told apart"), + "the two possible causes must be presented as undecided: {text}" + ); + assert!( + text.contains("this tool may be reading it wrongly"), + "a defect in this tool must be named as a live possibility: {text}" + ); + for pressure in ["Please", "please", "you should", "we need", "make sure"] { + assert!( + !text.contains(pressure), + "{pressure:?} turns an offer into a request: {text}" + ); + } +} + +#[test] +fn the_measurements_are_not_disowned_by_the_disagreement() { + // A runner told their machine described itself two ways will reasonably + // wonder whether the numbers above are worthless. They are not -- every row + // was timed on processors this run pinned -- and leaving that unsaid would + // lose submissions to a misunderstanding. + let text = render(&fully_populated()); + + assert!( + text.contains("Your measurements are unaffected"), + "got {text}" + ); +} + +#[test] +fn the_metadata_advice_matches_what_the_record_already_carries() { + // Telling somebody to re-run with a flag whose output they are already + // holding reads as though the flag had not worked. The report knows which + // case it is in, because the record says so. + let mut opted_in = fully_populated(); + opted_in.machine.os_build_suppressed = false; + + let mut redacted = fully_populated(); + redacted.machine.os_build_suppressed = true; + + let opted_in = render(&opted_in); + let redacted = render(&redacted); + + assert!( + redacted.contains("run with --include-metadata"), + "a redacted record should be told what would help: {redacted}" + ); + assert!( + !opted_in.contains("run with --include-metadata"), + "a record that already names its OS build must not be asked for one: {opted_in}" + ); + assert!( + opted_in.contains("already names the OS build"), + "and should be told that it is already the useful shape: {opted_in}" + ); +} + +#[test] +fn the_disagreement_points_at_the_repository_rather_than_the_results_thread() { + // A disagreement between the platform's own tables is not a result, and + // posting it into the collection thread would bury it among measurements. + let text = render(&fully_populated()); + + assert!(text.contains(REPOSITORY_URL), "got {text}"); + assert!( + text.contains("discussion") && text.contains("issue"), + "both routes are offered so the reader picks one: {text}" + ); +} diff --git a/crates/windows-placement-probe/src/submission/tests.rs b/crates/windows-placement-probe/src/submission/tests.rs index b916fea6..415bf70a 100644 --- a/crates/windows-placement-probe/src/submission/tests.rs +++ b/crates/windows-placement-probe/src/submission/tests.rs @@ -301,3 +301,17 @@ fn the_sub_second_precision_never_reaches_the_record() { "the sub-second field reached the record: {json}" ); } + +#[test] +fn the_two_urls_agree_about_which_repository_this_is() { + // Two constants naming one repository, in two modules, because `report` is + // not gated behind `serde` and `DISCUSSION_URL` is. A checked relationship + // rather than a silent duplicate: if the repository ever moves and only one + // is updated, this fails instead of a runner being sent to a dead link. + assert!( + super::DISCUSSION_URL.starts_with(crate::report::REPOSITORY_URL), + "{} is not under {}", + super::DISCUSSION_URL, + crate::report::REPOSITORY_URL + ); +} From 14217d581af3672d1bbccb970ba32db4dd29d539 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 17:28:03 -0400 Subject: [PATCH 332/361] refactor(placement-probe): say what a result can be traced to, not how far to trust it "Trust" carries connotations -- about honesty, about authentication -- that this tool does not address and cannot settle. What the record actually answers is narrow and mechanical: was the binary built by CI from a named commit, and was the topology read from the host rather than fed in. The vocabulary now says that instead. SubmissionRecord::is_fully_trusted -> is_fully_traceable "-- how far to trust this --" -> "-- where this result came from --" README "Trusting the binary" -> "Checking what a binary is" BuildSource "ordered by trust" -> ordered by how well a build can be traced; variants are unofficial/CI, the words the tool already prints Fingerprint "the untrusted cases" -> "the unmeasured cases" No behaviour change beyond the printed wording. The body of that section already said "cannot be traced the way an official run can", so this makes the heading agree with the text under it rather than introducing a new term. The three remaining uses of the word are deliberate: each explains why the term is avoided, at the site where a future reader would otherwise reach for it. Ordinary-English uses elsewhere in the crate ("trusting the return value") are left alone -- they mean "relying on" and carry none of the freight. Also fixes two broken intra-doc links in machine.rs, introduced by M36.2 and caught here by the first `cargo doc` run since: that commit was gated on fmt, clippy, check and test but not on rustdoc, and CI would have failed on it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-placement-probe.yml | 2 +- CHECKLIST-placement-tool.md | 4 ++-- CHECKLIST-ship-topology-and-queues.md | 17 ++++++------- .../windows-placement-probe/DESIGN-NOTES.md | 9 +++---- crates/windows-placement-probe/README.md | 4 ++-- crates/windows-placement-probe/build.rs | 4 ++-- .../src/bin/placement_probe/main.rs | 2 +- .../src/build_identity.rs | 21 ++++++++++------ .../src/build_identity/tests.rs | 6 ++--- .../src/fingerprint.rs | 2 +- .../src/fingerprint/tests.rs | 14 +++++------ crates/windows-placement-probe/src/machine.rs | 9 +++---- crates/windows-placement-probe/src/record.rs | 14 ++++++++--- .../src/record/tests.rs | 18 +++++++------- crates/windows-placement-probe/src/report.rs | 24 ++++++++++++------- .../src/report/tests.rs | 6 ++--- 16 files changed, 91 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release-placement-probe.yml b/.github/workflows/release-placement-probe.yml index 20eaea5e..12493993 100644 --- a/.github/workflows/release-placement-probe.yml +++ b/.github/workflows/release-placement-probe.yml @@ -176,7 +176,7 @@ jobs: # triggers exist -- but it must not *publish*. Without the guard these # steps ran on every trigger, so each pull-request run attached two # downloadable binaries stamped `[ci]`, which `is_official` accepts and - # `SubmissionRecord::is_fully_trusted` then passes. Anyone could take one + # `SubmissionRecord::is_fully_traceable` then passes. Anyone could take one # from the run's artifact list and submit results indistinguishable from a # real release build, which is precisely the boundary this file's header # says the release attachment exists to draw. diff --git a/CHECKLIST-placement-tool.md b/CHECKLIST-placement-tool.md index 7032980a..6a5169ad 100644 --- a/CHECKLIST-placement-tool.md +++ b/CHECKLIST-placement-tool.md @@ -638,8 +638,8 @@ actionable is identifying and therefore belongs behind the review this tool alre than plumbing: (a) **Name the processors in the printed text**, not only in the record -- "what each source claimed", which is what this item asks for and what counts alone do not give. - (b) **Decide whether an inconsistent run is marked in "how far to trust this".** - `is_fully_trusted` is deliberately untouched, so that section currently reads "an official build, + (b) **Decide whether an inconsistent run is marked in "where this result came from".** + `is_fully_traceable` is deliberately untouched, so that section currently reads "an official build, reading this machine's real topology" directly above the disagreement section. That is not a contradiction -- the build *is* official and the topology *was* read -- but a reader may feel one. This item's own two-sided framing below is the guidance for settling it: mark it plainly enough diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 6ab49f29..b7f9339d 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -538,16 +538,17 @@ that previously stood in the way are gone: Windows 10 "Iron" codebase (build 20348), while Server 2025 shares Windows 11 24H2''s build 26100. A crate claiming a Windows 11 floor pairs with **Server 2025**. -- [ ] **SH-4.5** -- **Propagate enumeration incompleteness into the placement probe''s trust state.** +- [ ] **SH-4.5** -- **Propagate enumeration incompleteness into the placement probe''s traceability + state.** Raised in PR #56 review. `places_from_topology` ignores - `MachineMemoryTopology::enumeration_anomalies`, and `SubmissionRecord::is_fully_trusted` gates only - on `build.is_official()` and two `Provenance::Measured` checks -- which a `discover()` result - satisfies even when records were dropped. A truncated enumeration can therefore be filed as a - **trustworthy small machine**, which is the one thing a measurement tool must not do. + `MachineMemoryTopology::enumeration_anomalies`, and `SubmissionRecord::is_fully_traceable` gates + only on `build.is_official()` and two `Provenance::Measured` checks -- which a `discover()` result + satisfies even when records were dropped. A truncated enumeration can therefore be filed as an + **unremarkable small machine**, which is the one thing a measurement tool must not do. **Deliberately not rushed into PR #56**: the honest fix records the anomaly count *in the record* - and gates trust on it, and the record carries a **versioned schema** (see the `collapse the record - schema back to v1` commit), so this is a schema decision rather than a one-line guard. Downgrading - `Provenance` instead would be wrong -- per [D-22](crates/windows-topology-sys/DESIGN-NOTES.md#d-22) + and gates that state on it, and the record carries a **versioned schema** (see the `collapse the + record schema back to v1` commit), so this is a schema decision rather than a one-line guard. + Downgrading `Provenance` instead would be wrong -- per [D-22](crates/windows-topology-sys/DESIGN-NOTES.md#d-22) provenance records how the object was *obtained*, not how complete it is. The gap is newly reachable because `enumeration_anomalies` itself is new in this PR; before it, there was nothing to propagate. diff --git a/crates/windows-placement-probe/DESIGN-NOTES.md b/crates/windows-placement-probe/DESIGN-NOTES.md index 0d2744b3..d149161f 100644 --- a/crates/windows-placement-probe/DESIGN-NOTES.md +++ b/crates/windows-placement-probe/DESIGN-NOTES.md @@ -79,8 +79,9 @@ git is absent -- and it would answer a different question than the one the marking exists to ask. "This source came from commit X" is not "this binary was built by CI from commit X"; only the second makes the artifact independently checkable, because only the second was produced by something other than the -person submitting the record. Blurring the two would leave the record's trust -section saying something it cannot support. The unknown commit is honest, and +person submitting the record. Blurring the two would leave the record's +"where this result came from" section saying something it cannot support. The +unknown commit is honest, and honest is the point. **What publication will oblige**, recorded so the cost is not rediscovered @@ -381,9 +382,9 @@ into sending a runner to a dead link. ### What this deliberately does not do -**`is_fully_trusted` is untouched.** A disagreement is not a doubt about +**`is_fully_traceable` is untouched.** A disagreement is not a doubt about provenance -- the build is official and the topology really was read from this -machine -- so the "how far to trust this" section still says so, and the +machine -- so the "where this result came from" section still says so, and the disagreement is reported in its own section below. Whether an inconsistent machine should additionally be *marked* there, and how to mark it without dressing up a nuisance as a prize, is `PT-7.1`'s decision and is left to it. diff --git a/crates/windows-placement-probe/README.md b/crates/windows-placement-probe/README.md index 0fd79a43..c883f064 100644 --- a/crates/windows-placement-probe/README.md +++ b/crates/windows-placement-probe/README.md @@ -131,7 +131,7 @@ is identified by its **topology** -- an unusual core count, a novel cache arrangement -- at least as well as by its name, and the topology is the measurement. No switch fixes that, and it would be dishonest to imply otherwise. -## Trusting the binary +## Checking what a binary is Run `placement-probe --version`. A binary built by this repository's CI reports its commit and reads as official; anything else is marked `!!UNOFFICIAL!!`, @@ -157,7 +157,7 @@ implied otherwise. **To establish what a download actually is, verify its attestation.** Every released binary is signed by GitHub at build time with a statement binding those exact bytes to this repository, the workflow that built them, and the commit -they were built from. Checking it trusts none of what the binary says about +they were built from. Checking it relies on none of what the binary says about itself: ```powershell diff --git a/crates/windows-placement-probe/build.rs b/crates/windows-placement-probe/build.rs index 180f0b61..3bb6b5d5 100644 --- a/crates/windows-placement-probe/build.rs +++ b/crates/windows-placement-probe/build.rs @@ -6,7 +6,7 @@ //! owns, built from whatever commit was current -- so the record carries the //! commit, whether the tree was dirty, and whether the build came from CI. //! -//! # The default is untrusted +//! # The default is unofficial //! //! Every value here can fail to be determined: a `cargo install` from a //! crates.io tarball has no repository, a downloaded source zip has no `.git`, @@ -71,7 +71,7 @@ fn main() { // local build reported `v0.1.0 79b9c4666a1b [ci]` -- no `!!UNOFFICIAL!!` // marker, so a record from it would have pooled with real CI results. // That inverts this file's stated default, which is that being unable to - // tell must resolve to untrusted. + // tell must resolve to unofficial. let source = match std::env::var(SOURCE_ENV) { Ok(value) if value.trim().eq_ignore_ascii_case("ci") && stamped_commit.is_some() => "ci", _ if commit.is_some() => "local", diff --git a/crates/windows-placement-probe/src/bin/placement_probe/main.rs b/crates/windows-placement-probe/src/bin/placement_probe/main.rs index 3d665f28..5f795f82 100644 --- a/crates/windows-placement-probe/src/bin/placement_probe/main.rs +++ b/crates/windows-placement-probe/src/bin/placement_probe/main.rs @@ -110,7 +110,7 @@ fn run(out: &mut impl Sink) -> ExitCode { if options.version { // Deliberately the whole identity rather than just a version number. // CI asserts on this line that a released artifact reports itself - // official, and a runner can check the same thing before trusting a + // official, and a runner can check the same thing before relying on a // download -- both need the commit and the source, not just "0.1.0". out.line(&BuildIdentity::current().to_string()); return ExitCode::SUCCESS; diff --git a/crates/windows-placement-probe/src/build_identity.rs b/crates/windows-placement-probe/src/build_identity.rs index b036eb5c..f16fdd41 100644 --- a/crates/windows-placement-probe/src/build_identity.rs +++ b/crates/windows-placement-probe/src/build_identity.rs @@ -5,9 +5,16 @@ use std::fmt; /// Where a binary came from. /// -/// Ordered by trust, `Unknown < Local < Ci`, so the derived `Ord` is the trust -/// order -- the same shape as `windows_topology_sys::Provenance` one layer down, -/// and for the same reason. +/// Ordered by how well a build can be traced to the source that made it, +/// `Unknown < Local < Ci`, so the derived `Ord` is that order -- the same shape +/// as `windows_topology_sys::Provenance` one layer down, and for the same +/// reason. +/// +/// **Traceability, not trustworthiness.** `Ci` means an artifact that names the +/// commit it was built from; it does not mean the binary is honest, and this +/// enum cannot establish that -- the value is read from an environment variable +/// at build time, so anyone building this crate can set it. Ordering these by +/// "trust" would claim an authentication property that no variant here has. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] @@ -25,7 +32,7 @@ pub enum BuildSource { /// prove.** The value comes from an environment variable read at build /// time, so it distinguishes an *accidental* local build from a CI one -- /// which is what it is for -- and does not authenticate a binary someone - /// else handed you. See the crate README, "Trusting the binary": the + /// else handed you. See the crate README, "Checking what a binary is": the /// release asset's SHA-256 digest is what ties a download to what this /// repository published. Ci, @@ -44,9 +51,9 @@ impl BuildSource { } impl fmt::Display for BuildSource { - /// Renders the untrusted variants in capitals and the trusted one in lower - /// case, so a build that cannot vouch for itself is visibly louder than one - /// that can. + /// Renders the unofficial variants in capitals and the CI one in lower + /// case, so a build that cannot name where it came from is visibly louder + /// than one that can. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.label()) } diff --git a/crates/windows-placement-probe/src/build_identity/tests.rs b/crates/windows-placement-probe/src/build_identity/tests.rs index 73659d8b..a20f394a 100644 --- a/crates/windows-placement-probe/src/build_identity/tests.rs +++ b/crates/windows-placement-probe/src/build_identity/tests.rs @@ -14,14 +14,14 @@ fn official() -> BuildIdentity { } #[test] -fn the_default_source_is_the_untrusted_one() { +fn the_default_source_is_the_unofficial_one() { // The load-bearing property, matching Provenance one layer down: a value - // that was never established must not read as trustworthy. + // that was never established must not read as official. assert_eq!(BuildSource::default(), BuildSource::Unknown); } #[test] -fn the_source_ordering_is_the_trust_order() { +fn the_source_ordering_runs_from_unknown_to_official() { assert!(BuildSource::Unknown < BuildSource::Local); assert!(BuildSource::Local < BuildSource::Ci); } diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index 0872b927..5bf52ed9 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -499,7 +499,7 @@ impl fmt::Display for Fingerprint { /// /// A measured fingerprint renders exactly as it always did, so every string /// already recorded in a checklist or design note stays valid and - /// comparable. Only the untrusted cases gain a prefix, and they gain it at + /// comparable. Only the unmeasured cases gain a prefix, and they gain it at /// the *front*, where a reader scanning a column of results cannot skip it. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { if !self.provenance.is_measured() { diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index 01130087..cdb49a5b 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -192,7 +192,7 @@ fn a_synthetic_host_is_marked_at_the_front() { } #[test] -fn a_restored_host_is_marked_and_says_which_kind_of_untrusted_it_is() { +fn a_restored_host_is_marked_and_says_which_kind_of_unmeasured_it_is() { // Restored and synthetic are different claims -- one describes some real // machine, the other describes none -- and a reader deciding how much to // believe a number needs to know which. @@ -206,16 +206,16 @@ fn a_restored_host_is_marked_and_says_which_kind_of_untrusted_it_is() { } #[test] -fn an_untrusted_host_never_compares_equal_to_the_real_one_it_imitates() { +fn an_unmeasured_host_never_compares_equal_to_the_real_one_it_imitates() { // The specific bug the marker exists to prevent, and the reason it lives // inside the string rather than beside it. The fingerprint is documented as // canonical, so equality of the rendered form is a supported comparison -- // which means a fabricated machine claiming the exact shape of a real one // must not produce the same string. let real = x64_smt_host(); - for untrusted in [Provenance::Synthetic, Provenance::Restored] { + for unmeasured in [Provenance::Synthetic, Provenance::Restored] { let mut imitation = x64_smt_host(); - imitation.provenance = untrusted; + imitation.provenance = unmeasured; assert_eq!( imitation.processors, real.processors, @@ -224,13 +224,13 @@ fn an_untrusted_host_never_compares_equal_to_the_real_one_it_imitates() { assert_ne!( imitation.to_string(), real.to_string(), - "{untrusted:?} rendered identically to a measured host" + "{unmeasured:?} rendered identically to a measured host" ); } } #[test] -fn the_marker_is_the_only_difference_an_untrusted_host_renders() { +fn the_marker_is_the_only_difference_an_unmeasured_host_renders() { // The taint must not disturb the shape it prefixes, or a tainted // fingerprint could not be compared against a real one at all -- which is // exactly what someone validating synthetic selection logic needs to do. @@ -260,7 +260,7 @@ fn a_fingerprint_read_from_this_machine_reports_itself_as_measured() { #[test] fn a_fingerprint_built_from_a_hand_made_topology_is_not_measured() { - // The path a synthetic host takes. `MachineMemoryTopology::default` is untrusted by + // The path a synthetic host takes. `MachineMemoryTopology::default` is unmeasured by // construction, and `from_topology` must carry that through rather than // inventing an answer. let fingerprint = diff --git a/crates/windows-placement-probe/src/machine.rs b/crates/windows-placement-probe/src/machine.rs index ccb3034b..f519b7d1 100644 --- a/crates/windows-placement-probe/src/machine.rs +++ b/crates/windows-placement-probe/src/machine.rs @@ -23,10 +23,11 @@ //! # None of it is collected unless the runner says so //! //! Every field here is *context* rather than measurement, so -//! [`MetadataPolicy`] withholds all of it by default and -//! [`MachineDescription::read`] does not even ask the host for a field it will -//! not carry. The paragraph above therefore describes the shape of what an -//! opted-in submission contains, not what a default one does. +//! [`MetadataPolicy`](crate::redaction::MetadataPolicy) withholds all of it by +//! default and [`MachineDescription::read`](crate::machine::MachineDescription::read) +//! does not even ask the host for a field it will not carry. The paragraph +//! above therefore describes the shape of what an opted-in submission contains, +//! not what a default one does. //! //! # Every field is optional, and absence is honest //! diff --git a/crates/windows-placement-probe/src/record.rs b/crates/windows-placement-probe/src/record.rs index 317046d6..acd49f13 100644 --- a/crates/windows-placement-probe/src/record.rs +++ b/crates/windows-placement-probe/src/record.rs @@ -381,7 +381,15 @@ impl SubmissionRecord { }) } - /// Whether every part of this record is trustworthy. + /// Whether this record can be traced back to a named build and a real + /// machine. + /// + /// **Deliberately not phrased as trust.** What this answers is narrow and + /// mechanical -- was the binary built by CI from a named commit, and was the + /// topology read from the host rather than fed in -- and calling that + /// "trusted" would import a much larger set of questions about honesty and + /// authentication that this predicate does not ask and cannot answer. The + /// build stamp is self-reported; see [`BuildIdentity`]. /// /// A record that fails this is still worth sending -- it is not worth /// silently pooling with the rest, because a defect found later can only be @@ -394,14 +402,14 @@ impl SubmissionRecord { /// into `host`. Both fields are public, so the duplication can be broken -- /// by hand-assembling a record, or by editing one field of a deserialized /// one -- and consulting only the copy let a record whose fingerprint - /// renders `!!SYNTHETIC!!` report itself fully trusted. The printed report + /// renders `!!SYNTHETIC!!` report itself fully traceable. The printed report /// would then contradict the very string beside it. /// /// Requiring both is the conservative reading: a record that disagrees with /// itself about where its topology came from is exactly the record not to /// pool, whichever field happens to be right. #[must_use] - pub fn is_fully_trusted(&self) -> bool { + pub fn is_fully_traceable(&self) -> bool { self.build.is_official() && self.topology_provenance.is_measured() && self.host.provenance.is_measured() diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index 37cbc04c..7fc9725f 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -240,37 +240,37 @@ fn node_hops_is_an_empty_list_rather_than_an_absent_field() { } #[test] -fn a_record_is_fully_trusted_only_when_the_build_and_the_topology_both_are() { - assert!(fully_populated().is_fully_trusted()); +fn a_record_is_fully_traceable_only_when_the_build_and_the_topology_both_are() { + assert!(fully_populated().is_fully_traceable()); let mut synthetic = fully_populated(); synthetic.topology_provenance = Provenance::Synthetic; - assert!(!synthetic.is_fully_trusted()); + assert!(!synthetic.is_fully_traceable()); let mut unofficial = fully_populated(); unofficial.build.source = BuildSource::Local; - assert!(!unofficial.is_fully_trusted()); + assert!(!unofficial.is_fully_traceable()); } #[test] -fn a_record_whose_two_provenance_fields_disagree_is_not_trusted() { +fn a_record_whose_two_provenance_fields_disagree_is_not_fully_traceable() { // **The defect this guards.** `topology_provenance` duplicates the // fingerprint's provenance for a collector's convenience, and both fields // are public, so the two can be made to disagree. Consulting only the // top-level copy let a record whose fingerprint renders `!!SYNTHETIC!!` - // report itself fully trusted -- the printed report contradicting the very + // report itself fully traceable -- the printed report contradicting the very // string beside it. let mut top_level_lies = fully_populated(); top_level_lies.host.provenance = Provenance::Synthetic; assert!( - !top_level_lies.is_fully_trusted(), - "a synthetic fingerprint was reported as fully trusted" + !top_level_lies.is_fully_traceable(), + "a synthetic fingerprint was reported as fully traceable" ); // And the converse, so the check is not merely reading the other field now. let mut duplicate_lies = fully_populated(); duplicate_lies.topology_provenance = Provenance::Restored; - assert!(!duplicate_lies.is_fully_trusted()); + assert!(!duplicate_lies.is_fully_traceable()); } #[test] diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index e54b15fc..1745140f 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -45,7 +45,7 @@ pub fn render(record: &SubmissionRecord) -> String { render_placements(&mut out, record); render_by_class(&mut out, record); render_node_hops(&mut out, record); - render_trust(&mut out, record); + render_origin(&mut out, record); render_topology_disagreement(&mut out, record); out } @@ -336,11 +336,20 @@ fn render_node_hops(out: &mut String, record: &SubmissionRecord) { } } -fn render_trust(out: &mut String, record: &SubmissionRecord) { +/// Say what this result can be traced back to. +/// +/// # Deliberately not "how far to trust this" +/// +/// The question here is narrow and mechanical: was the binary built by CI from +/// a named commit, and was the topology read from the host rather than fed in. +/// Framing that as trust invites a much larger conversation -- about honesty, +/// about authentication -- that this section does not have and cannot settle, +/// since the build stamp is self-reported. Saying where a result came from is +/// the claim that is actually being made. +fn render_origin(out: &mut String, record: &SubmissionRecord) { let _ = writeln!(out); - let _ = writeln!(out, "-- how far to trust this --"); - - if record.is_fully_trusted() { + let _ = writeln!(out, "-- where this result came from --"); + if record.is_fully_traceable() { let _ = writeln!( out, " An official build, reading this machine's real topology." @@ -368,9 +377,8 @@ fn render_trust(out: &mut String, record: &SubmissionRecord) { let _ = writeln!(out, " an official run can, so say so when you send them."); } - // Stated on every run, not only untrusted ones. A long clean run is exactly - // when someone is most tempted to read more into it than it says. - let _ = writeln!(out); + // Stated on every run, not only marked ones. A long clean run is exactly + // when someone is most tempted to read more into it than it says. let _ = writeln!(out); let _ = writeln!( out, " What this does NOT establish: anything about memory ordering. These" diff --git a/crates/windows-placement-probe/src/report/tests.rs b/crates/windows-placement-probe/src/report/tests.rs index c24c51ad..3db9c3d8 100644 --- a/crates/windows-placement-probe/src/report/tests.rs +++ b/crates/windows-placement-probe/src/report/tests.rs @@ -150,14 +150,14 @@ fn a_single_node_machine_says_why_there_are_no_hops() { } #[test] -fn a_fully_trusted_run_is_not_marked() { +fn a_fully_traceable_run_is_not_marked() { let text = render(&fully_populated()); assert!(text.contains("official build"), "got {text}"); } #[test] -fn an_untrusted_run_names_each_reason_separately() { +fn a_marked_run_names_each_reason_separately() { // "Something is wrong" is not actionable. A reader triaging a surprising // submission needs to know whether the build or the topology was the // problem, and both can be true at once. @@ -180,7 +180,7 @@ fn the_ordering_caveat_is_stated_even_on_a_clean_run() { assert!( text.contains("memory ordering"), - "the ordering caveat must appear on every run, including trusted ones" + "the ordering caveat must appear on every run, including unmarked ones" ); } From 116d4e60b2b0f15e945eea86520e79b6613e61d7 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 18:09:23 -0400 Subject: [PATCH 333/361] fix(placement-probe): correct release notes and node-zero docs, and close the dispatch publish hole Three findings from the latest Copilot review rounds on PR #56, all of which had been raised more than once. Release notes contradicted M36.2. They told downloaders to pass --no-cpu-model to withhold the processor name, but the binary now withholds all secondary metadata by default and that flag does nothing on its own. This was a regression I introduced when the default flipped and did not sweep the release notes for. They now state the opt-in and the conditional opt-out together. workflow_dispatch could publish. The trigger's comment claimed it was "inherently build-and-verify-only"; it was not. A dispatch takes a ref, GitHub accepts a tag there, and dispatching against an existing placement-probe-v* tag satisfied every tag-prefix guard -- so a manual run could mint attestations and replace the assets of a shipped release. All four publishing guards now also require github.event_name == 'push', and the comment no longer asserts a property the file did not enforce. Tracked in SH-4.8, now marked done. The node-zero fallback was removed from the code but survived in the docs. Six sites still described placements defaulting to node 0 for a topology that names no memory domain, which `places_from_topology` now refuses. Corrected at each: the Fingerprint::numa_node_sizes contract, MissingPlacement::NumaNode and its message fragment, discover_places' error docs, places_from_topology's uniformity rule, and two test expectations plus the bare_processors description -- that fixture now supplies a memory domain, so the messages named a behaviour no test exercises. This is exactly the restatement drift the contract-integrity rule warns about: the behaviour change was swept, the prose was not. Three findings that are design decisions rather than patches are recorded instead of rushed: SH-4.10 (the fingerprint flattens multi-source observations by last-write-wins and discards CPU-set-only NUMA labels), SH-4.11 (the anti-splice guard compares fingerprints, which record only marginal sizes and so cannot establish the invariant its comment claims), and SH-4.12 (ring_copy's ByL3 restates the partition rule instead of asking outermost_partitioning_cache). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release-placement-probe.yml | 30 +++++++--- CHECKLIST-ship-topology-and-queues.md | 57 ++++++++++++++++++- .../src/fingerprint.rs | 43 ++++++++++---- .../src/fingerprint/tests.rs | 13 +++-- 4 files changed, 116 insertions(+), 27 deletions(-) diff --git a/.github/workflows/release-placement-probe.yml b/.github/workflows/release-placement-probe.yml index 12493993..dc49bd40 100644 --- a/.github/workflows/release-placement-probe.yml +++ b/.github/workflows/release-placement-probe.yml @@ -51,9 +51,16 @@ on: - 'Cargo.lock' - 'rust-toolchain.toml' # Kept for a re-run after merge, when the file does live on the default - # branch. Inherently build-and-verify-only for the same reason as above: an - # earlier revision declared a `dry_run` input and never read it, which would - # have been a switch that silently did nothing. + # branch. Build-and-verify-only -- but **that is enforced below rather than + # inherent**, and an earlier revision of this comment claimed otherwise. + # + # A dispatch takes a `ref`, and GitHub accepts a **tag** there as readily as a + # branch. Dispatching against an existing `placement-probe-v*` tag therefore + # made `github.ref` match the tag-prefix guards, so a manual run reached the + # publishing steps and could mint attestations and replace the assets of a + # release that had already shipped. The guards now require a `push` event as + # well, which is the only trigger that can create a tag rather than merely + # name one. workflow_dispatch: permissions: @@ -194,7 +201,7 @@ jobs: # The negative check above overwrote the artifact with an unstamped one. # Rebuilding is not a formality: shipping that binary would attach a # file marked UNOFFICIAL to an official release. - if: startsWith(github.ref, 'refs/tags/placement-probe-v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/placement-probe-v') shell: bash env: PLACEMENT_PROBE_COMMIT: ${{ github.sha }} @@ -205,7 +212,7 @@ jobs: -p windows-placement-probe --bin placement-probe - name: Name the artifact for its architecture - if: startsWith(github.ref, 'refs/tags/placement-probe-v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/placement-probe-v') shell: bash run: | arch="${{ matrix.target }}" @@ -215,7 +222,7 @@ jobs: "dist/placement-probe-${arch}.exe" - uses: actions/upload-artifact@v4 - if: startsWith(github.ref, 'refs/tags/placement-probe-v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/placement-probe-v') with: name: placement-probe-${{ matrix.target }} path: dist/placement-probe-*.exe @@ -225,7 +232,7 @@ jobs: name: attach to the release needs: build runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/placement-probe-v') + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/placement-probe-v') permissions: # The only job that needs it, and only for the tag path. contents: write @@ -282,7 +289,14 @@ jobs: Download the binary for your architecture and run it. Pass `--preview` first if you would like to see exactly what it collects before it - measures anything, and `--no-cpu-model` to withhold the processor name. + measures anything. + + By default a result carries **only** the machine's shape and its + timings. The CPU model, OS build, virtualisation hint and run time are + withheld unless you pass `--include-metadata`; sending them helps tie + a result to a particular platform version, and is entirely your + choice. `--no-cpu-model` subtracts the processor name from that + opt-in, so on its own it changes nothing. It makes **no network connections**. Sending the result is your decision and your action: diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index b7f9339d..a1fe7965 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -600,13 +600,64 @@ that previously stood in the way are gone: **A live-host test asserting cross-API agreement** (`cpu_set/tests.rs:242`) contradicts the model''s premise that CPU Sets may disagree with the walk; it is a latent failure on untried hardware. Assert that both observations are *recorded*, not that they agree. - **`release-placement-probe.yml` gating**: `workflow_dispatch` against an existing release tag - satisfies the tag-prefix condition, so a build-only run can create or modify a release. Add - `github.event_name == ''push''`, and verify both paths rather than reading the change. + **`release-placement-probe.yml` gating -- DONE 2026-09-04.** `workflow_dispatch` against an + existing release tag satisfied the tag-prefix condition, so a build-only run could create or modify + a release. All four publishing guards now require `github.event_name == ''push''`, and the trigger''s + own comment no longer claims the restriction is "inherent" -- it was not, which is precisely how + the hole survived. Raised again by Copilot at review `5117514238` before it was fixed. **`queue_contention.rs:241`** starts its clock without ordering against workers entering their loops, so a descheduled coordinator under-reports the baseline -- the optimistic direction, in a probe whose numbers are quoted as evidence. +- [ ] **SH-4.10** -- **The fingerprint collapses multi-source observations by last-write-wins, and + privileges the relationship walk.** Raised by Copilot across reviews `5117032381`, `5117514238` and + `5117911029` -- three rounds, one root, so it is recorded once here rather than three times. + `Source` has **no trust ordering** by construction + ([observation.rs](crates/windows-topology-sys/src/observation.rs)), and `fold_in_cpu_sets` + deliberately keeps differing memberships as *separate domains* so a disagreement survives into the + model. `places_from_topology` then flattens that with `HashMap::insert`, so: + (a) a processor in two core domains takes whichever domain is visited last, making core and + efficiency-class labels **iteration-order dependent**; + (b) the same for NUMA membership; + (c) a domain observed only by CPU Sets yields `MissingPlacement::NumaNode` even though it carries an + OS-reported node label, so a real observation is **discarded** for coming from the wrong source. + A reported disagreement is thereby converted into a silent arbitrary choice -- which is the defect + the topology model was reshaped to prevent, reappearing one layer up in its first consumer. + **The fix is a design decision, not a patch**: accept a sole or agreed label, and refuse the + measurement as ambiguous when the sources actually conflict. Refusing is consistent with this + seam''s existing rule that an invented value is worse than a lost one. Relates to `SH-4.8`''s + overlapping-domain finding, which is the same shape one layer down in `memory_domain_of`. + +- [ ] **SH-4.11** -- **The record''s anti-splice guard compares fingerprints, which cannot establish + what it claims.** Raised by Copilot at review `5117911029`. + [main.rs](crates/windows-placement-probe/src/bin/placement_probe/main.rs) compares + `observation.host != host` to refuse a record whose announced shape and measured rows came from two + different readings. But `Fingerprint` records only **marginal sizes** and its own documentation says + plainly that equal fingerprints may have different cache/class/NUMA *intersections*. A topology that + changed between the two discoveries while preserving every count passes the check, and the record + then combines the first topology with rows measured from the second -- the exact splice the guard + exists to prevent. + The honest fix is to compare a canonical **placement signature**, or the derived `places` + themselves, rather than the fingerprint. That signature is already queued as `PT-6.1` in + [CHECKLIST-placement-tool.md](CHECKLIST-placement-tool.md), so this item is gated on it and the two + should be done together. + **Not a reason to weaken the guard**: it still catches every change that alters a count, which is + every case observed so far. It is weaker than its own comment claims, and the comment must be + corrected even if the check is not. + +- [ ] **SH-4.12** -- **`ring_copy`''s `ByL3` policy restates the partition rule instead of asking for + it.** Raised by Copilot at reviews `5116772196` and `5116886015`. + [policy.rs](crates/windows-ioring-sys/examples/ring_copy/policy.rs) selects domains with + `matches!(domain.kind, DomainKind::Cache { level: 3, .. })`. The reshaped topology model makes + `outermost_partitioning_cache()` the one definition of which cache level partitions a machine, and + level numbering is explicitly **not** the ordering contract. So this consumer can produce + **overlapping ring domains** where two cache kinds are reported at level 3, and degrades to a single + whole-machine domain on a host whose outermost partition is at some other level -- neither of which + the policy''s own documentation admits to. + This is the consumer-side twin of the platform-integrity rule: bind to the specified primitive, not + to the level number that happens to be L3 on today''s hardware. The fix renames the policy as well + as changing it, since `byl3` is a user-facing CLI value that would no longer describe what it does. + - [ ] **SH-4.9** -- **`tools/check-publishable.ps1`: three findings with one root.** Its checks are **text searches standing in for structural facts**, which is how a check goes quietly vacuous. An unanchored pattern is satisfied by a *commented-out* assignment, so CI would believe the diff --git a/crates/windows-placement-probe/src/fingerprint.rs b/crates/windows-placement-probe/src/fingerprint.rs index 5bf52ed9..ab9a1b71 100644 --- a/crates/windows-placement-probe/src/fingerprint.rs +++ b/crates/windows-placement-probe/src/fingerprint.rs @@ -334,9 +334,14 @@ pub struct Fingerprint { /// /// The nodes the topology **reported**, so this does not necessarily sum to /// [`processors`](Self::processors): a topology naming no memory domains - /// leaves this empty, while every processor is still counted and every - /// placement still reports node `0` -- the documented single-node default - /// for exactly that case. + /// leaves this empty, while every processor is still counted. + /// + /// **Such a topology yields no placements at all**, so this list being + /// empty and the run having measured something cannot both be true of a + /// discovered host: `places_from_topology` refuses a topology that names no + /// memory domain rather than reporting node `0` for everyone. An earlier + /// version of this field documented that fallback, which was removed for + /// fabricating a node the machine never claimed. /// /// Empty is deliberately not rendered as one node covering the machine, even /// though [`cache_domain_sizes`](Self::cache_domain_sizes) does exactly that @@ -548,8 +553,9 @@ impl fmt::Display for Fingerprint { /// /// Returns whatever [`MachineMemoryTopology::discover`] failed with, or /// [`ErrorKind::InvalidData`](std::io::ErrorKind::InvalidData) if the discovered -/// topology names memory domains but leaves an online processor out of all of -/// them. Discovery has never produced that, and it would mean the topology +/// topology leaves an online processor with no memory domain -- either because +/// it names domains and omits that processor, or because it names none at all. +/// Discovery has never produced either, and either would mean the topology /// crate's parse had regressed rather than that the machine is unusual -- which /// is worth saying out loud rather than papering over with a fabricated node. pub fn discover_places() -> std::io::Result> { @@ -587,7 +593,12 @@ pub enum MissingPlacement { Core, /// No cache domain covers it at the level that partitions the machine. CacheDomain, - /// No memory domain covers it, though the topology names memory domains. + /// No memory domain covers it. + /// + /// Covers both shapes, deliberately: a topology that names memory domains + /// and omits this processor, and one that names none at all. The second is + /// not a single-node machine to be defaulted to node `0` -- it is a machine + /// that declined to say -- so both are refused and both arrive here. NumaNode, } @@ -597,7 +608,7 @@ impl MissingPlacement { match self { Self::Core => "core domains but places no core for", Self::CacheDomain => "a partitioning cache level that omits", - Self::NumaNode => "memory domains but places no NUMA node for", + Self::NumaNode => "no NUMA node for", } } } @@ -653,11 +664,19 @@ impl std::error::Error for UnplacedProcessor {} /// **uniform** across the machine is a real answer, and an absence that singles /// one processor out is a gap. /// -/// A topology naming no memory domain describes a single-node machine, so node -/// zero is correct for everyone; a topology naming nodes 1 and 2 and omitting a -/// processor has not said where it is, and answering zero invents a node the -/// machine does not have. The same holds for cores, efficiency classes, and the -/// cache level that partitions the machine. +/// A topology naming nodes 1 and 2 and omitting a processor has not said where +/// that processor is, and answering zero invents a node the machine does not +/// have. The same holds for cores, efficiency classes, and the cache level that +/// partitions the machine. +/// +/// **Naming no memory domain at all is refused too, and that is the correction +/// this paragraph used to get wrong.** It once read that such a topology +/// "describes a single-node machine, so node zero is correct for everyone". +/// It does not describe one: it declined to describe any, and node `0` was a +/// value this code supplied rather than one the machine claimed. The uniformity +/// rule above still holds for cores and classes, where an absent attribute has +/// no value to invent; NUMA is the exception because node `0` is a real label +/// that compares equal to a real answer. /// /// **The invented value is worse than a lost one**, which is why this refuses /// rather than substituting a sentinel: a synthetic core id can equal a real diff --git a/crates/windows-placement-probe/src/fingerprint/tests.rs b/crates/windows-placement-probe/src/fingerprint/tests.rs index cdb49a5b..5afa3254 100644 --- a/crates/windows-placement-probe/src/fingerprint/tests.rs +++ b/crates/windows-placement-probe/src/fingerprint/tests.rs @@ -744,8 +744,12 @@ mod multi_group_conversion { // --- Partial topologies, which this seam exists to accept (D-12) --- - /// A topology whose only domain is the group: online processors, no core, - /// no cache, and no memory domain at all. + /// A topology whose only domains are the group and one memory domain: + /// online processors, no core, and no cache. + /// + /// **The memory domain is not incidental.** A topology naming none at all is + /// refused outright, so without it every test below would fail for that + /// reason rather than for the partial-topology behaviour it is exercising. fn bare_processors(count: u8) -> MachineMemoryTopology { let all: Vec = (0..count).collect(); let mask = all.iter().fold(0_usize, |mask, n| mask | (1 << n)); @@ -796,7 +800,7 @@ mod multi_group_conversion { // core mentioned simply vanished -- the result described a smaller // machine than the topology did, and said nothing about the omission. let places = places_from_topology(&bare_processors(4)) - .expect("no memory domain at all means the single-node default applies"); + .expect("the fixture supplies a memory domain, so every processor is placeable"); assert_eq!(places.len(), 4, "every online processor must be placed"); let mut numbers: Vec = places.iter().map(|p| p.number).collect(); @@ -840,7 +844,8 @@ mod multi_group_conversion { )], }); - let places = places_from_topology(&topology).expect("no memory domain, so node 0 applies"); + let places = places_from_topology(&topology) + .expect("both groups carry a memory domain, so every processor is placeable"); assert_eq!(places.len(), 2); assert_ne!( From bfc7971661223fb937d0c6857b4adbe3b304594a Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 18:40:09 -0400 Subject: [PATCH 334/361] fix(probes): name the host in every probe report, and correct the crate docs Two findings from Copilot review 5118237348, a headline-only round. Eight probe reports named no machine. The banner had reached the seven binaries M34.2 listed plus the shared long-path renderer, which left cancel_io, completion_port, device_map, error_mode, handle_state, ioring, pool_growth and worker_context composing a report with no host line. pool_growth was the sharpest case: it printed "every number here is from this host and this Windows build" while giving a reader no way to tell which host that was. The rest report what *this* Windows does, which is equally uninterpretable unattributed -- ioring's availability, for one, is a function of the build. All eight now emit banner_line() as the first line of the returned text, matching the house style, so a captured report keeps it. Verified by running each of the eight binaries and checking the first line, rather than by reading the diff. The crate docs still described the pre-M36.2 default. lib.rs listed the CPU model, OS build and virtualisation hint under a flat "Collected", and offered a switch for withholding the model -- both true before the default flipped and false after. That is the third document I have had to correct for this one behaviour change, after the README and the release notes, which is a sweep I should have done once rather than three times. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST.md | 9 ++++++ crates/windows-placement-probe/src/lib.rs | 32 +++++++++++-------- .../src/bin/cancel_io.rs | 9 ++++++ .../src/bin/completion_port.rs | 9 ++++++ .../src/bin/device_map.rs | 9 ++++++ .../src/bin/error_mode.rs | 9 ++++++ .../src/bin/handle_state.rs | 9 ++++++ .../windows-platform-probes/src/bin/ioring.rs | 9 ++++++ .../src/bin/pool_growth.rs | 9 ++++++ .../src/bin/worker_context.rs | 9 ++++++ 10 files changed, 100 insertions(+), 13 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index dc7c0c01..369eaea7 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -129,6 +129,15 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. - [ ] **M34.2** -- **Route every tool's output through one sink, per the repository's own rule**: never call `println!`/`eprintln!` from more than one site in a tool; introduce a writer trait, sink or formatter at the first occurrence and route everything through it. + **Updated 2026-09-04 (second pass): every probe now leads its report with the host line.** The + banner had reached only the seven binaries this item named plus the shared long-path renderer, + which left **eight** probes -- `cancel_io`, `completion_port`, `device_map`, `error_mode`, + `handle_state`, `ioring`, `pool_growth`, `worker_context` -- composing a report that named no + machine. `pool_growth` was the sharpest case: it printed "every number here is from this host and + this Windows build" while giving a reader no way to tell which host that was. The rest are + behavioural findings about what *this* Windows does, which is equally uninterpretable unattributed. + All eight now emit `banner_line()` as the first line of the returned text, verified by running each + binary rather than by reading the diff. Raised by Copilot at review `5118237348`. **Updated 2026-09-04: the conversion is done; what remains is the capture test.** The item said "seven binaries violate this today" and named them, from review 5072622803 on pull request #56. Five had already been converted when a later review round re-checked, and the last two -- diff --git a/crates/windows-placement-probe/src/lib.rs b/crates/windows-placement-probe/src/lib.rs index 92589c6f..2c0e8ee8 100644 --- a/crates/windows-placement-probe/src/lib.rs +++ b/crates/windows-placement-probe/src/lib.rs @@ -24,24 +24,30 @@ //! //! # What it collects, and what it does not //! -//! **Collected:** the shape of the machine (logical processors, cores, cache -//! domains, efficiency classes, NUMA nodes), the CPU model, the OS build, a -//! hint about whether virtualisation was detected, and the timings this tool -//! measures. +//! **Always collected:** the shape of the machine (logical processors, cores, +//! cache domains, efficiency classes, NUMA nodes) and the timings this tool +//! measures. That is the measurement, so it is not redactable. //! -//! **Not collected:** host name, user name, file paths, environment variables, -//! serial numbers, or anything about installed software. That list is a -//! commitment rather than a description of the current implementation. +//! **Collected only with `--include-metadata`:** the CPU model, the OS build, a +//! hint about whether virtualisation was detected, and the minute the run +//! finished. These are *context* rather than measurement, so they are +//! **withheld by default** -- see +//! [`redaction::MetadataPolicy`]. A field the +//! policy withholds is not read at all, rather than read and then dropped. +//! +//! **Never collected:** host name, user name, file paths, environment +//! variables, serial numbers, or anything about installed software. That list +//! is a commitment rather than a description of the current implementation. //! //! **The tool makes no network connections.** It writes a file; sending it is //! your decision and your action. The record is text, so you can read it before -//! deciding -- and if you would rather not share the CPU model, there is a -//! switch for that. +//! deciding. //! -//! **If the hardware is confidential, do not send the record.** The model name -//! can be suppressed, but the topology *is* the measurement, and an unreleased -//! part is identified by its shape at least as well as by its name. No switch -//! fixes that, and pretending otherwise would be worse than saying so. +//! **If the hardware is confidential, do not send the record.** Redaction +//! reduces incidental leakage and nothing more: the topology *is* the +//! measurement, and an unreleased part is identified by its shape at least as +//! well as by its name. No switch fixes that, and pretending otherwise would be +//! worse than saying so. //! //! # An instrument, not a library //! diff --git a/crates/windows-platform-probes/src/bin/cancel_io.rs b/crates/windows-platform-probes/src/bin/cancel_io.rs index 4497603b..c9a92edd 100644 --- a/crates/windows-platform-probes/src/bin/cancel_io.rs +++ b/crates/windows-platform-probes/src/bin/cancel_io.rs @@ -36,6 +36,15 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // finding can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!( out, "== is CancelSynchronousIo safe to point at a shared thread? ==\n" diff --git a/crates/windows-platform-probes/src/bin/completion_port.rs b/crates/windows-platform-probes/src/bin/completion_port.rs index 6c44578b..db331f6c 100644 --- a/crates/windows-platform-probes/src/bin/completion_port.rs +++ b/crates/windows-platform-probes/src/bin/completion_port.rs @@ -156,6 +156,15 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // finding can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!(out, "== IOCP association vs IoRing, on one handle ==\n"); match measure() { diff --git a/crates/windows-platform-probes/src/bin/device_map.rs b/crates/windows-platform-probes/src/bin/device_map.rs index 722bcf8a..f844a881 100644 --- a/crates/windows-platform-probes/src/bin/device_map.rs +++ b/crates/windows-platform-probes/src/bin/device_map.rs @@ -26,6 +26,15 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // finding can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!(out, "== does impersonation change the DOS device map? ==\n"); let Some(drive) = SubstDrive::claim("binary") else { diff --git a/crates/windows-platform-probes/src/bin/error_mode.rs b/crates/windows-platform-probes/src/bin/error_mode.rs index 2d052a60..bc692e96 100644 --- a/crates/windows-platform-probes/src/bin/error_mode.rs +++ b/crates/windows-platform-probes/src/bin/error_mode.rs @@ -47,6 +47,15 @@ fn main() { /// changes the result. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // finding can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!(out, "--- each bit on its own, set then read back ---"); for bit in [ bits::FAIL_CRITICAL_ERRORS, diff --git a/crates/windows-platform-probes/src/bin/handle_state.rs b/crates/windows-platform-probes/src/bin/handle_state.rs index 30a30cd5..214a9dac 100644 --- a/crates/windows-platform-probes/src/bin/handle_state.rs +++ b/crates/windows-platform-probes/src/bin/handle_state.rs @@ -27,6 +27,15 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // finding can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let fixture = Fixture::new("bin"); let truth = ground_truth(&fixture); let _ = writeln!(out, "--- ground truth (one handle, start to finish) ---"); diff --git a/crates/windows-platform-probes/src/bin/ioring.rs b/crates/windows-platform-probes/src/bin/ioring.rs index d8c3284e..e3909b14 100644 --- a/crates/windows-platform-probes/src/bin/ioring.rs +++ b/crates/windows-platform-probes/src/bin/ioring.rs @@ -26,6 +26,15 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // finding can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!(out, "== IoRing registration and thread agnosticism ==\n"); if !is_available() { diff --git a/crates/windows-platform-probes/src/bin/pool_growth.rs b/crates/windows-platform-probes/src/bin/pool_growth.rs index 892b4d11..91a3e91f 100644 --- a/crates/windows-platform-probes/src/bin/pool_growth.rs +++ b/crates/windows-platform-probes/src/bin/pool_growth.rs @@ -51,6 +51,15 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // finding can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!(out, "== how a blocked pool grows ==\n"); report(&mut out, "P1 growth curve, max 4:", 4, 8, false); diff --git a/crates/windows-platform-probes/src/bin/worker_context.rs b/crates/windows-platform-probes/src/bin/worker_context.rs index c6c8aaef..7ec804fc 100644 --- a/crates/windows-platform-probes/src/bin/worker_context.rs +++ b/crates/windows-platform-probes/src/bin/worker_context.rs @@ -27,6 +27,15 @@ fn main() { /// The probe's whole report, as text. fn render() -> String { let mut out = String::new(); + // First line of the report, and part of the returned text rather than + // written out here: a captured report must carry the line naming the + // machine that produced it, and the taint marker with it. Without it a + // finding can be pasted anywhere and compared against anything. + let _ = writeln!( + out, + "{}", + windows_placement_probe::fingerprint::banner_line() + ); let _ = writeln!(out, "== what a thread-pool worker is handed ==\n"); let plain = observe_on_worker(); From cdb7a216c9dadd250574b489daa43a6ed974f564 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 18:48:41 -0400 Subject: [PATCH 335/361] fix(topology): retry a size-and-fetch pair when the buffer grows underneath it Both Win32 enumerations this crate reads are two-call APIs: size with a null buffer, then fetch into an allocation of the size reported. A processor hot-added between the two calls needs more bytes than the sizing call asked for, so the fetch fails with ERROR_INSUFFICIENT_BUFFER a second time. Both enumerators returned that error. That was the opposite of how this crate treats the same class of transient one layer up. D-16 has discover() read both sources up to COHERENCE_ATTEMPTS times precisely so a hot-add caught between them is retried away, and only a difference that survives is reported as Coherence::Disagreed. The inner read gave up on the first growth while the outer loop stood ready to absorb exactly that event -- and the inner error propagated out through collect_once()?, so the outer retry never ran at all. Both now attempt the pair up to SIZING_ATTEMPTS times, defined once in records.rs beside the record-walking code the two already share. Any error other than ERROR_INSUFFICIENT_BUFFER on the fetch is still returned immediately: only the growth case is retried, because only that case is a fact about the machine rather than about the call. Not a silent-corruption fix. The old behaviour failed loudly and truncated nothing, so this is robustness -- it converts an error that reads like a bug in this crate into the successful read it almost always would have been on a second pass. The race cannot be provoked by a test, so what is guarded is the constant: at 1 the loop sizes, fetches and gives up on the first growth, which is the behaviour being replaced, while every call site still reads correctly. SIZING_ATTEMPTS >= 2 is asserted at compile time, verified in both directions by setting it to 1 and confirming the build fails with that message. Recorded as D-41. Raised by Copilot as "both topology enumerators mishandle buffer growth during hot-add" in a headline-only round; the first reading was that Coherence already covered it, which is true of the cross-source hot-add and not of the within-enumerator one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-topology-sys/DESIGN-NOTES.md | 53 +++++++++ crates/windows-topology-sys/src/cpu_set.rs | 120 +++++++++++--------- crates/windows-topology-sys/src/records.rs | 32 ++++++ crates/windows-topology-sys/src/walk.rs | 89 +++++++++------ 4 files changed, 206 insertions(+), 88 deletions(-) diff --git a/crates/windows-topology-sys/DESIGN-NOTES.md b/crates/windows-topology-sys/DESIGN-NOTES.md index 350418e4..0194d38f 100644 --- a/crates/windows-topology-sys/DESIGN-NOTES.md +++ b/crates/windows-topology-sys/DESIGN-NOTES.md @@ -856,3 +856,56 @@ absent processor is visible in `cpu_sets_only` while a fabricated one is visible checklist item, because the answer is that the current shape is correct and the documentation was what was missing. Should a consumer ever need a merged list, it belongs as a derived view built from both sources, not as a mutation of the walk's. + +## D-41: a buffer that grows between sizing and fetching is a transient, and is retried + +Both Win32 enumerations this crate reads -- `GetLogicalProcessorInformationEx` +and `GetSystemCpuSetInformation` -- are two-call APIs: size with a null buffer, +which fails with `ERROR_INSUFFICIENT_BUFFER` and reports the byte count, then +fetch into an allocation of that size. + +**The machine can change between those two calls.** A processor hot-added after +the sizing call needs more bytes than that call asked for, so the fetch fails +with `ERROR_INSUFFICIENT_BUFFER` a *second* time. Both enumerators returned that +error, which made a transient into a failed `discover()`. + +That was the opposite of how this crate treats the same class of transient one +layer up. [D-16](#d-16) has `discover` read both sources up to +`COHERENCE_ATTEMPTS` times precisely so that a hot-add caught between them is +retried away, and only a difference that *survives* the retry is reported as +`Coherence::Disagreed`. The inner read gave up on the first growth while the +outer loop stood ready to absorb exactly that event -- and worse, the inner +error propagated out through `collect_once()?`, so the outer retry never ran at +all. + +Both enumerators now attempt the size-and-fetch pair up to `SIZING_ATTEMPTS` +times, defined once in [records.rs](src/records.rs) beside the record-walking +code the two already share. Any error other than `ERROR_INSUFFICIENT_BUFFER` on +the fetch is still returned immediately: only the growth case is retried, +because only the growth case is a fact about the machine rather than about the +call. + +**Bounded, for the same reason the outer retry is.** A machine being hot-plugged +continuously is not one this crate can describe, and looping until it settles +would hang discovery rather than fail it. Three attempts, matching D-16's shape. + +**Not a silent-corruption fix.** The previous behaviour failed loudly and +truncated nothing, so this is robustness rather than correctness: it converts an +`ERROR_INSUFFICIENT_BUFFER` that reads like a bug in this crate into the +successful read it almost always would have been on a second pass. + +### Why there is a `const` assertion and not a test + +The race needs a hot-add to occur inside a two-call window, which no test here +can provoke; a test that merely called `enumerate()` would exercise the +first-attempt path and prove nothing about the loop. What *can* be checked is +the constant: at `1` the loop sizes, fetches and gives up on the first growth, +which is exactly the behaviour this decision replaces, while every call site +still reads correctly. So `SIZING_ATTEMPTS >= 2` is asserted at compile time -- +verified in both directions, by setting it to `1` and confirming the build fails +with that message. + +Raised by Copilot in the PR #56 review as "both topology enumerators mishandle +buffer growth during hot-add", in a headline-only round. The first reading was +that `Coherence` already covered it; it covers the *cross-source* hot-add and +not the *within-enumerator* one, which is why the two are distinguished above. diff --git a/crates/windows-topology-sys/src/cpu_set.rs b/crates/windows-topology-sys/src/cpu_set.rs index f2de4cc5..92460501 100644 --- a/crates/windows-topology-sys/src/cpu_set.rs +++ b/crates/windows-topology-sys/src/cpu_set.rs @@ -12,7 +12,10 @@ //! the same reasons: //! //! - Size first with a null buffer, which fails with `ERROR_INSUFFICIENT_BUFFER` -//! and reports the byte count. +//! and reports the byte count. **The pair is attempted more than once**: the +//! machine can grow between the sizing call and the fetch, and a hot-add there +//! makes the fetch fail the same way. See +//! [`SIZING_ATTEMPTS`](crate::records::SIZING_ATTEMPTS). //! - Records are **variable length**: advance by each record's own `Size` field, //! never by `size_of::()`. The struct's declared //! size describes today's `CpuSetInformation` record, and a future type may be @@ -51,6 +54,7 @@ use crate::observation::Source; use crate::records::RecordWalk; use std::io; +use crate::records::SIZING_ATTEMPTS; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; use windows_sys::Win32::System::SystemInformation::{ CpuSetInformation, GetSystemCpuSetInformation, SYSTEM_CPU_SET_INFORMATION, @@ -175,61 +179,73 @@ mod flags { /// Returns any error from `GetSystemCpuSetInformation` other than the expected /// sizing failure. pub(crate) fn enumerate() -> io::Result<(Vec, Option)> { - let mut length: u32 = 0; - // SAFETY: a null buffer with a zero length and a valid out-pointer, which is - // the documented sizing call. `GetCurrentProcess` is a pseudo-handle needing - // no close, and is the documented way to ask about this process. - let probe = unsafe { - GetSystemCpuSetInformation( - std::ptr::null_mut(), - 0, - &raw mut length, - GetCurrentProcess(), - 0, - ) - }; - if probe != 0 { - // Succeeding on the sizing call would mean zero bytes were needed, so - // there is nothing to report. - return Ok((Vec::new(), None)); - } - let error = io::Error::last_os_error(); - if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { - return Err(error); - } - if length == 0 { - return Ok((Vec::new(), None)); - } + // **Sized and fetched in a bounded loop**, because the machine can change + // between the two calls -- see `SIZING_ATTEMPTS` for why a second + // `ERROR_INSUFFICIENT_BUFFER` is retried rather than returned. + let mut grew = None; + for _ in 0..SIZING_ATTEMPTS { + let mut length: u32 = 0; + // SAFETY: a null buffer with a zero length and a valid out-pointer, which + // is the documented sizing call. `GetCurrentProcess` is a pseudo-handle + // needing no close, and is the documented way to ask about this process. + let probe = unsafe { + GetSystemCpuSetInformation( + std::ptr::null_mut(), + 0, + &raw mut length, + GetCurrentProcess(), + 0, + ) + }; + if probe != 0 { + // Succeeding on the sizing call would mean zero bytes were needed, so + // there is nothing to report. + return Ok((Vec::new(), None)); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { + return Err(error); + } + if length == 0 { + return Ok((Vec::new(), None)); + } - // `u64`-backed storage for the same reason the relationship walk uses it: - // it guarantees 8-byte alignment for every record header regardless of what - // a `Vec` allocation would have happened to provide. `AllocationTag` is - // 8-byte-sized, so this is not merely tidiness. - let mut storage = vec![0_u64; (length as usize).div_ceil(8)]; - let buffer = storage.as_mut_ptr().cast::(); - let mut actual_length = length; - // SAFETY: `buffer` points to `storage`, whose byte length is at least - // `length` (the size the probe just reported) and is 8-byte aligned; - // `actual_length` is a valid in/out length pointer. - let ok = unsafe { - GetSystemCpuSetInformation( - buffer.cast(), - length, - &raw mut actual_length, - GetCurrentProcess(), - 0, - ) - }; - if ok == 0 { - return Err(io::Error::last_os_error()); + // `u64`-backed storage for the same reason the relationship walk uses it: + // it guarantees 8-byte alignment for every record header regardless of what + // a `Vec` allocation would have happened to provide. `AllocationTag` is + // 8-byte-sized, so this is not merely tidiness. + let mut storage = vec![0_u64; (length as usize).div_ceil(8)]; + let buffer = storage.as_mut_ptr().cast::(); + let mut actual_length = length; + // SAFETY: `buffer` points to `storage`, whose byte length is at least + // `length` (the size the probe just reported) and is 8-byte aligned; + // `actual_length` is a valid in/out length pointer. + let ok = unsafe { + GetSystemCpuSetInformation( + buffer.cast(), + length, + &raw mut actual_length, + GetCurrentProcess(), + 0, + ) + }; + if ok != 0 { + // SAFETY: `buffer` holds `actual_length` bytes written by the call + // above: consecutive `SYSTEM_CPU_SET_INFORMATION` records whose `Size` + // fields sum to `actual_length`, per the API's contract. + return Ok(unsafe { decode(buffer.cast_const(), actual_length) }); + } + + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { + return Err(error); + } + // The machine grew between the sizing call and this one. Size again. + grew = Some(error); } - // SAFETY: `buffer` holds `actual_length` bytes written by the call above: - // consecutive `SYSTEM_CPU_SET_INFORMATION` records whose `Size` fields sum - // to `actual_length`, per the API's contract. - Ok(unsafe { decode(buffer.cast_const(), actual_length) }) + Err(grew.expect("SIZING_ATTEMPTS is a non-zero constant, so the loop ran at least once")) } - const SIZE_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Size); const TYPE_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Type); const UNION_OFFSET: usize = core::mem::offset_of!(SYSTEM_CPU_SET_INFORMATION, Anonymous); diff --git a/crates/windows-topology-sys/src/records.rs b/crates/windows-topology-sys/src/records.rs index 58f2ff99..f7845460 100644 --- a/crates/windows-topology-sys/src/records.rs +++ b/crates/windows-topology-sys/src/records.rs @@ -27,6 +27,38 @@ use crate::EnumerationAnomaly; use crate::observation::Source; +/// How many times a size-then-fetch pair is attempted when the buffer grows +/// between the two calls. +/// +/// **Both enumerations size with a null buffer and then fetch into an +/// allocation of the size they were told.** The machine can change in between: +/// a processor hot-added after the sizing call needs more bytes than that call +/// asked for, and the fetch then fails with `ERROR_INSUFFICIENT_BUFFER` a +/// *second* time. +/// +/// Treating that as a hard failure would turn a transient into a failed +/// discovery, which is the opposite of how +/// [`MachineMemoryTopology::discover`](crate::MachineMemoryTopology::discover) +/// handles the same class of transient one layer up: per +/// [D-16](../DESIGN-NOTES.md#d-16) it retries a bounded number of times and +/// then represents whatever survives. This is that discipline applied to the +/// read itself, so the outer retry is reached rather than pre-empted by an +/// error from the inner one. +/// +/// **Bounded, for the same reason the outer retry is.** A machine being +/// hot-plugged continuously is not a machine this crate can describe, and +/// looping until it settles would hang discovery instead of failing it. +pub(crate) const SIZING_ATTEMPTS: u32 = 3; + +// A single attempt is no retry at all -- it would size, fetch, and give up on +// the first growth, which is the behaviour this constant exists to replace. +// Asserted at compile time because the loops below read correctly either way +// and a `1` here would silently restore the old behaviour. +const _: () = assert!( + SIZING_ATTEMPTS >= 2, + "SIZING_ATTEMPTS below 2 absorbs no buffer growth at all" +); + /// One record, bounded by the `Size` it declared. /// /// Reads through this type cannot leave the record, which is what keeps a diff --git a/crates/windows-topology-sys/src/walk.rs b/crates/windows-topology-sys/src/walk.rs index ef0e2552..6932de49 100644 --- a/crates/windows-topology-sys/src/walk.rs +++ b/crates/windows-topology-sys/src/walk.rs @@ -16,6 +16,10 @@ //! `Size` accounting, is exactly what correct use of the API requires. //! - The record body is a `union` discriminated by the record's //! `Relationship` field, unchecked by the type system. +//! - The buffer is sized by one call and filled by another, so the machine can +//! grow in between and make the second call fail for the same reason the +//! first did. The pair is therefore attempted more than once -- see +//! [`SIZING_ATTEMPTS`](crate::records::SIZING_ATTEMPTS). //! //! Everything `unsafe` in this crate is here. Every function this module //! exposes to the rest of the crate is safe. @@ -42,6 +46,7 @@ use crate::observation::Source; use crate::records::{Record as RawRecord, RecordWalk}; use std::io; +use crate::records::SIZING_ATTEMPTS; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; use windows_sys::Win32::System::SystemInformation::{ CACHE_RELATIONSHIP, CacheData, CacheInstruction, CacheTrace, CacheUnified, GROUP_AFFINITY, @@ -126,46 +131,58 @@ pub(crate) enum Record { /// /// Returns any error from `GetLogicalProcessorInformationEx`. pub(crate) fn enumerate() -> io::Result<(Vec, Vec)> { - let mut length: u32 = 0; - // SAFETY: a null buffer and a valid `length` out-pointer. Documented to - // fail with `ERROR_INSUFFICIENT_BUFFER` and report the required size in - // `length`, writing nothing through the null buffer pointer. - let probe = unsafe { - GetLogicalProcessorInformationEx(RelationAll, std::ptr::null_mut(), &raw mut length) - }; - if probe != 0 { - // Documented to fail on the sizing call; succeeding would mean zero - // bytes were needed, i.e. nothing to report. - return Ok((Vec::new(), Vec::new())); - } - let error = io::Error::last_os_error(); - if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { - return Err(error); - } + // **Sized and fetched in a bounded loop**, because the machine can change + // between the two calls -- see `SIZING_ATTEMPTS` for why a second + // `ERROR_INSUFFICIENT_BUFFER` is retried rather than returned. + let mut grew = None; + for _ in 0..SIZING_ATTEMPTS { + let mut length: u32 = 0; + // SAFETY: a null buffer and a valid `length` out-pointer. Documented to + // fail with `ERROR_INSUFFICIENT_BUFFER` and report the required size in + // `length`, writing nothing through the null buffer pointer. + let probe = unsafe { + GetLogicalProcessorInformationEx(RelationAll, std::ptr::null_mut(), &raw mut length) + }; + if probe != 0 { + // Documented to fail on the sizing call; succeeding would mean zero + // bytes were needed, i.e. nothing to report. + return Ok((Vec::new(), Vec::new())); + } + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { + return Err(error); + } - // `u64`-backed storage guarantees 8-byte alignment for every record's - // header and for the `usize`-sized fields inside its trailing arrays, - // regardless of what a `Vec` allocation would have happened to give. - let mut storage = vec![0_u64; length.div_ceil(8) as usize]; - let buffer = storage.as_mut_ptr().cast::(); - let mut actual_length = length; - // SAFETY: `buffer` points to `storage`, whose byte length is at least - // `length` (the value the sizing call just reported) and 8-byte aligned; - // `actual_length` is a valid in/out length pointer. - let ok = unsafe { - GetLogicalProcessorInformationEx(RelationAll, buffer.cast(), &raw mut actual_length) - }; - if ok == 0 { - return Err(io::Error::last_os_error()); + // `u64`-backed storage guarantees 8-byte alignment for every record's + // header and for the `usize`-sized fields inside its trailing arrays, + // regardless of what a `Vec` allocation would have happened to give. + let mut storage = vec![0_u64; length.div_ceil(8) as usize]; + let buffer = storage.as_mut_ptr().cast::(); + let mut actual_length = length; + // SAFETY: `buffer` points to `storage`, whose byte length is at least + // `length` (the value the sizing call just reported) and 8-byte aligned; + // `actual_length` is a valid in/out length pointer. + let ok = unsafe { + GetLogicalProcessorInformationEx(RelationAll, buffer.cast(), &raw mut actual_length) + }; + if ok != 0 { + // SAFETY: `buffer` now holds `actual_length` bytes written by the + // call above: zero or more consecutive + // `SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX` records whose `Size` + // fields sum to `actual_length`, per the API's own contract. + return Ok(unsafe { decode(buffer.cast_const(), actual_length) }); + } + + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) { + return Err(error); + } + // The machine grew between the sizing call and this one. Size again. + grew = Some(error); } - // SAFETY: `buffer` now holds `actual_length` bytes written by the call - // above: zero or more consecutive `SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX` - // records whose `Size` fields sum to `actual_length`, per the API's own - // contract. - Ok(unsafe { decode(buffer.cast_const(), actual_length) }) + Err(grew.expect("SIZING_ATTEMPTS is a non-zero constant, so the loop ran at least once")) } - const RELATIONSHIP_OFFSET: usize = core::mem::offset_of!(SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX, Relationship); const SIZE_OFFSET: usize = core::mem::offset_of!(SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX, Size); From 4833c4b02a818ae2dc64c5c19d2cdcc89b51dc66 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 19:20:17 -0400 Subject: [PATCH 336/361] fix(placement-probe): stop linking into a feature-gated module from ungated prose The "windows-placement-probe (no serde feature)" CI job has been red since e451b40. REPOSITORY_URL's doc comment linked to `crate::submission::DISCUSSION_URL` with an intra-doc link, and `submission` is gated behind `serde` while `report` is not -- so the link resolved under --all-features and dangled under --no-default-features, which is the configuration this crate's manifest advertises and that job exists to protect. The sentence doing the linking was itself explaining that the target is gated and this module is not, which is the part worth recording: I wrote the reason down correctly and then did the thing it warned against in the same clause. The name is now inline code, with a note saying why it is not a link. That job's own comment predicted this exactly -- "a link from ungated prose into a now-gated item resolves there and dangles here" -- so the guard worked and the gap was mine for not running the configuration locally. Two causes overlapped across those six commits, which is why fixing one did not turn the job green. M36.2 broke both `rustdoc (intra-doc links)` and this job with the machine.rs links; 14217d5 fixed those, clearing the first job while this one stayed red on REPOSITORY_URL, added by M36.4 in between. Verified by running all four of the job's steps locally under --no-default-features (build, clippy, test, doc), and re-running the --all-features gate so one configuration is not fixed by breaking the other. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/src/report.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/windows-placement-probe/src/report.rs b/crates/windows-placement-probe/src/report.rs index 1745140f..d96030fa 100644 --- a/crates/windows-placement-probe/src/report.rs +++ b/crates/windows-placement-probe/src/report.rs @@ -31,9 +31,15 @@ use crate::record::SubmissionRecord; /// with rather than being told which one this is. /// /// Deliberately its own constant rather than reusing -/// [`submission::DISCUSSION_URL`](crate::submission::DISCUSSION_URL), which is -/// gated behind the `serde` feature that this module is not; a test pins that -/// the two agree about the repository so the pair cannot drift apart silently. +/// `submission::DISCUSSION_URL`, which is gated behind the `serde` feature that +/// this module is not; a test pins that the two agree about the repository so +/// the pair cannot drift apart silently. +/// +/// **That name is inline code and not an intra-doc link, for the same reason +/// the constant exists.** A link resolves under `--all-features` and dangles +/// under `--no-default-features`, so linking it broke the configuration this +/// crate's manifest advertises -- while the sentence doing the linking was +/// itself explaining that the target is gated and this module is not. pub const REPOSITORY_URL: &str = "https://github.com/MikeGrier/windows-threadpool-sys"; /// Render the report a runner sees. From 5ad082ce454fd491f46fd2d3b2f84676ba6c4ce6 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 19:51:56 -0400 Subject: [PATCH 337/361] fix(topology): stop the long-path probe leaking, and refuse anomalies from a document Two findings from Copilot review 5118406119, a headline-only round. Both real. The long-path probe leaked process state and a temporary tree. `measure` called SetCurrentDirectoryW and never moved back, and built a 40-deep tree under %TEMP% that nothing removed. Neither is excused by the probe binaries exiting straight afterwards: this is a library function, so a test or any other caller keeps running in a process whose current directory now points into a temp directory. Seven stale trees were sitting on this machine, which is how the leak was confirmed rather than argued. The tree is the sharper half. It is deliberately longer than MAX_PATH, which is exactly what stops Explorer and `del` from removing it -- litter from a probe about long paths is litter that is awkward to clear by hand. Both are now released by an `Apparatus` guard, so the early returns on every apparatus failure clean up too. Restoring the directory before removing the tree is load-bearing rather than tidy: a process's current directory holds a handle on it, so removal while parked inside silently fails. Sabotage confirmed exactly that -- deleting only the restore also broke the tree test. Writing the tests found a third thing: `measure` cannot be called concurrently. It borrows the current directory, which is one per process, so a unique root per call would not fix it. The three tests collided until they took a lock, and `measure` now documents the constraint instead of leaving the next caller to discover it. Deserialization asserted enumeration anomalies into a restored topology. `enumeration_anomalies` was `serde(default)` while its own first paragraph has always said the list is "empty for a hand-built or deserialized topology, which asked nothing" -- a contract contradicted by its own attribute. An anomaly is a fact about an enumeration and a deserialized topology performed none, which is the same reasoning that already made `coherence` skip_deserializing. It now does too, and is still written out, so a dump still carries the diagnosis. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-platform-probes/src/long_path.rs | 127 ++++++++++++++++-- .../src/long_path/tests.rs | 109 +++++++++++++++ crates/windows-topology-sys/src/topology.rs | 15 ++- .../src/topology/tests.rs | 57 ++++++++ 4 files changed, 299 insertions(+), 9 deletions(-) create mode 100644 crates/windows-platform-probes/src/long_path/tests.rs diff --git a/crates/windows-platform-probes/src/long_path.rs b/crates/windows-platform-probes/src/long_path.rs index 45dd8596..40466160 100644 --- a/crates/windows-platform-probes/src/long_path.rs +++ b/crates/windows-platform-probes/src/long_path.rs @@ -55,7 +55,7 @@ use windows_sys::Win32::Storage::FileSystem::{ // `SetCurrentDirectoryW` lives under Environment rather than FileSystem, // because the current directory is per-process environment state rather than a // file operation. -use windows_sys::Win32::System::Environment::SetCurrentDirectoryW; +use windows_sys::Win32::System::Environment::{GetCurrentDirectoryW, SetCurrentDirectoryW}; /// Windows's classic path ceiling. const MAX_PATH: usize = 260; @@ -287,11 +287,121 @@ fn attempt(current_dir_len: usize, depth: usize, shape: Shape) -> Attempt { } } +/// This process's current directory, as a null-terminated wide string. +/// +/// Sized then fetched, which is this API's documented shape: a zero length with +/// a null buffer returns the size *including* the terminator, and the filling +/// call returns the count *excluding* it. +fn current_directory() -> Result, String> { + // SAFETY: the documented sizing form -- a zero length with a null buffer, + // which writes nothing and returns the required size. + let needed = unsafe { GetCurrentDirectoryW(0, std::ptr::null_mut()) }; + if needed == 0 { + // SAFETY: called immediately after the failing call. + return Err(format!("GetCurrentDirectoryW sizing failed: {}", unsafe { + GetLastError() + })); + } + let mut buffer = vec![0_u16; needed as usize]; + // SAFETY: `buffer` has `needed` elements, which is the size the call above + // asked for, and is writable for that length. + let written = unsafe { GetCurrentDirectoryW(needed, buffer.as_mut_ptr()) }; + if written == 0 || written >= needed { + // SAFETY: called immediately after the failing call. + return Err(format!("GetCurrentDirectoryW failed: {}", unsafe { + GetLastError() + })); + } + Ok(buffer) +} + +/// The temporary tree and the process state this experiment borrows. +/// +/// **A guard, because both are leaks if `measure` returns early**, and it +/// returns early on every apparatus failure. This is a library function, so +/// neither is excused by the probe binaries exiting straight afterwards: a test +/// or any other caller keeps running in the process whose current directory was +/// moved. +/// +/// The tree is the sharper of the two. It is deliberately longer than +/// `MAX_PATH`, which is the very property that stops Explorer and `del` from +/// removing it -- so litter left in `%TEMP%` by a probe about long paths is +/// litter that is hard to clear up by hand. +struct Apparatus { + root: PathBuf, + /// Where the process was before [`Self::enter`], if it moved at all. + previous_directory: Option>, +} + +impl Apparatus { + fn new(root: PathBuf) -> Self { + Self { + root, + previous_directory: None, + } + } + + /// Move the process into `directory`, remembering where it was. + fn enter(&mut self, directory: &Path) -> Result<(), String> { + // Captured *before* the move, or there is nothing to go back to. + let previous = current_directory()?; + let wide = wide(directory.as_os_str()); + // SAFETY: `wide` is a live null-terminated buffer for the call. + if unsafe { SetCurrentDirectoryW(wide.as_ptr()) } == 0 { + // SAFETY: called immediately after the failing call. + return Err(format!("SetCurrentDirectoryW failed: {}", unsafe { + GetLastError() + })); + } + self.previous_directory = Some(previous); + Ok(()) + } +} + +impl Drop for Apparatus { + fn drop(&mut self) { + // **Restore the directory first, and that ordering is load-bearing.** + // A process's current directory holds a handle on it, so removing the + // tree while parked inside it fails -- the cleanup would silently do + // nothing and leave exactly the litter this guard exists to prevent. + if let Some(previous) = &self.previous_directory { + // SAFETY: `previous` is the null-terminated buffer + // `GetCurrentDirectoryW` filled, still live here. + unsafe { SetCurrentDirectoryW(previous.as_ptr()) }; + } + + // By verbatim path, for the same reason the tree was built by one: the + // deep branch is past `MAX_PATH`, so an ordinary path would fail to + // reach it on a host that has not opted in -- which is half the hosts + // this probe is meant to run on. + let verbatim = PathBuf::from(format!(r"\\?\{}", self.root.display())); + // Best-effort: a failure here leaves litter, which is worth neither a + // panic in a `Drop` nor a field on an observation about path lengths. + let _ = std::fs::remove_dir_all(&verbatim); + } +} + /// Run the experiment. /// /// `manifest_aware` is what the *caller* knows about its own manifest -- the /// process cannot ask Windows whether it opted in, so the two binaries pass /// their own answer and are named for it. +/// +/// The temporary tree and the current directory are both restored before this +/// returns, on every path including the apparatus failures -- see `Apparatus`. +/// +/// # Not safe to call concurrently +/// +/// This borrows **process-wide** state: it moves the current directory, and it +/// builds its tree under a root named after the process id. Two calls at once +/// in one process share both -- one would remove the tree the other was still +/// using, and they would fight over the directory. A unique root per call would +/// not fix that, because there is one current directory per process however the +/// trees are named. +/// +/// The probe binaries call this once and exit, so this costs them nothing. A +/// caller running it from a test suite must serialize its calls; the tests +/// beside this module do exactly that. #[must_use] pub fn measure(manifest_aware: bool) -> Observation { let mut observation = Observation { @@ -306,6 +416,9 @@ pub fn measure(manifest_aware: bool) -> Observation { observation.apparatus_error = Some(error); return observation; } + // From here on the tree exists, so every exit below has something to clean + // up -- including the early returns, which is why this is a guard. + let mut apparatus = Apparatus::new(root.clone()); // Deep enough that the resolved path clears `MAX_PATH` with room to spare, // and shallow enough that the short case stays well under it. @@ -338,13 +451,8 @@ pub fn measure(manifest_aware: bool) -> Observation { // The current directory is the short root for every attempt, so the length // under test lives in the relative path rather than in the cwd. - let root_wide = wide(root.as_os_str()); - // SAFETY: `root_wide` is a live null-terminated buffer for the call. - if unsafe { SetCurrentDirectoryW(root_wide.as_ptr()) } == 0 { - // SAFETY: called immediately after the failing call. - observation.apparatus_error = Some(format!("SetCurrentDirectoryW failed: {}", unsafe { - GetLastError() - })); + if let Err(error) = apparatus.enter(&root) { + observation.apparatus_error = Some(error); return observation; } let current_dir_len = root.as_os_str().len(); @@ -370,3 +478,6 @@ pub fn measure(manifest_aware: bool) -> Observation { pub fn is_refusal(attempt: &Attempt) -> bool { !attempt.opened && matches!(attempt.error, ERROR_PATH_NOT_FOUND | ERROR_FILE_NOT_FOUND) } + +#[cfg(test)] +mod tests; diff --git a/crates/windows-platform-probes/src/long_path/tests.rs b/crates/windows-platform-probes/src/long_path/tests.rs new file mode 100644 index 00000000..0575139f --- /dev/null +++ b/crates/windows-platform-probes/src/long_path/tests.rs @@ -0,0 +1,109 @@ +// Copyright (c) 2026 Mike Grier +//! Tests for [`measure`](super::measure)'s apparatus. +//! +//! **These assert what the experiment gives back, not what it found.** What it +//! finds is a fact about the host -- whether the manifest and the registry +//! setting lift `MAX_PATH` -- and asserting that here would encode this +//! machine's configuration into the suite. What must hold on every host is that +//! running the experiment leaves the process and the disk as it found them. +//! +//! # Why these serialize +//! +//! `measure` borrows two pieces of **process-wide** state: the current +//! directory, and a temporary root named after the process id. This crate's +//! tests run as threads in one process, so two of these running at once would +//! share both -- one call removing the tree another was still using, and the +//! two fighting over the current directory. +//! +//! That is a property of `measure` rather than a defect in it, and the fix is +//! not a per-call unique root: the current directory is one per process however +//! the directories are named, so concurrent calls could not work whatever the +//! tree was called. See `measure`'s own documentation. These tests therefore +//! take a lock, which is also what a consumer would have to do. +//! +//! Found by writing the third test below, which failed until the lock existed. + +use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard}; + +use super::measure; + +/// Serializes the tests here, for the reason in this module's documentation. +static APPARATUS: Mutex<()> = Mutex::new(()); + +/// Take the lock, ignoring poisoning. +/// +/// A panic in one of these tests leaves the mutex poisoned, which would turn +/// one real failure into three and hide which was the original. +fn exclusive() -> MutexGuard<'static, ()> { + APPARATUS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Where this process's current directory points. +fn current_directory() -> PathBuf { + std::env::current_dir().expect("the test process must have a current directory") +} + +/// The tree `measure` builds, named as it names it. +fn apparatus_root() -> PathBuf { + std::env::temp_dir().join(format!("long-path-probe-{}", std::process::id())) +} + +#[test] +fn measuring_leaves_the_current_directory_where_it_found_it() { + // **The leak that matters most in a library.** `measure` moves the process + // into its temporary root so the length under test lives in the relative + // path rather than in the current directory. The process is shared, so + // failing to move back would silently re-root every later relative path in + // whatever called this -- a test, or any other consumer. + let _lock = exclusive(); + let before = current_directory(); + + let _ = measure(false); + + assert_eq!( + current_directory(), + before, + "the experiment left the process parked somewhere else" + ); +} + +#[test] +fn measuring_removes_the_tree_it_built() { + // The tree is deliberately deeper than `MAX_PATH`, which is exactly what + // stops Explorer and `del` from clearing it up -- so litter from a probe + // about long paths is litter that is awkward to remove by hand. + let _lock = exclusive(); + + let _ = measure(false); + + assert!( + !apparatus_root().exists(), + "the experiment left its temporary tree behind at {}", + apparatus_root().display() + ); +} + +#[test] +fn the_apparatus_is_cleaned_up_even_when_the_experiment_is_run_twice() { + // Two calls in one process, which is the shape a test run takes and the + // shape the probe binaries never do. The second rebuilds a tree under the + // same name the first removed, so a first run that left its tree behind + // would surface here -- as an apparatus error rather than a silent leak. + let _lock = exclusive(); + let before = current_directory(); + + let first = measure(false); + let second = measure(false); + + assert_eq!(first.apparatus_error, None, "first run"); + assert_eq!(second.apparatus_error, None, "second run"); + assert!( + !apparatus_root().exists(), + "{} survived", + apparatus_root().display() + ); + assert_eq!(current_directory(), before); +} diff --git a/crates/windows-topology-sys/src/topology.rs b/crates/windows-topology-sys/src/topology.rs index e9a9bce4..cc74d193 100644 --- a/crates/windows-topology-sys/src/topology.rs +++ b/crates/windows-topology-sys/src/topology.rs @@ -193,7 +193,20 @@ pub struct MachineMemoryTopology { /// leave a consumer unable to tell a truncated enumeration from a small /// machine. Whatever decoded before the anomaly is still present in the /// fields above and is still correct. - #[cfg_attr(feature = "serde", serde(default))] + /// + /// **Written out, never read back in**, for exactly the reason + /// [`Self::coherence`] is not: an anomaly is a fact about *an enumeration*, + /// and a deserialized topology performed none. A description is welcome to + /// carry these so a human reading a dump can see how the run that produced + /// it went, but a file cannot *establish* that a buffer walk hit a + /// malformed record any more than it can establish that the walk observed + /// anything at all (D-12). + /// + /// This was `serde(default)` until 2026-09-04, which let a document assert + /// anomalies into a topology that had asked nothing -- contradicting the + /// first paragraph of this very comment, which has always said the list is + /// empty for a deserialized topology. + #[cfg_attr(feature = "serde", serde(skip_deserializing))] pub enumeration_anomalies: Vec, /// Whether the two Win32 sources described the same machine when this was /// collected. diff --git a/crates/windows-topology-sys/src/topology/tests.rs b/crates/windows-topology-sys/src/topology/tests.rs index f713afb6..b1b419a0 100644 --- a/crates/windows-topology-sys/src/topology/tests.rs +++ b/crates/windows-topology-sys/src/topology/tests.rs @@ -432,6 +432,63 @@ mod serde_tests { ); } + #[test] + #[cfg(feature = "serde")] + fn a_document_cannot_assert_enumeration_anomalies_into_a_restored_topology() { + // An anomaly is a fact about *an enumeration*, and a deserialized + // topology performed none -- the same reason `coherence` is not read + // back. The field's own documentation has always said the list is empty + // for a deserialized topology; `serde(default)` let a document say + // otherwise, so the contract was contradicted by its own attribute. + let document = r#"{ + "processors": [], + "domains": [], + "cpu_sets": [], + "provenance": "measured", + "enumeration_anomalies": [ + { + "source": "relationship_walk", + "offset": 64, + "kind": { "TrailingBytes": { "remaining": 3 } } + } + ] + }"#; + + let restored: MachineMemoryTopology = + serde_json::from_str(document).expect("the document must still deserialize"); + + assert!( + restored.enumeration_anomalies.is_empty(), + "a restored topology claimed anomalies from an enumeration it never ran: {:?}", + restored.enumeration_anomalies + ); + // The neighbouring guarantee, asserted here so the two cannot drift: + // both fields describe a collection, and neither survives a round trip. + assert_eq!(restored.coherence, Coherence::NotCollected); + } + + #[test] + #[cfg(feature = "serde")] + fn enumeration_anomalies_are_still_written_out() { + // Skipping the *deserialize* side only. A dump is where a human reads + // how the run that produced it went, so dropping these from the output + // would lose the diagnosis this field exists to carry. + let mut topology = MachineMemoryTopology::default(); + topology.enumeration_anomalies.push(EnumerationAnomaly { + source: Source::RelationshipWalk, + offset: 64, + kind: crate::AnomalyKind::TrailingBytes { remaining: 3 }, + }); + + let text = serde_json::to_string(&topology).expect("serialize"); + + assert!( + text.contains("enumeration_anomalies"), + "the anomaly must survive into the document: {text}" + ); + assert!(text.contains("64"), "got {text}"); + } + #[test] #[cfg(feature = "serde")] fn coherence_serializes_in_the_lowercase_spelling_the_rest_of_this_crate_uses() { From 231750b3d6b24ea374f04646038c379499c1225e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 20:10:12 -0400 Subject: [PATCH 338/361] fix(topology): count the long path in UTF-16 units, and make the Provenance sweep exhaustive Three findings from the unresolved PR #56 review threads. The long-path probe measured against MAX_PATH in the wrong unit. `OsStr::len` counts Rust's platform encoding, which is WTF-8; MAX_PATH counts UTF-16 code units. The two agree for ASCII and diverge for anything else, so a %TEMP% with a non-ASCII character made `resolved_len` too large and could report an attempt on the wrong side of the ceiling -- in a probe whose entire output is which side of that ceiling a path landed on. Now counted with `wtf_string::Wtf16String`, which is this workspace's own answer to exactly this question: it holds the string in the encoding Windows uses, so its `len` is the number under test rather than a conversion of one. The relative part is built from ASCII constants and so cannot currently differ, and is measured the same way regardless -- a unit that is only correct while the input happens to be ASCII is one waiting to be wrong. The Provenance pairwise test claimed to be exhaustive and was not. Its comment promised that "a variant added later cannot quietly acquire an upgrade path" while the list was a hand-written array that a new variant would leave untouched. It now comes from a `match`, so the compiler refuses to build until the variant is added -- verified by adding one and confirming the test's own line fails to compile. The topology crate's PLANS.md still advertised M6 as in progress with every M6 item checked, directing a reader at finished work. M6 is archived to COMPLETED-CHECKLIST.md and recorded in COMPLETED-PLANS.md. The `Deferred, and why` section stays in CHECKLIST.md: it is live context about two things left out on purpose, not completed work, and it rode along in the first move. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../windows-platform-probes/src/long_path.rs | 24 +++++++-- .../src/long_path/tests.rs | 43 +++++++++++++++ crates/windows-topology-sys/CHECKLIST.md | 50 ------------------ .../COMPLETED-CHECKLIST.md | 52 +++++++++++++++++++ .../windows-topology-sys/COMPLETED-PLANS.md | 1 + crates/windows-topology-sys/PLANS.md | 5 +- .../src/provenance/tests.rs | 25 +++++++-- 7 files changed, 141 insertions(+), 59 deletions(-) diff --git a/crates/windows-platform-probes/src/long_path.rs b/crates/windows-platform-probes/src/long_path.rs index 40466160..833cf376 100644 --- a/crates/windows-platform-probes/src/long_path.rs +++ b/crates/windows-platform-probes/src/long_path.rs @@ -56,6 +56,7 @@ use windows_sys::Win32::Storage::FileSystem::{ // because the current directory is per-process environment state rather than a // file operation. use windows_sys::Win32::System::Environment::{GetCurrentDirectoryW, SetCurrentDirectoryW}; +use wtf_string::Wtf16String; /// Windows's classic path ceiling. const MAX_PATH: usize = 260; @@ -114,6 +115,11 @@ pub struct Attempt { /// Total length the call had to resolve: current directory plus the /// relative path. This is the number `MAX_PATH` is compared against, not /// the length of the relative part alone. + /// + /// **In UTF-16 code units**, which is the unit `MAX_PATH` itself is + /// expressed in. Counting Rust's platform encoding instead would disagree + /// the moment a non-ASCII character appeared in the temporary directory's + /// path, and would put an attempt on the wrong side of the ceiling. pub resolved_len: usize, /// Whether that total exceeds `MAX_PATH`. pub over_max_path: bool, @@ -253,8 +259,13 @@ fn relative_path(depth: usize, shape: Shape) -> String { /// directory, with no prefix of any kind. fn attempt(current_dir_len: usize, depth: usize, shape: Shape) -> Attempt { let relative = relative_path(depth, shape); - // Plus one for the separator Windows inserts when it joins the two. - let resolved_len = current_dir_len + 1 + relative.len(); + // Plus one for the separator Windows inserts when it joins the two. Both + // lengths are UTF-16 code units, which is the unit `MAX_PATH` is expressed + // in -- see `current_dir_len`'s construction in `measure`. The relative + // part is built from ASCII constants here, so its two counts agree today; + // it is measured the same way regardless, because a unit that is only + // correct while the input happens to be ASCII is one waiting to be wrong. + let resolved_len = current_dir_len + 1 + Wtf16String::from_os_str(OsStr::new(&relative)).len(); let wide = wide(OsStr::new(&relative)); // SAFETY: `wide` is a live null-terminated buffer for the duration of the // call; the handle, if any, is closed below. @@ -455,7 +466,14 @@ pub fn measure(manifest_aware: bool) -> Observation { observation.apparatus_error = Some(error); return observation; } - let current_dir_len = root.as_os_str().len(); + // **UTF-16 code units, not bytes.** `MAX_PATH` counts what Windows counts, + // and `OsStr::len` counts Rust's platform encoding -- which is WTF-8 here, + // so a non-ASCII character in `%TEMP%` makes the two disagree and can put + // an attempt on the wrong side of the ceiling in the report. `Wtf16String` + // is the workspace's own answer to exactly this: it holds the string in the + // encoding Windows uses, so its `len` is the number under test rather than + // a conversion of one. + let current_dir_len = Wtf16String::from_os_str(root.as_os_str()).len(); for depth in [shallow, deep] { for shape in [Shape::Plain, Shape::DotDot, Shape::ForwardSlash] { diff --git a/crates/windows-platform-probes/src/long_path/tests.rs b/crates/windows-platform-probes/src/long_path/tests.rs index 0575139f..94a5005c 100644 --- a/crates/windows-platform-probes/src/long_path/tests.rs +++ b/crates/windows-platform-probes/src/long_path/tests.rs @@ -23,9 +23,12 @@ //! //! Found by writing the third test below, which failed until the lock existed. +use std::ffi::OsString; use std::path::PathBuf; use std::sync::{Mutex, MutexGuard}; +use wtf_string::Wtf16String; + use super::measure; /// Serializes the tests here, for the reason in this module's documentation. @@ -107,3 +110,43 @@ fn the_apparatus_is_cleaned_up_even_when_the_experiment_is_run_twice() { ); assert_eq!(current_directory(), before); } + +#[test] +fn the_resolved_length_is_counted_in_the_unit_max_path_uses() { + // **The bug this guards is invisible on an ASCII host**, which is every + // machine this has run on so far. `MAX_PATH` counts UTF-16 code units; + // `OsStr::len` counts Rust's platform encoding, which is WTF-8 here. The + // two agree for ASCII and diverge for anything else, so a `%TEMP%` with a + // non-ASCII character made the reported length too large and could push an + // attempt onto the wrong side of the ceiling in the report. + // + // Asserted on the encoding rather than through `measure`, because the fault + // needs a non-ASCII temporary directory that this test cannot conjure -- + // and pinning the relationship is what actually stops the regression. + let ascii = OsString::from("C:\\Temp"); + assert_eq!( + Wtf16String::from_os_str(&ascii).len(), + ascii.len(), + "the two units must agree for ASCII, or this test proves nothing below" + ); + + // U+00E9 is one UTF-16 unit and two WTF-8 bytes; U+4E2D is one and three. + // A path Windows sees as shorter than `MAX_PATH` can therefore look longer + // when measured in bytes -- the direction that matters, since it would + // report a refusal as expected when it was not. + for accented in ["C:\\Tempé", "C:\\Temp中"] { + let path = OsString::from(accented); + let wide = Wtf16String::from_os_str(&path).len(); + + assert!( + wide < path.len(), + "{accented:?} must expose the divergence: {wide} wide vs {} bytes", + path.len() + ); + assert_eq!( + wide, + accented.chars().count(), + "every character here is one UTF-16 unit, so the wide count is the character count" + ); + } +} diff --git a/crates/windows-topology-sys/CHECKLIST.md b/crates/windows-topology-sys/CHECKLIST.md index 922339bd..d72db2ca 100644 --- a/crates/windows-topology-sys/CHECKLIST.md +++ b/crates/windows-topology-sys/CHECKLIST.md @@ -7,56 +7,6 @@ enumeration plan that preceded it. Cite item IDs (`MMT-1.1`, `M4+.1`, `M5+.4`, . Decisions live in [DESIGN-NOTES.md](DESIGN-NOTES.md), which is the authority for current behaviour; the archived checklist records what was *done*, not what is *true now*. -## M6: one record walk, per D-24 - -Opened 2026-09-03 by the PR #56 diff review (`SH-3.1.1`), which found the crate''s two record -decoders internally coherent and mutually opposite. [D-24](DESIGN-NOTES.md#d-24) is the ruling this -milestone implements: **one shared walk, no panic, incoherence recorded in the returned data, and no -trust boundary** -- the OS is trusted for structural validity, and the careful walk is simply how -variable-length records are traversed correctly. - -**All five landed in one commit, and the split was wrong.** M6.1 produces anomalies, so it cannot -compile without M6.2''s type; neither can be warning-free until M6.3/M6.4 give them a consumer; and -M6.4 changes `enumerate`''s signature, which is what M6.5 surfaces. They are one coupled change and -are recorded as such rather than teased into a fiction of five commits. - -**One measurement changed the design.** The obvious minimum record size for the relationship walk is -`size_of::()` -- and it is **wrong**. That struct is 80 -bytes because its union is as large as `GROUP_RELATIONSHIP` (72), while a real processor-core record -is 8 + 40 = 48. Using it would have rejected every processor, cache and NUMA record on every machine. -The minimum is the 8-byte header, and each body bounds its own reads instead. - -**The amplification is closed by construction and witnessed, not argued.** With a 48-byte record -flush against a `PAGE_NOACCESS` page and `GroupCount = 65535`, the unbounded read raises `0xC0000005` -and the record-bounded one returns cleanly. - -- [x] **M6.1** -- **A shared, self-bounding record walk.** New private module: an iterator over a - `Size`-chained record list, parameterised by the offset of the `Size` field and the minimum record - size, yielding a **record view bounded by its own `Size`**. The view''s read accessor returns - nothing when the read would leave the record, so a trailing array cannot be read past the record - that declares it -- the `GroupCount` amplification closes *by construction*, not by a separate - check. Built first and unused; `walk.rs` and `cpu_set.rs` adopt it in M6.3/M6.4. - -- [x] **M6.2** -- **Vocabulary for a record that did not fit, and somewhere for it to live.** A public - anomaly type carrying the [`Source`](src/observation.rs) that was being read, the byte offset, and - what was wrong, plus a new `MachineMemoryTopology` field to carry them. Breaking (the struct has - public fields and is deliberately hand-constructible, so it does not take `#[non_exhaustive]`), - which is free on this branch. `serde(default)` so an existing description still deserializes. - -- [x] **M6.3** -- **Port `cpu_set::decode` to the shared walk.** Its existing checks become the shared - ones; its silent `break` becomes a recorded anomaly. Behaviour for well-formed input is unchanged, - which its five malformed-input tests should confirm without being rewritten. - -- [x] **M6.4** -- **Port `walk::decode` to the shared walk, and delete the `assert!`.** This is the - item with the actual defect in it: a zero `Size` currently panics, `offset + size` is never checked - against the buffer, and `read_group_affinities` reads `GroupCount` x 16 bytes unbounded. All three - resolve into the shared walk. Add the malformed-input tests this file has never had. - Verify the amplification is closed the way `cpu_set`''s was -- a guard-page harness, since the - decoded output is identical either way and no ordinary test can witness it. - -- [x] **M6.5** -- **Surface the anomalies through `discover()`**, and state the policy where a reader - will meet it: the module docs of both walks, which currently say opposite things about trust. - ## Deferred, and why Two things were deliberately left out of the reshape rather than forgotten: diff --git a/crates/windows-topology-sys/COMPLETED-CHECKLIST.md b/crates/windows-topology-sys/COMPLETED-CHECKLIST.md index 006de22e..58bcc1fa 100644 --- a/crates/windows-topology-sys/COMPLETED-CHECKLIST.md +++ b/crates/windows-topology-sys/COMPLETED-CHECKLIST.md @@ -805,3 +805,55 @@ separately and then re-fixed. Two stale statements sweeps found and fixed: the [D-13](DESIGN-NOTES.md#d-13) audit row, and the Linux-comparison summary, which had recorded optional distances as a decision that *held up* -- sound about the schema, and reversed by a ruling about scope. + +## Moved 2026-09-04 -- M6: one record walk, per D-24 + +## M6: one record walk, per D-24 + +Opened 2026-09-03 by the PR #56 diff review (`SH-3.1.1`), which found the crate''s two record +decoders internally coherent and mutually opposite. [D-24](DESIGN-NOTES.md#d-24) is the ruling this +milestone implements: **one shared walk, no panic, incoherence recorded in the returned data, and no +trust boundary** -- the OS is trusted for structural validity, and the careful walk is simply how +variable-length records are traversed correctly. + +**All five landed in one commit, and the split was wrong.** M6.1 produces anomalies, so it cannot +compile without M6.2''s type; neither can be warning-free until M6.3/M6.4 give them a consumer; and +M6.4 changes `enumerate`''s signature, which is what M6.5 surfaces. They are one coupled change and +are recorded as such rather than teased into a fiction of five commits. + +**One measurement changed the design.** The obvious minimum record size for the relationship walk is +`size_of::()` -- and it is **wrong**. That struct is 80 +bytes because its union is as large as `GROUP_RELATIONSHIP` (72), while a real processor-core record +is 8 + 40 = 48. Using it would have rejected every processor, cache and NUMA record on every machine. +The minimum is the 8-byte header, and each body bounds its own reads instead. + +**The amplification is closed by construction and witnessed, not argued.** With a 48-byte record +flush against a `PAGE_NOACCESS` page and `GroupCount = 65535`, the unbounded read raises `0xC0000005` +and the record-bounded one returns cleanly. + +- [x] **M6.1** -- **A shared, self-bounding record walk.** New private module: an iterator over a + `Size`-chained record list, parameterised by the offset of the `Size` field and the minimum record + size, yielding a **record view bounded by its own `Size`**. The view''s read accessor returns + nothing when the read would leave the record, so a trailing array cannot be read past the record + that declares it -- the `GroupCount` amplification closes *by construction*, not by a separate + check. Built first and unused; `walk.rs` and `cpu_set.rs` adopt it in M6.3/M6.4. + +- [x] **M6.2** -- **Vocabulary for a record that did not fit, and somewhere for it to live.** A public + anomaly type carrying the [`Source`](src/observation.rs) that was being read, the byte offset, and + what was wrong, plus a new `MachineMemoryTopology` field to carry them. Breaking (the struct has + public fields and is deliberately hand-constructible, so it does not take `#[non_exhaustive]`), + which is free on this branch. `serde(default)` so an existing description still deserializes. + +- [x] **M6.3** -- **Port `cpu_set::decode` to the shared walk.** Its existing checks become the shared + ones; its silent `break` becomes a recorded anomaly. Behaviour for well-formed input is unchanged, + which its five malformed-input tests should confirm without being rewritten. + +- [x] **M6.4** -- **Port `walk::decode` to the shared walk, and delete the `assert!`.** This is the + item with the actual defect in it: a zero `Size` currently panics, `offset + size` is never checked + against the buffer, and `read_group_affinities` reads `GroupCount` x 16 bytes unbounded. All three + resolve into the shared walk. Add the malformed-input tests this file has never had. + Verify the amplification is closed the way `cpu_set`''s was -- a guard-page harness, since the + decoded output is identical either way and no ordinary test can witness it. + +- [x] **M6.5** -- **Surface the anomalies through `discover()`**, and state the policy where a reader + will meet it: the module docs of both walks, which currently say opposite things about trust. diff --git a/crates/windows-topology-sys/COMPLETED-PLANS.md b/crates/windows-topology-sys/COMPLETED-PLANS.md index 2080b931..090accaf 100644 --- a/crates/windows-topology-sys/COMPLETED-PLANS.md +++ b/crates/windows-topology-sys/COMPLETED-PLANS.md @@ -8,3 +8,4 @@ was finished. Individual milestones are archived in [COMPLETED-CHECKLIST.md](COM |---|---|---|---| | [CHECKLIST.md](CHECKLIST.md) | 2026-08-22 | M1-M4: safe enumeration of Windows processor, cache, and memory topology (a walk-by-`Size`, trailing-array-respecting wrapper over `GetLogicalProcessorInformationEx`), the open-kinded `Domain`/`Topology` description (including a memory domain with no processors, for CXL-shaped systems), JSON serialization behind a default-off `serde` feature with the schema explicitly not semver-covered, and crate documentation plus a worked example printing the host's topology. Unblocks `windows-ioring-sys`'s `M7` (`ring-copy`). | [DESIGN-NOTES.md](DESIGN-NOTES.md) | | [CHECKLIST.md](CHECKLIST.md) | 2026-09-03 | **MMT-*: reshaping the machine memory topology.** Replaced the ladder-of-levels model with **observed connectivity**: relations held as a set with per-relation provenance rather than reduced on insert (`Observation`, `Source`), presence and observation represented as facts rather than inferred (`Observed`, adopted for `memory_bytes` and `cache_domain`), a granularity order with a `minimal_shared` meet, and the pairwise `proximity` query *derived* from an inclusion-ordered partitioning rather than restated -- which removed the third statement of the partitioning rule. Also dropped `distances` and `Domain::id`, folded CPU Sets into the relation set, and recorded per-processor attribute conflicts. Five of the planned items turned out to assert gaps the crate did not have and were closed as already-satisfied or re-planned. Three breaking changes; the crate goes to **0.2.0**. | [DESIGN-NOTES.md](DESIGN-NOTES.md) (`D-13`-`D-23`), [design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md](../../design-sessions/DESIGN-SESSION-2026-09-02-cache-locality-model.md) | +| [CHECKLIST.md](CHECKLIST.md) | 2026-09-04 | **M6: one record walk, per [D-24](DESIGN-NOTES.md#d-24).** The PR #56 diff review found the crate's two record decoders internally coherent and mutually opposite: `cpu_set` bounded every read and stopped on a bad `Size`; `walk` proved one byte, `assert!`ed on a zero `Size`, and read `GroupCount` x 16 bytes unbounded (up to 1,048,560). Resolved by one shared self-bounding walk in [records.rs](src/records.rs) that never panics, records incoherence in the returned data as `enumeration_anomalies`, and draws no trust boundary -- the operating system is relied on for the structural validity of a buffer it just wrote, and careful walking is correct traversal rather than validation. | [DESIGN-NOTES.md](DESIGN-NOTES.md) (`D-24`) | diff --git a/crates/windows-topology-sys/PLANS.md b/crates/windows-topology-sys/PLANS.md index d6a27e6f..c81714fe 100644 --- a/crates/windows-topology-sys/PLANS.md +++ b/crates/windows-topology-sys/PLANS.md @@ -2,6 +2,9 @@ | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| -| [CHECKLIST.md](CHECKLIST.md) | in progress | **M6: one record walk, per [D-24](DESIGN-NOTES.md#d-24).** The PR #56 diff review found the crate's two record decoders internally coherent and mutually opposite: `cpu_set` bounded every read and stopped on a bad `Size`; `walk` proved one byte, `assert!`ed on a zero `Size`, and read `GroupCount` x 16 bytes unbounded (up to 1,048,560). The ruling: one shared self-bounding walk, never panic, incoherence recorded in the returned data, and no trust boundary -- the OS is trusted for structural validity, and careful walking is just correct traversal. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | + +No plan is currently open for this crate. [CHECKLIST.md](CHECKLIST.md) retains only the +`Deferred, and why` section, which records two things left out of the reshape on purpose -- +context for a future reader rather than work anybody is expected to pick up. Completed plans are in [COMPLETED-PLANS.md](COMPLETED-PLANS.md). diff --git a/crates/windows-topology-sys/src/provenance/tests.rs b/crates/windows-topology-sys/src/provenance/tests.rs index d44d47bd..c4868704 100644 --- a/crates/windows-topology-sys/src/provenance/tests.rs +++ b/crates/windows-topology-sys/src/provenance/tests.rs @@ -47,15 +47,30 @@ fn downgrading_leaves_an_equal_or_lower_claim_alone() { ); } +/// Every [`Provenance`] variant. +/// +/// **The `match` is what makes this exhaustive, and it is not decoration.** A +/// bare array claims to cover the type and cannot: adding a variant leaves it +/// unchanged, so it still compiles and silently stops testing the new case -- +/// which is what the comment below used to promise and the code did not keep. +/// Matching on a value makes the compiler refuse to build until the new variant +/// is added here, so the promise is enforced rather than asserted. +fn every_variant() -> [Provenance; 3] { + // The binding is what forces the check; the arms all yield the same list. + match Provenance::Synthetic { + Provenance::Synthetic | Provenance::Restored | Provenance::Measured => [ + Provenance::Synthetic, + Provenance::Restored, + Provenance::Measured, + ], + } +} + #[test] fn downgrading_never_raises_for_any_pair() { // Exhaustive over the whole type, so a variant added later cannot quietly // acquire an upgrade path. - let all = [ - Provenance::Synthetic, - Provenance::Restored, - Provenance::Measured, - ]; + let all = every_variant(); for value in all { for ceiling in all { let result = value.downgraded_to(ceiling); From 82703425f75630f6bb7234fb58d40854d7da1b88 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 20:12:04 -0400 Subject: [PATCH 339/361] docs: record SH-4.13 and SH-4.14 from the PR #56 review threads SH-4.13 groups three unresolved threads with one root: ProcessorSet cannot represent every u8 processor id while Processor::id is public and constructible, so a deserialized or hand-built topology panics in machine_processors() and in granularity's insert. Per-site validation would close the reported path and leave the next open, so the decision is about ProcessorSet's representable range. SH-4.14 records that probe-long-path-aware builds without its manifest on windows-gnu, making the aware/unaware pair measure one configuration while claiming two. Gated on CI building a GNU target, since a fix would otherwise ship unexercised. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 31 +++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index a1fe7965..42678478 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -658,6 +658,37 @@ that previously stood in the way are gone: to the level number that happens to be L3 on today''s hardware. The fix renames the policy as well as changing it, since `byl3` is a user-facing CLI value that would no longer describe what it does. +- [ ] **SH-4.13** -- **`ProcessorSet` cannot represent every `u8` processor id, and the public API + cannot uphold both "every processor" and "no abort".** Raised by Copilot across three unresolved + review threads -- `topology.rs:100`, `topology.rs:946` and `granularity.rs:94` -- which share one + root and are recorded once here. The reviewer''s own phrasing on the third is the clearest statement + of the problem and is quoted deliberately. + `MachineMemoryTopology` and `Processor::id` are **public and constructible**, and the derived + deserializer accepts `number: 255` under `processors` even though `ProcessorSet` rejects the same id + under `domains`. Such a topology then **panics** in `machine_processors()`, and on a 32-bit target a + processor number of 40 panics in `granularity`''s `insert` before the argument guard can answer. + **Not fixable site by site**: validating each lookup closes the path that was reported and leaves + the next one open, because the gap is between what a `Processor` may say and what a `ProcessorSet` + can hold. The decision is which of the two moves -- widen `ProcessorSet` to the full `u8` range, or + make invalid topology data *reportable* rather than fatal (the `Observed`/anomaly shape this crate + already uses for "the platform said something we cannot represent"). + **Precedent, and the reason this is not theoretical:** the proximity panic fixed in `c072a8a` was + the same family -- a panic reachable from public, constructible input -- and it was real. + Whichever way it goes, add the malformed-description case to the deserialization tests, which is the + path that currently has no coverage at all. + +- [ ] **SH-4.14** -- **`probe-long-path-aware` builds without its manifest on `windows-gnu`.** Raised + by Copilot at `crates/windows-platform-probes/build.rs:29`. The resource is embedded only for MSVC, + so on a GNU target the "aware" and "unaware" binaries measure the **same** configuration while their + names and their reports claim opposite intent. + That is worse than failing to build: the pair exists to isolate one variable, and a report stating a + conclusion the binary did not test is a wrong measurement rather than a missing one. **Fail clearly + on non-MSVC** is the preferred answer over embedding the resource for GNU, since the experiment is + meaningless unless the two halves genuinely differ. + **Gated on CI, deliberately.** This workspace builds MSVC targets only, so a GNU fix would ship + unexercised -- the honest sequencing is to add the target to CI first and then make this fail + against it, rather than writing a guard nothing runs. + - [ ] **SH-4.9** -- **`tools/check-publishable.ps1`: three findings with one root.** Its checks are **text searches standing in for structural facts**, which is how a check goes quietly vacuous. An unanchored pattern is satisfied by a *commented-out* assignment, so CI would believe the From 269ee845d2580466ee8b0021d65d0f460772882f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 22:52:57 -0400 Subject: [PATCH 340/361] feat(platform-probes): measure the reserving claim word's apportionment and width Adds three duplicated implementations of `reserving_mpsc`'s claim protocol -- 32/32 and 16/48 over `AtomicU64`, 64/64 over `AtomicU128` -- and measures them through `probe-queue-contention` in both regimes, so `D-37`'s shipping decision has a number it currently lacks. Built as a duplicated path in this crate rather than in `windows-waitable-queues`: the measurement adds no third-party dependency to a publishable crate and cannot disturb the branch being peeled off PR #56. `CW-1.6` owns merge-or-delete. Re-apportioning the bits is free (16/48 tracks 32/32 within noise in both regimes) and moves the SH-14.1 recurrence from 2^32 to 2^48. Widening the word costs 2-3x isolated, growing with contention, but only 5-12% drained -- and the drained figure understates it, because a slower producer earns fewer refusals and refusal retries are inside the timed region. The control caught a defect in the first run: the duplicates had not padded `head` and the claim word onto separate cache lines, reporting 3.7x against the shipping shape on a different scaling curve entirely. Completed item: CW-1.1: Add portable-atomic and record whether AtomicU128 is lock-free on this target Completed item: CW-1.2: Implement the three claim-word layouts as self-contained u64-item queues Completed item: CW-1.3: Wire the three layouts into probe-queue-contention Completed item: CW-1.4: Run the probe and capture the report Completed item: CW-1.5: Record the measurement in DESIGN-NOTES.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 7 + .../CHECKLIST-claim-word-layout.md | 108 +++++ crates/windows-platform-probes/Cargo.toml | 1 + .../windows-platform-probes/DESIGN-NOTES.md | 68 +++ crates/windows-platform-probes/PLANS.md | 1 + .../src/bin/queue_contention.rs | 74 ++++ .../src/claim_layout.rs | 406 ++++++++++++++++++ .../src/claim_layout/tests.rs | 95 ++++ crates/windows-platform-probes/src/lib.rs | 1 + .../src/queue_contention.rs | 261 +++++++++++ 10 files changed, 1022 insertions(+) create mode 100644 crates/windows-platform-probes/CHECKLIST-claim-word-layout.md create mode 100644 crates/windows-platform-probes/src/claim_layout.rs create mode 100644 crates/windows-platform-probes/src/claim_layout/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 92355f4f..b8af5f99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,6 +20,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -239,6 +245,7 @@ dependencies = [ name = "windows-platform-probes" version = "0.0.0" dependencies = [ + "portable-atomic", "windows-namespace-request-sys", "windows-placement-probe", "windows-sys", diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md new file mode 100644 index 00000000..ff76c0db --- /dev/null +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -0,0 +1,108 @@ +# Checklist: claim-word layout measurement + +Measures how the `reserving_mpsc` claim word's bit apportionment and width affect +push throughput, so the shipping crate's layout can be chosen on evidence rather +than on the single 32/32 split it inherited. + +Design decisions land in [DESIGN-NOTES.md](DESIGN-NOTES.md); the decisions this +informs live in the queue crate's +[DESIGN-NOTES.md](../windows-waitable-queues/DESIGN-NOTES.md) (`D-36`, `D-37`). + +## Background + +`reserving_mpsc` packs `reserved` and `position` into one `AtomicU64` because the +claim protocol needs a single compare-and-swap to update both (`D-17`, `D-34`). +The split is 32/32, which caps positions at 2^32 and is the whole source of the +`SH-14.1` recurrence hazard disclosed by `D-36`. + +**The 32/32 split is not forced by the platform.** It follows from a capacity +ceiling of 2^31, because the `reserved` half must be able to hold the entire +capacity. Two independent constraints bound the capacity: + +- ring arithmetic: `capacity <= 2^(POSITION_BITS - 1)` +- packing: `capacity <= 2^(64 - POSITION_BITS) - 1` + +`BOUNDS_MAX` is currently derived from the first alone and the second is only +*asserted*, so widening the position raises the ceiling while shrinking the field +obliged to hold it -- which is why widening trips the assertion instead of +working. Deriving the ceiling as the minimum of both makes asymmetric splits +expressible. + +`D-37` offers only 32/32 and a 128-bit 64/64. The asymmetric middle ground is +unexplored, and it needs no new dependency and no 128-bit exchange. + +## M1: measure the layouts + +The variants are built as a **duplicated, experimental path in this crate**, not +in the queue crate. `windows-platform-probes` is explicitly experiments rather +than components, so the measurement adds no third-party dependency to a +publishable crate and cannot disturb the `windows-waitable-queues` branch being +peeled off PR #56. The merge-or-delete decision is `CW-1.6`. + +- [x] **CW-1.1** -- Add `portable-atomic` with `default-features = false` to this + crate only, and record whether `AtomicU128` exists and is lock-free on + `x86_64-pc-windows-msvc`. `D-37` measured that the `use` statement is itself + the gate; confirm that still holds and note whether the implementation uses a + compile-time guarantee or runtime detection, because a CPUID branch in the + claim path would be measured as if it were the algorithm's cost. + +- [x] **CW-1.2** -- Implement the three claim-word layouts as self-contained + `u64`-item queues: `narrow` (32/32 over `AtomicU64`, mirroring the shipping + shape), `deep` (16/48 over `AtomicU64`), and `wide` (64/64 over `AtomicU128`). + Hand-written rather than generic over a layout trait, matching the existing + reason `time_isolated_permit` is a line-for-line twin of its neighbour: an + abstraction that might not inline identically would be measured as the + algorithm's cost. `deep` and `wide` must widen `head` and the per-slot + `sequence` to 64 bits, since a sequence narrower than the position aliases and + reintroduces the recurrence on the consumer side. + +- [x] **CW-1.3** -- Wire the three layouts into `probe-queue-contention` as + named shapes in both the isolated and drained regimes, and report them against + the existing `BASELINE_FETCH_ADD` floor. + +- [x] **CW-1.4** -- Run the probe and capture the report. State plainly whether + 32/32 and 16/48 differ: both are one `AtomicU64` exchange and should be + indistinguishable in the claim itself, so a difference is evidence about slot + metadata density rather than about the claim, and no difference is the result + that makes the apportionment free. + +- [x] **CW-1.5** -- Record the measurement in [DESIGN-NOTES.md](DESIGN-NOTES.md) + with the host fingerprint, and raise the finding against the queue crate's + `D-37` so the shipping decision has the number it currently lacks. + +- [ ] **CW-1.6** -- Decide merge-or-delete for the duplicated path: either the + layouts are promoted into `windows-waitable-queues` (which is `M2`) and the + probe keeps only what it needs to compare them, or the experiment is deleted. + Recorded here so a duplicated path cannot become permanent by nobody + returning to it. + +## M2+: expose the apportionment + +> **CROSS-COMPONENT PREREQUISITE:** every item below changes +> `windows-waitable-queues` and is gated on `CW-1.4`'s numbers and on the +> `mikegrier/waitable-queues` peel merging. Parked deliberately, not pending. + +- [ ] **CW-2.1** -- Derive `BOUNDS_MAX` from both constraints rather than from + the ring bound alone, so that widening the position narrows the capacity + ceiling instead of tripping a const assertion. + + **The measurement changed this item's shape.** Deriving the ceiling from both + constraints is not sufficient on its own: a 16-bit `reserved` half would cap + the capacity at 65535, and the probe's own isolated regime wants 2^21. What + makes 16/48 usable is **decoupling the reservation ceiling from the + capacity** -- capping *outstanding reservations* at `MAX_RESERVED` while the + capacity stays bounded only by the ring. That is what + [claim_layout.rs](src/claim_layout.rs) measured, and it is a **contract + change**: the shipping shape promises every slot may be reserved at once, and + this replaces that with a fixed reservation ceiling. A different promise + rather than a broken one, but it must be decided and stated, not slipped in. + +- [ ] **CW-2.2** -- Make the apportionment caller-selectable at compile time, + defaulting to today's behaviour so no existing caller changes. Generic + defaults are permitted on types but not on functions, so the entry points + need deciding rather than assuming. + +- [ ] **CW-2.3** -- Decide whether a 128-bit claim word becomes the default, on + `CW-1.4`'s evidence. This is the question behind `D-37`'s conditional gating, + and the engineer has said 32-bit Windows deployment is not a present concern + -- which changes `D-18`'s premise and must be recorded rather than assumed. diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index 4fe9415d..d78f52c6 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -118,6 +118,7 @@ windows-waitable-queues = { path = "../windows-waitable-queues", features = [ "experimental-permit-claim", ] } wtf-string = { path = "../wtf-string" } +portable-atomic = { version = "1.15.0", default-features = false } [dependencies.windows-sys] version = "0.61.2" diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 954a8b2c..20f966e5 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -660,3 +660,71 @@ change. **It passed everything.** Against the suite now, three tests fail. That seam bought, and it is why the existing `ProcessorPlace` fixtures were kept rather than treated as sufficient: they encode what a test author assumed the conversion produces, which is precisely the thing that cannot catch the conversion being wrong. + +## The claim word's width costs 2-3x in isolation and much less in use + +Measured by `probe-queue-contention` for +[CHECKLIST-claim-word-layout.md](CHECKLIST-claim-word-layout.md) `CW-1.4`, on +one host, `x86_64-pc-windows-msvc`. Three apportionments of `reserving_mpsc`'s +claim word, built as duplicates in [claim_layout.rs](src/claim_layout.rs) so the +shipping crate was not disturbed: 32/32 and 16/48 over `AtomicU64`, and 64/64 +over `AtomicU128`. + +`AtomicU128::is_always_lock_free()` is **true** on this target and +`cfg(target_feature = "cmpxchg16b")` is enabled by default, so the 128-bit +exchange is a compile-time-guaranteed native instruction here and no CPUID +branch was measured as though it were the algorithm. + +| producers | 16/48 vs 32/32 (isolated) | 64/64 vs 32/32 (isolated) | 64/64 vs 32/32 (drained) | +|---|---|---|---| +| 1 | 1.14x | 2.05x | 1.05x | +| 4 | 1.21x | 1.37x | 1.12x | +| 8 | 1.00x | 2.33x | 1.07x | +| 16 | 0.88x | 2.37x | 1.00x | +| 32 | 0.98x | 2.99x | 1.11x | + +**Re-apportioning the bits is free.** 16/48 tracks 32/32 within noise in both +regimes, which is the expected result and worth stating as a confirmed +prediction rather than a discovery: both issue the same `lock cmpxchg` on the +same `u64`, so only the shift and mask constants differ. The 48-bit position +does force `head` and the per-slot `sequence` to 64 bits, and that cost does not +show up either. What this buys is the recurrence moving from 2^32 to 2^48 -- +from about 37 seconds of sustained maximum-rate pushing to about 28 days. + +**Widening the word is not free, and how much it costs depends entirely on the +regime.** Isolated, where the claim is the only thing happening, `cmpxchg16b` +costs 2-3x and the penalty *grows* with contention. Drained, with a consumer +running, it is 5-12%. + +### The drained regime flatters the slower layout, and the refusal counts say so + +The two regimes must not be averaged, and the drained one must not be read as +the answer on its own. **A slower producer is less backpressured**, so it earns +fewer refusals, and refusal retries are inside the timed region. At eight +producers the 64/64 layout took 12,149 refusals against 32/32's 74,181 -- so +part of what makes its per-push number look close is that it spent less time +being turned away. The drained figures are therefore an *understatement* of the +128-bit word's cost, not a measurement of it under load. + +The isolated regime is the clean measurement of the claim itself; the drained +one shows that in a queue doing real work the claim is not the dominant cost. A +real application sits between them, nearer the drained end the more +consumer-bound it is. + +### What the control caught + +The first run reported 3.7x against the shipping shape and a completely +different scaling curve. The cause was that the duplicate had not padded `head` +and the claim word onto separate cache lines, which `reserving_mpsc` does +deliberately -- every producer reads `head` on every push, so sharing a line +puts the consumer's writes in their path. Aligned, the duplicate tracks the +shipping shape's curve. + +A residual gap remains: the duplicate runs about 1.26x slower than +`reserving_mpsc` at high producer counts. That offset applies equally to all +three layouts, so the ratios above stand, but it means these figures are **not** +absolute numbers for the shipping shape and must not be quoted as such. + +**Comparing a duplicate against the original it stands in for is what made both +of these visible.** A run of three layouts that agreed with each other and +disagreed with reality would have looked entirely healthy. diff --git a/crates/windows-platform-probes/PLANS.md b/crates/windows-platform-probes/PLANS.md index 6e557be8..a88b1e68 100644 --- a/crates/windows-platform-probes/PLANS.md +++ b/crates/windows-platform-probes/PLANS.md @@ -4,4 +4,5 @@ Design decisions are in [DESIGN-NOTES.md](DESIGN-NOTES.md). | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| +| [CHECKLIST-claim-word-layout.md](CHECKLIST-claim-word-layout.md) | in progress | Measure how the `reserving_mpsc` claim word's bit apportionment (32/32 vs 16/48) and width (64 vs 128) affect push throughput, so the shipping layout is chosen on evidence. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | | [../../CHECKLIST-thread-ambient.md](../../CHECKLIST-thread-ambient.md) | in progress | M27: create the crate, migrate this session's probes into it under the three-tier scheme, and queue migration of the nine earlier measurements that still live only in git-ignored scratch. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | diff --git a/crates/windows-platform-probes/src/bin/queue_contention.rs b/crates/windows-platform-probes/src/bin/queue_contention.rs index ab29ad64..6a767537 100644 --- a/crates/windows-platform-probes/src/bin/queue_contention.rs +++ b/crates/windows-platform-probes/src/bin/queue_contention.rs @@ -165,6 +165,80 @@ fn render() -> String { " one; above 1.00 and closing the hole costs throughput." ); + // Question 3: what does the claim word's apportionment and width cost? + let _ = writeln!(out, "\n 3. claim-word layout\n"); + let _ = writeln!( + out, + " 32/32 is what ships; 16/48 is the same u64 exchange with the bits" + ); + let _ = writeln!( + out, + " apportioned differently; 64/64 is a u128 exchange (cmpxchg16b)." + ); + let _ = writeln!( + out, + " 32/32 and 16/48 issue the SAME instruction, so a difference" + ); + let _ = writeln!( + out, + " between them prices the wider head and per-slot sequence the" + ); + let _ = writeln!( + out, + " deeper position forces, not the claim. 64/64 vs 32/32 prices the" + ); + let _ = writeln!( + out, + " double-width exchange -- the cost of removing the wrap entirely.\n" + ); + for (label, regime) in [ + ("isolated", &observation.isolated), + ("drained", &observation.drained), + ] { + let _ = writeln!(out, " -- {label} --"); + let _ = writeln!( + out, + " {:<12} {:>12} {:>12} {:>12} {:>12} {:>12}", + "producers", "32/32 ns", "16/48 ns", "64/64 ns", "16/48 vs", "64/64 vs" + ); + for &producers in PRODUCER_COUNTS { + let narrow = observation.find(regime, shapes::CLAIM_NARROW, producers); + let deep = observation.find(regime, shapes::CLAIM_DEEP, producers); + let wide = observation.find(regime, shapes::CLAIM_WIDE, producers); + let _ = writeln!( + out, + " {:<12} {:>12} {:>12} {:>12} {:>12} {:>12}", + producers, + format_nanos(narrow), + format_nanos(deep), + format_nanos(wide), + format_ratio(deep, narrow), + format_ratio(wide, narrow) + ); + } + let _ = writeln!(out); + } + let _ = writeln!( + out, + " the shipping reserving_mpsc row above is the control: 32/32 here" + ); + let _ = writeln!( + out, + " is a duplicate of it, so the two should agree. They will not match" + ); + let _ = writeln!( + out, + " exactly -- the duplicate carries no metrics, doorbell, or" + ); + let _ = writeln!( + out, + " disconnection checks -- but a large gap means the duplicate is not" + ); + let _ = writeln!( + out, + " standing in faithfully and the comparison below is not trustworthy." + ); + let _ = writeln!( out, "\n CAUTION: the drained regime has ONE consumer, because that is what" diff --git a/crates/windows-platform-probes/src/claim_layout.rs b/crates/windows-platform-probes/src/claim_layout.rs new file mode 100644 index 00000000..5dd8fc90 --- /dev/null +++ b/crates/windows-platform-probes/src/claim_layout.rs @@ -0,0 +1,406 @@ +// Copyright (c) Mike Grier. + +//! Three apportionments of the reserving claim word, for measurement. +//! +//! **An experiment, not a component.** These are deliberately duplicated +//! implementations of `windows-waitable-queues`' `reserving_mpsc` claim +//! protocol, built here so the shipping crate is not disturbed while the +//! layouts are compared. See CHECKLIST-claim-word-layout.md; the +//! merge-or-delete decision is `CW-1.6`. +//! +//! The protocol is the shipping one: producers claim a position by advancing a +//! packed `(reserved, position)` word with one compare-and-swap, then wait to +//! observe a `head` that has passed the slot's previous occupant before +//! writing it. Only the word's width and split differ between the three. +//! +//! | Layout | Word | reserved / position | Recurrence at | +//! |---|---|---|---| +//! | [`narrow`] | `u64` | 32 / 32 | 2^32 pushes | +//! | [`deep`] | `u64` | 16 / 48 | 2^48 pushes | +//! | [`wide`] | `u128` | 64 / 64 | 2^64 pushes | +//! +//! **`deep` decouples the reservation ceiling from the capacity.** The shipping +//! shape requires the `reserved` half to hold the entire capacity, because +//! every slot may be reserved at once; that is what makes a 2^31 capacity +//! ceiling consume 32 bits. Capping *outstanding reservations* at 65535 while +//! leaving the capacity bounded only by the ring lets the position keep 48 +//! bits. Reservations exist for messages that must not be lost, so a ceiling +//! far below the capacity is a different promise rather than a broken one -- +//! but it is a contract change, which is why it is measured before it is +//! proposed. +//! +//! Hand-written three times rather than made generic over a layout trait, for +//! the reason `time_isolated_permit` is a line-for-line twin of its neighbour: +//! an abstraction that might not inline identically would be reported as the +//! algorithm's cost, in a measurement whose whole output is a difference of a +//! few nanoseconds per push. +//! +//! Items are `u64` throughout. That keeps the slot payload identical across the +//! three so the comparison is of claim words and slot metadata, and it removes +//! drop glue from the timed region. + +#[cfg(test)] +mod tests; + +/// Isolates a field onto its own cache line. +/// +/// **Load-bearing, and measured to be.** The shipping shape puts both `head` +/// and the claim word behind this, because every producer reads `head` on +/// every push and the consumer writes it; sharing a line puts the consumer's +/// writes directly in every producer's path. A first version of this module +/// omitted the padding and measured 193.8 ns/push against the shipping shape's +/// 51.8 at 32 producers -- a 3.7x gap that was the missing alignment, not the +/// layouts being compared. 128 rather than 64 to match, which is what the +/// prefetcher pulling an adjacent line makes necessary. +#[repr(align(128))] +struct CacheAligned(T); + +pub mod narrow { + //! The shipping apportionment: a `u64` word split 32 / 32. + + use std::cell::UnsafeCell; + use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; + + /// Bits of the claim word given to the position. + const POSITION_BITS: u32 = 32; + + /// Isolates the position half of the claim word. + const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; + + /// One cell of the ring. + struct Slot { + /// `position + 1` once the claiming producer has finished writing. + sequence: AtomicU32, + value: UnsafeCell, + } + + /// A bounded MPSC whose claim word is split 32 / 32. + pub struct Queue { + claim: super::CacheAligned, + head: super::CacheAligned, + mask: u32, + capacity: u32, + slots: Box<[Slot]>, + } + + // SAFETY: a position is claimed by exactly one producer, which is therefore + // the slot's only writer, and it publishes with a release store the + // consumer acquires. The consumer is the only reader and advances `head` + // with a release store the producers acquire before reusing the slot. + unsafe impl Sync for Queue {} + // SAFETY: as above; the payload is `u64`, which is `Send`. + unsafe impl Send for Queue {} + + impl Queue { + /// Build a queue whose capacity is `capacity`, which must be a power of two. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + assert!( + capacity.is_power_of_two(), + "capacity must be a power of two" + ); + let slots = (0..capacity) + .map(|_| Slot { + sequence: AtomicU32::new(0), + value: UnsafeCell::new(0), + }) + .collect::>() + .into_boxed_slice(); + Self { + claim: super::CacheAligned(AtomicU64::new(0)), + head: super::CacheAligned(AtomicU32::new(0)), + mask: (capacity - 1) as u32, + capacity: capacity as u32, + slots, + } + } + + /// Claim a position and publish `item`, or report the queue full. + pub fn push(&self, item: u64) -> bool { + let mut word = self.claim.0.load(Ordering::Relaxed); + let position = loop { + let position = (word & POSITION_MASK) as u32; + let reserved = (word >> POSITION_BITS) as u32; + let occupied = position.wrapping_sub(self.head.0.load(Ordering::Acquire)); + if occupied >= self.capacity - reserved { + // Provisional: `position` and `head` were read at different + // instants, so re-read the claim before believing it. + let current = self.claim.0.load(Ordering::Relaxed); + if current != word { + word = current; + continue; + } + return false; + } + let next = ((reserved as u64) << POSITION_BITS) | position.wrapping_add(1) as u64; + match self.claim.0.compare_exchange_weak( + word, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break position, + Err(actual) => word = actual, + } + }; + + // The acquire edge the slot write needs: the consumer frees a slot + // with a release store to `head`, and this must observe one that has + // passed this position's previous occupant. + while position.wrapping_sub(self.head.0.load(Ordering::Acquire)) >= self.capacity { + std::hint::spin_loop(); + } + + let slot = &self.slots[(position & self.mask) as usize]; + // SAFETY: this thread claimed `position`, so it is the only writer, + // and the loop above established the previous occupant is gone. + unsafe { *slot.value.get() = item }; + slot.sequence + .store(position.wrapping_add(1), Ordering::Release); + true + } + + /// Take the oldest published item. Single consumer only. + pub fn pop(&self) -> Option { + let head = self.head.0.load(Ordering::Relaxed); + let slot = &self.slots[(head & self.mask) as usize]; + if slot.sequence.load(Ordering::Acquire) != head.wrapping_add(1) { + return None; + } + // SAFETY: the sequence read above synchronizes-with the producer's + // release store, so the write of this item happens-before this read. + let item = unsafe { *slot.value.get() }; + self.head.0.store(head.wrapping_add(1), Ordering::Release); + Some(item) + } + } +} + +pub mod deep { + //! An asymmetric apportionment: a `u64` word split 16 / 48. + + use std::cell::UnsafeCell; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Bits of the claim word given to the position. + const POSITION_BITS: u32 = 48; + + /// Isolates the position half of the claim word. + const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; + + /// One cell of the ring. + struct Slot { + /// Widened to match the position: a sequence narrower than the position + /// would alias every 2^32 and reintroduce the recurrence on the + /// consumer's side, which is the defect the split exists to remove. + sequence: AtomicU64, + value: UnsafeCell, + } + + /// A bounded MPSC whose claim word is split 16 / 48. + pub struct Queue { + claim: super::CacheAligned, + head: super::CacheAligned, + mask: u64, + capacity: u64, + slots: Box<[Slot]>, + } + + // SAFETY: as `narrow`'s; the protocol is identical and only the split differs. + unsafe impl Sync for Queue {} + // SAFETY: as above. + unsafe impl Send for Queue {} + + impl Queue { + /// Build a queue whose capacity is `capacity`, which must be a power of two. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + assert!( + capacity.is_power_of_two(), + "capacity must be a power of two" + ); + let slots = (0..capacity) + .map(|_| Slot { + sequence: AtomicU64::new(u64::MAX), + value: UnsafeCell::new(0), + }) + .collect::>() + .into_boxed_slice(); + Self { + claim: super::CacheAligned(AtomicU64::new(0)), + head: super::CacheAligned(AtomicU64::new(0)), + mask: (capacity - 1) as u64, + capacity: capacity as u64, + slots, + } + } + + /// Claim a position and publish `item`, or report the queue full. + pub fn push(&self, item: u64) -> bool { + let mut word = self.claim.0.load(Ordering::Relaxed); + let position = loop { + let position = word & POSITION_MASK; + let reserved = word >> POSITION_BITS; + // Masked because the position wraps at 2^48 rather than at the + // word's own width, which is the cost an asymmetric split pays + // and a 32 / 32 one gets free from `u32` truncation. + let occupied = + position.wrapping_sub(self.head.0.load(Ordering::Acquire)) & POSITION_MASK; + if occupied >= self.capacity - reserved { + let current = self.claim.0.load(Ordering::Relaxed); + if current != word { + word = current; + continue; + } + return false; + } + let next = (reserved << POSITION_BITS) | (position.wrapping_add(1) & POSITION_MASK); + match self.claim.0.compare_exchange_weak( + word, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break position, + Err(actual) => word = actual, + } + }; + + while (position.wrapping_sub(self.head.0.load(Ordering::Acquire)) & POSITION_MASK) + >= self.capacity + { + std::hint::spin_loop(); + } + + let slot = &self.slots[(position & self.mask) as usize]; + // SAFETY: as `narrow`'s -- sole claimant, previous occupant gone. + unsafe { *slot.value.get() = item }; + slot.sequence + .store(position.wrapping_add(1) & POSITION_MASK, Ordering::Release); + true + } + + /// Take the oldest published item. Single consumer only. + pub fn pop(&self) -> Option { + let head = self.head.0.load(Ordering::Relaxed); + let slot = &self.slots[(head & self.mask) as usize]; + if slot.sequence.load(Ordering::Acquire) != (head.wrapping_add(1) & POSITION_MASK) { + return None; + } + // SAFETY: as `narrow`'s. + let item = unsafe { *slot.value.get() }; + self.head + .0 + .store(head.wrapping_add(1) & POSITION_MASK, Ordering::Release); + Some(item) + } + } +} + +pub mod wide { + //! The double-width apportionment: a `u128` word split 64 / 64. + + use portable_atomic::AtomicU128; + use std::cell::UnsafeCell; + use std::sync::atomic::{AtomicU64, Ordering}; + + /// Bits of the claim word given to the position. + const POSITION_BITS: u32 = 64; + + /// One cell of the ring. + struct Slot { + sequence: AtomicU64, + value: UnsafeCell, + } + + /// A bounded MPSC whose claim word is a `u128` split 64 / 64. + pub struct Queue { + claim: super::CacheAligned, + head: super::CacheAligned, + mask: u64, + capacity: u64, + slots: Box<[Slot]>, + } + + // SAFETY: as `narrow`'s; the protocol is identical and only the width differs. + unsafe impl Sync for Queue {} + // SAFETY: as above. + unsafe impl Send for Queue {} + + impl Queue { + /// Build a queue whose capacity is `capacity`, which must be a power of two. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + assert!( + capacity.is_power_of_two(), + "capacity must be a power of two" + ); + let slots = (0..capacity) + .map(|_| Slot { + sequence: AtomicU64::new(u64::MAX), + value: UnsafeCell::new(0), + }) + .collect::>() + .into_boxed_slice(); + Self { + claim: super::CacheAligned(AtomicU128::new(0)), + head: super::CacheAligned(AtomicU64::new(0)), + mask: (capacity - 1) as u64, + capacity: capacity as u64, + slots, + } + } + + /// Claim a position and publish `item`, or report the queue full. + pub fn push(&self, item: u64) -> bool { + let mut word = self.claim.0.load(Ordering::Relaxed); + let position = loop { + let position = word as u64; + let reserved = (word >> POSITION_BITS) as u64; + let occupied = position.wrapping_sub(self.head.0.load(Ordering::Acquire)); + if occupied >= self.capacity - reserved { + let current = self.claim.0.load(Ordering::Relaxed); + if current != word { + word = current; + continue; + } + return false; + } + let next = ((reserved as u128) << POSITION_BITS) | position.wrapping_add(1) as u128; + match self.claim.0.compare_exchange_weak( + word, + next, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break position, + Err(actual) => word = actual, + } + }; + + while position.wrapping_sub(self.head.0.load(Ordering::Acquire)) >= self.capacity { + std::hint::spin_loop(); + } + + let slot = &self.slots[(position & self.mask) as usize]; + // SAFETY: as `narrow`'s -- sole claimant, previous occupant gone. + unsafe { *slot.value.get() = item }; + slot.sequence + .store(position.wrapping_add(1), Ordering::Release); + true + } + + /// Take the oldest published item. Single consumer only. + pub fn pop(&self) -> Option { + let head = self.head.0.load(Ordering::Relaxed); + let slot = &self.slots[(head & self.mask) as usize]; + if slot.sequence.load(Ordering::Acquire) != head.wrapping_add(1) { + return None; + } + // SAFETY: as `narrow`'s. + let item = unsafe { *slot.value.get() }; + self.head.0.store(head.wrapping_add(1), Ordering::Release); + Some(item) + } + } +} diff --git a/crates/windows-platform-probes/src/claim_layout/tests.rs b/crates/windows-platform-probes/src/claim_layout/tests.rs new file mode 100644 index 00000000..c1f93597 --- /dev/null +++ b/crates/windows-platform-probes/src/claim_layout/tests.rs @@ -0,0 +1,95 @@ +// Copyright (c) Mike Grier. + +//! Correctness checks for the three claim-word layouts. +//! +//! A measurement of a queue that loses or duplicates items is worthless, so +//! each layout is checked to deliver exactly what was pushed before it is +//! timed. These are not a substitute for `windows-waitable-queues`' own suite; +//! they establish that the duplicated protocol in this crate behaves like the +//! one it is standing in for. + +use std::collections::HashSet; +use std::sync::Arc; +use std::thread; + +use super::{deep, narrow, wide}; + +/// How many items each producer pushes in the concurrent checks. +const PER_PRODUCER: u64 = 2_000; + +/// How many producers the concurrent checks run. +const PRODUCERS: u64 = 4; + +macro_rules! layout_suite { + ($module:ident, $name:ident) => { + mod $name { + use super::*; + + #[test] + fn delivers_in_order_from_one_producer() { + let queue = $module::Queue::with_capacity(8); + for value in 0..64u64 { + while !queue.push(value) { + assert!(queue.pop().is_some(), "the queue may only refuse when full"); + } + } + let mut drained = Vec::new(); + while let Some(value) = queue.pop() { + drained.push(value); + } + assert!( + drained.windows(2).all(|pair| pair[0] < pair[1]), + "a single producer's items must arrive in the order it pushed them" + ); + } + + #[test] + fn refuses_when_full_rather_than_overwriting() { + let queue = $module::Queue::with_capacity(4); + for value in 0..4u64 { + assert!(queue.push(value), "the first four fit"); + } + assert!(!queue.push(4), "the fifth must be refused, not overwrite"); + for expected in 0..4u64 { + assert_eq!(queue.pop(), Some(expected)); + } + assert_eq!(queue.pop(), None, "the refused item was never accepted"); + } + + #[test] + fn loses_nothing_under_concurrent_producers() { + let queue = Arc::new($module::Queue::with_capacity(64)); + let mut handles = Vec::new(); + for producer in 0..PRODUCERS { + let queue = Arc::clone(&queue); + handles.push(thread::spawn(move || { + for index in 0..PER_PRODUCER { + let value = producer * PER_PRODUCER + index; + while !queue.push(value) { + std::thread::yield_now(); + } + } + })); + } + + let expected = (PRODUCERS * PER_PRODUCER) as usize; + let mut seen = HashSet::with_capacity(expected); + while seen.len() < expected { + if let Some(value) = queue.pop() { + assert!(seen.insert(value), "item {value} was delivered twice"); + } + } + + for handle in handles { + handle.join().expect("no producer panicked"); + } + assert_eq!(seen.len(), expected, "every pushed item was delivered"); + assert_eq!(queue.pop(), None, "nothing extra was delivered"); + } + } + }; +} + +layout_suite!(narrow, narrow_layout); +layout_suite!(deep, deep_layout); +layout_suite!(wide, wide_layout); diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index c59e1903..81b299ee 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -110,6 +110,7 @@ #![warn(missing_docs)] pub mod cancel_io; +pub mod claim_layout; pub mod completion_port; pub mod device_map; pub mod doorbell_cost; diff --git a/crates/windows-platform-probes/src/queue_contention.rs b/crates/windows-platform-probes/src/queue_contention.rs index e1b6a1d2..97fd4805 100644 --- a/crates/windows-platform-probes/src/queue_contention.rs +++ b/crates/windows-platform-probes/src/queue_contention.rs @@ -54,6 +54,8 @@ use std::time::Instant; use windows_waitable_queues::{permit_mpsc, reserving_mpsc, slotwise_mpsc}; +use crate::claim_layout; + /// How many pushes each producer thread performs in one timed run. const PUSHES_PER_PRODUCER: usize = 50_000; @@ -90,6 +92,16 @@ pub mod shapes { pub const PERMIT_MPSC: &str = "permit_mpsc"; /// The uncontended-atomic floor the queues are measured against. pub const BASELINE_FETCH_ADD: &str = "baseline_fetch_add"; + /// The reserving claim word as it ships: a `u64` split 32 / 32. + /// + /// Measured beside [`RESERVING_MPSC`] rather than assumed equal to it: it + /// is a duplicated implementation, so a divergence between the two is + /// evidence the duplicate is not standing in faithfully. + pub const CLAIM_NARROW: &str = "claim_32_32"; + /// The reserving claim word split 16 / 48, still one `u64` exchange. + pub const CLAIM_DEEP: &str = "claim_16_48"; + /// The reserving claim word widened to a `u128` split 64 / 64. + pub const CLAIM_WIDE: &str = "claim_64_64"; } /// One configuration's result. #[derive(Debug, Clone, Copy, PartialEq)] @@ -173,6 +185,26 @@ pub fn measure() -> Observation { drained.push(median_run(shapes::PERMIT_MPSC, producers, |count| { time_drained_permit(count) })); + + isolated.push(median_run(shapes::CLAIM_NARROW, producers, |count| { + time_isolated_claim_narrow(count) + })); + isolated.push(median_run(shapes::CLAIM_DEEP, producers, |count| { + time_isolated_claim_deep(count) + })); + isolated.push(median_run(shapes::CLAIM_WIDE, producers, |count| { + time_isolated_claim_wide(count) + })); + + drained.push(median_run(shapes::CLAIM_NARROW, producers, |count| { + time_drained_claim_narrow(count) + })); + drained.push(median_run(shapes::CLAIM_DEEP, producers, |count| { + time_drained_claim_deep(count) + })); + drained.push(median_run(shapes::CLAIM_WIDE, producers, |count| { + time_drained_claim_wide(count) + })); } Observation { @@ -512,3 +544,232 @@ fn time_drained_permit(producers: usize) -> Repetition { let refusals = consumer.join().expect("the consumer must not panic"); (elapsed, refusals) } + +/// The shipping 32 / 32 claim word, in the regime that isolates the claim. +/// +/// A line-for-line twin of [`time_isolated_reserving`] with the duplicated +/// layout substituted, and duplicated again for each of the three layouts for +/// the reason [`time_isolated_permit`] gives: a generic over the layouts would +/// put an indirection that might not inline identically inside the timed +/// region, in a measurement whose whole output is a difference of a few +/// nanoseconds per push. +fn time_isolated_claim_narrow(producers: usize) -> Repetition { + let queue = Arc::new(claim_layout::narrow::Queue::with_capacity(capacity_for( + producers, + ))); + let gate = start_barrier(producers); + let started = thread::scope(|scope| { + for producer in 0..producers { + let queue = Arc::clone(&queue); + let gate = Arc::clone(&gate); + scope.spawn(move || { + gate.wait(); + for index in 0..PUSHES_PER_PRODUCER { + assert!( + queue.push((producer * PUSHES_PER_PRODUCER + index) as u64), + "the run fits in the capacity" + ); + } + }); + } + gate.wait(); + Instant::now() + }); + let elapsed = started.elapsed().as_nanos() as f64; + while queue.pop().is_some() {} + (elapsed, 0) +} + +/// The 16 / 48 claim word, in the regime that isolates the claim. +fn time_isolated_claim_deep(producers: usize) -> Repetition { + let queue = Arc::new(claim_layout::deep::Queue::with_capacity(capacity_for( + producers, + ))); + let gate = start_barrier(producers); + let started = thread::scope(|scope| { + for producer in 0..producers { + let queue = Arc::clone(&queue); + let gate = Arc::clone(&gate); + scope.spawn(move || { + gate.wait(); + for index in 0..PUSHES_PER_PRODUCER { + assert!( + queue.push((producer * PUSHES_PER_PRODUCER + index) as u64), + "the run fits in the capacity" + ); + } + }); + } + gate.wait(); + Instant::now() + }); + let elapsed = started.elapsed().as_nanos() as f64; + while queue.pop().is_some() {} + (elapsed, 0) +} + +/// The 64 / 64 claim word, in the regime that isolates the claim. +fn time_isolated_claim_wide(producers: usize) -> Repetition { + let queue = Arc::new(claim_layout::wide::Queue::with_capacity(capacity_for( + producers, + ))); + let gate = start_barrier(producers); + let started = thread::scope(|scope| { + for producer in 0..producers { + let queue = Arc::clone(&queue); + let gate = Arc::clone(&gate); + scope.spawn(move || { + gate.wait(); + for index in 0..PUSHES_PER_PRODUCER { + assert!( + queue.push((producer * PUSHES_PER_PRODUCER + index) as u64), + "the run fits in the capacity" + ); + } + }); + } + gate.wait(); + Instant::now() + }); + let elapsed = started.elapsed().as_nanos() as f64; + while queue.pop().is_some() {} + (elapsed, 0) +} + +/// The shipping 32 / 32 claim word, against a continuously draining consumer. +fn time_drained_claim_narrow(producers: usize) -> Repetition { + let queue = Arc::new(claim_layout::narrow::Queue::with_capacity(DRAINED_CAPACITY)); + let refusals = Arc::new(AtomicU64::new(0)); + let done = Arc::new(AtomicBool::new(false)); + let gate = start_barrier(producers + 1); + + let consumer_queue = Arc::clone(&queue); + let consumer_done = Arc::clone(&done); + let consumer_gate = Arc::clone(&gate); + let consumer = thread::spawn(move || { + consumer_gate.wait(); + while !consumer_done.load(Ordering::Relaxed) { + while consumer_queue.pop().is_some() {} + std::hint::spin_loop(); + } + while consumer_queue.pop().is_some() {} + }); + + let started = thread::scope(|scope| { + for producer in 0..producers { + let queue = Arc::clone(&queue); + let gate = Arc::clone(&gate); + let refusals = Arc::clone(&refusals); + scope.spawn(move || { + gate.wait(); + let mut refused = 0u64; + for index in 0..PUSHES_PER_PRODUCER { + let item = (producer * PUSHES_PER_PRODUCER + index) as u64; + while !queue.push(item) { + refused += 1; + std::hint::spin_loop(); + } + } + refusals.fetch_add(refused, Ordering::Relaxed); + }); + } + gate.wait(); + Instant::now() + }); + let elapsed = started.elapsed().as_nanos() as f64; + done.store(true, Ordering::Relaxed); + consumer.join().expect("the consumer did not panic"); + (elapsed, refusals.load(Ordering::Relaxed)) +} + +/// The 16 / 48 claim word, against a continuously draining consumer. +fn time_drained_claim_deep(producers: usize) -> Repetition { + let queue = Arc::new(claim_layout::deep::Queue::with_capacity(DRAINED_CAPACITY)); + let refusals = Arc::new(AtomicU64::new(0)); + let done = Arc::new(AtomicBool::new(false)); + let gate = start_barrier(producers + 1); + + let consumer_queue = Arc::clone(&queue); + let consumer_done = Arc::clone(&done); + let consumer_gate = Arc::clone(&gate); + let consumer = thread::spawn(move || { + consumer_gate.wait(); + while !consumer_done.load(Ordering::Relaxed) { + while consumer_queue.pop().is_some() {} + std::hint::spin_loop(); + } + while consumer_queue.pop().is_some() {} + }); + + let started = thread::scope(|scope| { + for producer in 0..producers { + let queue = Arc::clone(&queue); + let gate = Arc::clone(&gate); + let refusals = Arc::clone(&refusals); + scope.spawn(move || { + gate.wait(); + let mut refused = 0u64; + for index in 0..PUSHES_PER_PRODUCER { + let item = (producer * PUSHES_PER_PRODUCER + index) as u64; + while !queue.push(item) { + refused += 1; + std::hint::spin_loop(); + } + } + refusals.fetch_add(refused, Ordering::Relaxed); + }); + } + gate.wait(); + Instant::now() + }); + let elapsed = started.elapsed().as_nanos() as f64; + done.store(true, Ordering::Relaxed); + consumer.join().expect("the consumer did not panic"); + (elapsed, refusals.load(Ordering::Relaxed)) +} + +/// The 64 / 64 claim word, against a continuously draining consumer. +fn time_drained_claim_wide(producers: usize) -> Repetition { + let queue = Arc::new(claim_layout::wide::Queue::with_capacity(DRAINED_CAPACITY)); + let refusals = Arc::new(AtomicU64::new(0)); + let done = Arc::new(AtomicBool::new(false)); + let gate = start_barrier(producers + 1); + + let consumer_queue = Arc::clone(&queue); + let consumer_done = Arc::clone(&done); + let consumer_gate = Arc::clone(&gate); + let consumer = thread::spawn(move || { + consumer_gate.wait(); + while !consumer_done.load(Ordering::Relaxed) { + while consumer_queue.pop().is_some() {} + std::hint::spin_loop(); + } + while consumer_queue.pop().is_some() {} + }); + + let started = thread::scope(|scope| { + for producer in 0..producers { + let queue = Arc::clone(&queue); + let gate = Arc::clone(&gate); + let refusals = Arc::clone(&refusals); + scope.spawn(move || { + gate.wait(); + let mut refused = 0u64; + for index in 0..PUSHES_PER_PRODUCER { + let item = (producer * PUSHES_PER_PRODUCER + index) as u64; + while !queue.push(item) { + refused += 1; + std::hint::spin_loop(); + } + } + refusals.fetch_add(refused, Ordering::Relaxed); + }); + } + gate.wait(); + Instant::now() + }); + let elapsed = started.elapsed().as_nanos() as f64; + done.store(true, Ordering::Relaxed); + consumer.join().expect("the consumer did not panic"); + (elapsed, refusals.load(Ordering::Relaxed)) +} From d201c067a9db48cd06d7b7736573dc7eb9887ff9 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 23:00:53 -0400 Subject: [PATCH 341/361] docs(platform-probes): record rollover times for candidate claim-word splits The reservation half holds 2^32 where hundreds would do, and trading it away is what buys position bits: 2^12 reservations leaves over a year before recurrence, 2^8 leaves twenty years, against today's 37 seconds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHECKLIST-claim-word-layout.md | 9 +++++ .../windows-platform-probes/DESIGN-NOTES.md | 37 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index ff76c0db..64701876 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -102,6 +102,15 @@ peeled off PR #56. The merge-or-delete decision is `CW-1.6`. defaults are permitted on types but not on functions, so the entry points need deciding rather than assuming. + **The default is the substantive question, not the mechanism.** The rollover + table in [DESIGN-NOTES.md](DESIGN-NOTES.md) shows the reservation half holds + four billion where hundreds would do, and that trading it away is what buys + the position bits: 2^12 reservations leaves over a year before recurrence and + 2^8 leaves twenty years, against today's 37 seconds. A changed default is a + contract change for anyone who read the current capacity ceiling, so it is + `CW-2.3`'s decision to make explicitly -- but leaving the default at 32/32 + because it is the status quo would preserve `SH-14.1` by inertia. + - [ ] **CW-2.3** -- Decide whether a 128-bit claim word becomes the default, on `CW-1.4`'s evidence. This is the question behind `D-37`'s conditional gating, and the engineer has said 32-bit Windows deployment is not a present concern diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 20f966e5..91289060 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -728,3 +728,40 @@ absolute numbers for the shipping shape and must not be quoted as such. **Comparing a duplicate against the original it stands in for is what made both of these visible.** A run of three layouts that agreed with each other and disagreed with reality would have looked entirely healthy. + +### What each apportionment actually buys + +The rollover figures for candidate splits, computed from the rates above. The +rate model reproduces the crate's own published figure -- 32/32 at 116M/s gives +37 seconds, which is what `reserving_mpsc`'s module documentation discloses -- so +these are an extension of that disclosure rather than a competing estimate. + +| split (reserved/position) | max outstanding reservations | @257M/s | @116M/s | @33M/s | +|---|---|---|---|---| +| 32/32 (ships) | 2^32 | 17 s | 37 s | 2.2 min | +| 24/40 | 2^24 | 71 min | 2.6 hr | 9.2 hr | +| 21/43 | 2^21 | 9.5 hr | 21.1 hr | 3.1 days | +| 20/44 | 2^20 | 19.0 hr | 42.1 hr | 6.1 days | +| 16/48 | 2^16 | 12.7 days | 28.1 days | 98 days | +| 12/52 | 2^12 | 202 days | 449 days | 4 yr | +| 8/56 | 2^8 | 9 yr | 20 yr | 69 yr | +| 64/64 (`u128`) | 2^64 | 2,270 yr | 5,039 yr | 17,607 yr | + +Rates: 257M/s is the measured isolated peak at one producer, which has no +consumer and so is not a rate any draining queue can sustain -- it is a +conservative floor on time-to-wrap. 33M/s is the measured drained rate at one +producer. 116M/s is the crate's own disclosed figure and is the honest planning +number. + +**The reservation half is where the bits are being spent, and it is the half +worth least.** Outstanding reservations are bounded by how many producers are +mid-flight -- hundreds, perhaps thousands -- and the field currently holds four +billion. Giving up reservations nobody will allocate is what buys the position +bits: 2^21 reservations leaves about a day, 2^12 leaves over a year, and 2^8 +leaves twenty years. The last is the same practical answer a 128-bit word gives, +on a plain `AtomicU64`, at no measured cost, without a third-party dependency and +without reopening `D-18`'s i686 question. + +So the candidates worth considering are **12/52 and 8/56**, not the 16/48 first +sketched here: 16/48's 12.7 days at the conservative floor is still reachable by +a busy long-lived process, and 12/52 is the first row that is not. From ed6cadd801dcd183e80b019ef816fc0684263c63 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 23:09:02 -0400 Subject: [PATCH 342/361] docs(platform-probes): decide to offer claim-word layouts as documented options Records the engineer's direction that options are right if quality is maintained and ramifications are available, and binds both halves: each layout states its own reservation ceiling, capacity ceiling, and time-to-recurrence where it is named; quality is per-layout; the default is not weakened. Adds CW-2.4 for the documentation and CW-2.5 for the SH-14.1 disclosure sweep, since every existing statement of that hazard is scoped to a 32-bit position. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHECKLIST-claim-word-layout.md | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index 64701876..91bb6c68 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -82,6 +82,30 @@ peeled off PR #56. The merge-or-delete decision is `CW-1.6`. > `windows-waitable-queues` and is gated on `CW-1.4`'s numbers and on the > `mikegrier/waitable-queues` peel merging. Parked deliberately, not pending. +**Decided: offer a set of named layouts rather than one, on the condition that +each carries its own ramifications.** The engineer's direction was that options +are the right answer "as long as quality is maintained" and "the ramifications +of the choices are available". Both halves are binding, and the second is the +one an options API usually fails: a caller who cannot see what a layout costs +will pick by name, and the names are the least informative thing about them. + +Three obligations follow, and they apply to every item in this milestone: + +- **Each layout states its own consequences where it is named** -- reservation + ceiling, capacity ceiling, and time-to-recurrence at a stated push rate. Not + in a table elsewhere that the layout links to; a caller reading the type must + see the trade. The rollover figures already exist in + [DESIGN-NOTES.md](DESIGN-NOTES.md) and are the source, not a second copy: the + crate documentation restates them once and nothing else does. +- **Quality is per-layout, not per-crate.** Every layout gets the same const + assertions, the same tests, and the same mutation coverage as the shipping + one. A layout that exists but is exercised only by a doctest is worse than no + option, because its presence claims a support level nothing verifies. +- **Adding a layout must not weaken the default.** Callers who never name one + keep today's semantics unless `CW-2.3` explicitly changes them, and the + layout parameter must not leak into the signatures of callers who do not use + it. If it cannot be kept out of them, say so rather than accepting the churn. + - [ ] **CW-2.1** -- Derive `BOUNDS_MAX` from both constraints rather than from the ring bound alone, so that widening the position narrows the capacity ceiling instead of tripping a const assertion. @@ -115,3 +139,29 @@ peeled off PR #56. The merge-or-delete decision is `CW-1.6`. `CW-1.4`'s evidence. This is the question behind `D-37`'s conditional gating, and the engineer has said 32-bit Windows deployment is not a present concern -- which changes `D-18`'s premise and must be recorded rather than assumed. + +- [ ] **CW-2.4** -- Document the layouts as a choice, in the crate documentation + and the README, with the rollover table and the two axes a caller actually + trades between: outstanding reservations against time-to-recurrence. Lead with + the fact `CW-1.4` measured -- re-apportioning is free, widening is not -- so a + caller is not left assuming the safest option must be the slowest. State the + push rate the figures assume and that a draining queue cannot sustain the + fastest of them, or the numbers will be read as forecasts rather than as the + floor they are. + + **Compiled, not merely written.** Any README example naming a layout is a + doctest per this repository's CONTRACT INTEGRITY rule, so a layout that is + renamed or removed breaks the build instead of leaving the documentation + teaching a name that no longer exists. + +- [ ] **CW-2.5** -- Sweep the existing `SH-14.1` disclosure once the layouts + land. `D-36` states the hazard in the crate documentation, the README, and + `reserving_mpsc`'s module documentation, each leading with "on every target, + not only 32-bit ones", and `lib.rs` separately claims the shape is "sound + below the wrap". Every one of those statements is scoped to a 32-bit position + and becomes wrong for a caller who selected a different layout. + + This is a blast-radius sweep, not an edit of the site someone reports: grep + the distinguishing terms across `src/`, `tests/`, `examples/` and `*.md` for + the crate and its dependents, fix every hit or say why it is out of scope, and + record the sweep in the commit message. From 80184a1f12cb56a092f1b91a1ad80db516ab74f1 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 23:16:35 -0400 Subject: [PATCH 343/361] docs(platform-probes): disambiguate CW-1.6 as the duplicate implementation, not the layouts The item read as though it might decide which layouts to offer. It does not: multiple layouts ship as options per M2. CW-1.6 is only about the private copy of the protocol in this crate, which M2 makes obsolete because the shipping type can then be instantiated at any layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHECKLIST-claim-word-layout.md | 32 ++++++++++++++++--- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index 91bb6c68..c0d8813f 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -70,11 +70,33 @@ peeled off PR #56. The merge-or-delete decision is `CW-1.6`. with the host fingerprint, and raise the finding against the queue crate's `D-37` so the shipping decision has the number it currently lacks. -- [ ] **CW-1.6** -- Decide merge-or-delete for the duplicated path: either the - layouts are promoted into `windows-waitable-queues` (which is `M2`) and the - probe keeps only what it needs to compare them, or the experiment is deleted. - Recorded here so a duplicated path cannot become permanent by nobody - returning to it. +- [ ] **CW-1.6** -- Decide merge-or-delete for the duplicated *implementation* + in [claim_layout.rs](src/claim_layout.rs). Recorded here so a duplicated path + cannot become permanent by nobody returning to it. + + **This is not a decision about which layouts to offer.** That is settled -- + multiple layouts ship as caller-selectable options, per `M2`. This item is + only about the private copy of the reserving protocol that lives in this + crate, which exists so the layouts could be measured without touching + `windows-waitable-queues` while it was being peeled off PR #56. + + **`M2` done properly makes the copy obsolete.** Once the shipping crate takes + the layout as a compile-time parameter, the probe instantiates the *real* + type at any layout it wants to compare, including candidates that are not + defaults -- so exploring a new apportionment no longer needs a duplicate. + + Deleting it is not tidiness. A second implementation of the same protocol + drifts, and this one already did: `CW-1.4`'s first run measured 3.7x against + the shipping shape on an entirely different scaling curve, because the + duplicate had not cache-padded `head` and the claim word. Corrected, it still + sits about 1.26x off. A duplicate that diverges silently produces a + measurement that looks healthy and describes something nobody ships. + + **One exception survives:** the 64/64 layout needs `AtomicU128`. If `CW-2.3` + declines that dependency for the shipping crate, this crate must keep its + `wide` implementation, because a layout the crate cannot express is one the + probe cannot instantiate. Keep the minimum that case requires and delete the + rest. ## M2+: expose the apportionment From bdd0ad5653b38c147bb192e671c9d504b96bc0f9 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 23:27:43 -0400 Subject: [PATCH 344/361] docs(platform-probes): unpark M2 and reopen D-36, whose premise the measurement falsified D-36 deferred fixing SH-14.1 because the fix was believed to be the D-35 claim-protocol replacement, gated on an open question. Re-apportionment is a second fix neither D-36 nor D-37 considered, measured free by CW-1.4, moving the recurrence from ~37 seconds to ~20 years. M2 was also parked on not disturbing a branch under review; that branch has no PR open and the u64 layouts need no new dependency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHECKLIST-claim-word-layout.md | 294 +++++++++--------- 1 file changed, 141 insertions(+), 153 deletions(-) diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index c0d8813f..d16ae804 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -1,19 +1,20 @@ -# Checklist: claim-word layout measurement +# Checklist: claim-word layout -Measures how the `reserving_mpsc` claim word's bit apportionment and width affect -push throughput, so the shipping crate's layout can be chosen on evidence rather -than on the single 32/32 split it inherited. +Measures how the `reserving_mpsc` claim word's bit apportionment and width +affect push throughput, then offers the layouts as documented, caller-selectable +options in `windows-waitable-queues`. -Design decisions land in [DESIGN-NOTES.md](DESIGN-NOTES.md); the decisions this -informs live in the queue crate's -[DESIGN-NOTES.md](../windows-waitable-queues/DESIGN-NOTES.md) (`D-36`, `D-37`). +Design decisions land in [DESIGN-NOTES.md](DESIGN-NOTES.md) for the measurement +and in the queue crate's +[DESIGN-NOTES.md](../windows-waitable-queues/DESIGN-NOTES.md) for the API +(`D-36`, `D-37`). ## Background -`reserving_mpsc` packs `reserved` and `position` into one `AtomicU64` because the -claim protocol needs a single compare-and-swap to update both (`D-17`, `D-34`). -The split is 32/32, which caps positions at 2^32 and is the whole source of the -`SH-14.1` recurrence hazard disclosed by `D-36`. +`reserving_mpsc` packs `reserved` and `position` into one `AtomicU64` because +the claim protocol needs a single compare-and-swap to update both (`D-17`, +`D-34`). The split is 32/32, which caps positions at 2^32 and is the whole +source of the `SH-14.1` recurrence hazard disclosed by `D-36`. **The 32/32 split is not forced by the platform.** It follows from a capacity ceiling of 2^31, because the `reserved` half must be able to hold the entire @@ -23,167 +24,154 @@ capacity. Two independent constraints bound the capacity: - packing: `capacity <= 2^(64 - POSITION_BITS) - 1` `BOUNDS_MAX` is currently derived from the first alone and the second is only -*asserted*, so widening the position raises the ceiling while shrinking the field -obliged to hold it -- which is why widening trips the assertion instead of -working. Deriving the ceiling as the minimum of both makes asymmetric splits -expressible. +*asserted*, so widening the position raises the ceiling while shrinking the +field obliged to hold it -- which is why widening trips the assertion instead of +working. -`D-37` offers only 32/32 and a 128-bit 64/64. The asymmetric middle ground is -unexplored, and it needs no new dependency and no 128-bit exchange. +## M1: measure the layouts -- done -## M1: measure the layouts +Built as a duplicated path in this crate so the measurement added no third-party +dependency to a publishable crate and could not disturb the +`windows-waitable-queues` branch being peeled off PR #56. -The variants are built as a **duplicated, experimental path in this crate**, not -in the queue crate. `windows-platform-probes` is explicitly experiments rather -than components, so the measurement adds no third-party dependency to a -publishable crate and cannot disturb the `windows-waitable-queues` branch being -peeled off PR #56. The merge-or-delete decision is `CW-1.6`. - -- [x] **CW-1.1** -- Add `portable-atomic` with `default-features = false` to this - crate only, and record whether `AtomicU128` exists and is lock-free on - `x86_64-pc-windows-msvc`. `D-37` measured that the `use` statement is itself - the gate; confirm that still holds and note whether the implementation uses a - compile-time guarantee or runtime detection, because a CPUID branch in the - claim path would be measured as if it were the algorithm's cost. +- [x] **CW-1.1** -- Add `portable-atomic` with `default-features = false` to + this crate only, and record whether `AtomicU128` exists and is lock-free. + Measured: `is_always_lock_free()` is true and `cmpxchg16b` is a default target + feature here, so no CPUID branch was timed as though it were the algorithm. - [x] **CW-1.2** -- Implement the three claim-word layouts as self-contained - `u64`-item queues: `narrow` (32/32 over `AtomicU64`, mirroring the shipping - shape), `deep` (16/48 over `AtomicU64`), and `wide` (64/64 over `AtomicU128`). - Hand-written rather than generic over a layout trait, matching the existing - reason `time_isolated_permit` is a line-for-line twin of its neighbour: an - abstraction that might not inline identically would be measured as the - algorithm's cost. `deep` and `wide` must widen `head` and the per-slot - `sequence` to 64 bits, since a sequence narrower than the position aliases and - reintroduces the recurrence on the consumer side. + `u64`-item queues in [claim_layout.rs](src/claim_layout.rs). - [x] **CW-1.3** -- Wire the three layouts into `probe-queue-contention` as - named shapes in both the isolated and drained regimes, and report them against - the existing `BASELINE_FETCH_ADD` floor. - -- [x] **CW-1.4** -- Run the probe and capture the report. State plainly whether - 32/32 and 16/48 differ: both are one `AtomicU64` exchange and should be - indistinguishable in the claim itself, so a difference is evidence about slot - metadata density rather than about the claim, and no difference is the result - that makes the apportionment free. - -- [x] **CW-1.5** -- Record the measurement in [DESIGN-NOTES.md](DESIGN-NOTES.md) - with the host fingerprint, and raise the finding against the queue crate's - `D-37` so the shipping decision has the number it currently lacks. - -- [ ] **CW-1.6** -- Decide merge-or-delete for the duplicated *implementation* - in [claim_layout.rs](src/claim_layout.rs). Recorded here so a duplicated path - cannot become permanent by nobody returning to it. - - **This is not a decision about which layouts to offer.** That is settled -- - multiple layouts ship as caller-selectable options, per `M2`. This item is - only about the private copy of the reserving protocol that lives in this - crate, which exists so the layouts could be measured without touching - `windows-waitable-queues` while it was being peeled off PR #56. - - **`M2` done properly makes the copy obsolete.** Once the shipping crate takes - the layout as a compile-time parameter, the probe instantiates the *real* - type at any layout it wants to compare, including candidates that are not - defaults -- so exploring a new apportionment no longer needs a duplicate. - - Deleting it is not tidiness. A second implementation of the same protocol - drifts, and this one already did: `CW-1.4`'s first run measured 3.7x against - the shipping shape on an entirely different scaling curve, because the - duplicate had not cache-padded `head` and the claim word. Corrected, it still - sits about 1.26x off. A duplicate that diverges silently produces a - measurement that looks healthy and describes something nobody ships. + named shapes in both regimes. - **One exception survives:** the 64/64 layout needs `AtomicU128`. If `CW-2.3` - declines that dependency for the shipping crate, this crate must keep its - `wide` implementation, because a layout the crate cannot express is one the - probe cannot instantiate. Keep the minimum that case requires and delete the - rest. +- [x] **CW-1.4** -- Run the probe and capture the report. Result: + re-apportioning is free (16/48 tracks 32/32 within noise in both regimes); + widening to `u128` costs 2-3x isolated and 5-12% drained, and the drained + figure understates it because a slower producer earns fewer refusals. -## M2+: expose the apportionment +- [x] **CW-1.5** -- Record the measurement and the rollover table in + [DESIGN-NOTES.md](DESIGN-NOTES.md). -> **CROSS-COMPONENT PREREQUISITE:** every item below changes -> `windows-waitable-queues` and is gated on `CW-1.4`'s numbers and on the -> `mikegrier/waitable-queues` peel merging. Parked deliberately, not pending. +## M2: offer the layouts as options **Decided: offer a set of named layouts rather than one, on the condition that each carries its own ramifications.** The engineer's direction was that options -are the right answer "as long as quality is maintained" and "the ramifications -of the choices are available". Both halves are binding, and the second is the -one an options API usually fails: a caller who cannot see what a layout costs -will pick by name, and the names are the least informative thing about them. +are right "as long as quality is maintained" and "the ramifications of the +choices are available". Both halves are binding, and the second is the one an +options API usually fails: a caller who cannot see what a layout costs will pick +by name, and the names are the least informative thing about them. -Three obligations follow, and they apply to every item in this milestone: +Three obligations apply to every item in this milestone: - **Each layout states its own consequences where it is named** -- reservation - ceiling, capacity ceiling, and time-to-recurrence at a stated push rate. Not - in a table elsewhere that the layout links to; a caller reading the type must - see the trade. The rollover figures already exist in - [DESIGN-NOTES.md](DESIGN-NOTES.md) and are the source, not a second copy: the + ceiling, capacity ceiling, and time-to-recurrence at a stated push rate. The + rollover figures in [DESIGN-NOTES.md](DESIGN-NOTES.md) are the source; the crate documentation restates them once and nothing else does. - **Quality is per-layout, not per-crate.** Every layout gets the same const - assertions, the same tests, and the same mutation coverage as the shipping - one. A layout that exists but is exercised only by a doctest is worse than no - option, because its presence claims a support level nothing verifies. -- **Adding a layout must not weaken the default.** Callers who never name one - keep today's semantics unless `CW-2.3` explicitly changes them, and the - layout parameter must not leak into the signatures of callers who do not use - it. If it cannot be kept out of them, say so rather than accepting the churn. - -- [ ] **CW-2.1** -- Derive `BOUNDS_MAX` from both constraints rather than from - the ring bound alone, so that widening the position narrows the capacity - ceiling instead of tripping a const assertion. - - **The measurement changed this item's shape.** Deriving the ceiling from both - constraints is not sufficient on its own: a 16-bit `reserved` half would cap - the capacity at 65535, and the probe's own isolated regime wants 2^21. What - makes 16/48 usable is **decoupling the reservation ceiling from the - capacity** -- capping *outstanding reservations* at `MAX_RESERVED` while the - capacity stays bounded only by the ring. That is what - [claim_layout.rs](src/claim_layout.rs) measured, and it is a **contract - change**: the shipping shape promises every slot may be reserved at once, and - this replaces that with a fixed reservation ceiling. A different promise - rather than a broken one, but it must be decided and stated, not slipped in. - -- [ ] **CW-2.2** -- Make the apportionment caller-selectable at compile time, - defaulting to today's behaviour so no existing caller changes. Generic - defaults are permitted on types but not on functions, so the entry points - need deciding rather than assuming. - - **The default is the substantive question, not the mechanism.** The rollover - table in [DESIGN-NOTES.md](DESIGN-NOTES.md) shows the reservation half holds - four billion where hundreds would do, and that trading it away is what buys - the position bits: 2^12 reservations leaves over a year before recurrence and - 2^8 leaves twenty years, against today's 37 seconds. A changed default is a - contract change for anyone who read the current capacity ceiling, so it is - `CW-2.3`'s decision to make explicitly -- but leaving the default at 32/32 - because it is the status quo would preserve `SH-14.1` by inertia. - -- [ ] **CW-2.3** -- Decide whether a 128-bit claim word becomes the default, on - `CW-1.4`'s evidence. This is the question behind `D-37`'s conditional gating, - and the engineer has said 32-bit Windows deployment is not a present concern - -- which changes `D-18`'s premise and must be recorded rather than assumed. + assertions, tests, and mutation coverage as the shipping one. A layout + exercised only by a doctest is worse than no option, because its presence + claims a support level nothing verifies. +- **Adding a layout must not weaken the default.** The layout parameter must + not leak into the signatures of callers who do not use it. If it cannot be + kept out, say so rather than accepting the churn. + +**This milestone is no longer parked.** It was gated on the peel merging, on the +reasoning that touching `windows-waitable-queues` would re-grow a branch under +review. That reasoning expired: `mikegrier/waitable-queues` has no pull request +open, so there is no review to disturb, and the `u64` layouts need no new +dependency at all -- only 64/64 does, which is `CW-2.3`. + +- [ ] **CW-2.1** -- Decouple the reservation ceiling from the capacity, and + derive `BOUNDS_MAX` from both constraints rather than the ring bound alone. + + Deriving the ceiling from both constraints is not sufficient on its own: a + 16-bit `reserved` half would cap the capacity at 65535. What makes an + asymmetric split usable is capping *outstanding reservations* at + `MAX_RESERVED` while the capacity stays bounded only by the ring. + + **This is a contract change**: the shipping shape promises every slot may be + reserved at once, and this replaces that with a fixed reservation ceiling. A + different promise rather than a broken one, but it must be stated, not slipped + in. + +- [ ] **CW-2.2** -- Introduce the layout as a compile-time parameter with named + layouts, keeping `head` and the per-slot `sequence` at 64 bits for every + layout. Uniform 64-bit metadata is measured-safe rather than assumed: + `CW-1.4` compared 32/32 with 32-bit metadata against 16/48 with 64-bit + metadata and found no difference, and for a `u64` payload the slot is 16 bytes + either way once alignment is applied. + + Generic defaults are permitted on types but not on functions, so the entry + points need deciding rather than assuming. + +- [ ] **CW-2.3** -- Decide whether a 128-bit claim word ships at all, and + therefore whether `portable-atomic` becomes a dependency of a published crate. + `D-7` sets the burden of proof for a Cargo feature and `D-37` discharged it + conditionally; `CW-1.4` supplies the number both were missing. The engineer + has said 32-bit Windows deployment is not a present concern, which changes + `D-18`'s premise and must be recorded rather than assumed. + + **`CW-1.6`'s scope is decided by this item**: if `portable-atomic` is + declined, this crate must keep its `wide` implementation, because a layout the + queue crate cannot express is one the probe cannot instantiate. - [ ] **CW-2.4** -- Document the layouts as a choice, in the crate documentation - and the README, with the rollover table and the two axes a caller actually - trades between: outstanding reservations against time-to-recurrence. Lead with - the fact `CW-1.4` measured -- re-apportioning is free, widening is not -- so a - caller is not left assuming the safest option must be the slowest. State the - push rate the figures assume and that a draining queue cannot sustain the - fastest of them, or the numbers will be read as forecasts rather than as the - floor they are. + and the README, with the rollover table and the two axes a caller trades + between: outstanding reservations against time-to-recurrence. Lead with what + `CW-1.4` measured -- re-apportioning is free, widening is not -- so a caller + is not left assuming the safest option must be the slowest. State the push + rate the figures assume, and that a draining queue cannot sustain the fastest + of them. **Compiled, not merely written.** Any README example naming a layout is a - doctest per this repository's CONTRACT INTEGRITY rule, so a layout that is - renamed or removed breaks the build instead of leaving the documentation - teaching a name that no longer exists. - -- [ ] **CW-2.5** -- Sweep the existing `SH-14.1` disclosure once the layouts - land. `D-36` states the hazard in the crate documentation, the README, and - `reserving_mpsc`'s module documentation, each leading with "on every target, - not only 32-bit ones", and `lib.rs` separately claims the shape is "sound - below the wrap". Every one of those statements is scoped to a 32-bit position - and becomes wrong for a caller who selected a different layout. - - This is a blast-radius sweep, not an edit of the site someone reports: grep - the distinguishing terms across `src/`, `tests/`, `examples/` and `*.md` for - the crate and its dependents, fix every hit or say why it is out of scope, and - record the sweep in the commit message. + doctest per this repository's CONTRACT INTEGRITY rule, so a renamed or removed + layout breaks the build instead of leaving the documentation teaching a name + that no longer exists. + +- [ ] **CW-2.5** -- Reopen `D-36` with the measurement in hand, then sweep every + statement of the hazard. + + **`D-36`'s premise is falsified, and that is the finding, not the sweep.** It + decided 0.1.0 ships `SH-14.1` disclosed rather than fixed *because the fix is + a claim-protocol replacement (`D-35`) gated on an open question*. Re- + apportionment is a second fix that neither `D-36` nor `D-37` considered, and + `CW-1.4` measured it free. It does not eliminate the recurrence -- only moves + it -- but 8/56 moves it from about 37 seconds to about 20 years at the + disclosed rate, which takes `D-36`'s "computed exposure" from reachable in + under a minute to unreachable in any real deployment. + + So the question is whether the crate ships this hazard at all. Answer that + first; the sweep follows from the answer. + + The sweep is blast-radius, not an edit of one reported site: `D-36` states the + hazard in the crate documentation, the README, and `reserving_mpsc`'s module + documentation, each leading with "on every target, not only 32-bit ones", and + `lib.rs` separately claims the shape is "sound below the wrap". Every one is + scoped to a 32-bit position. Grep the distinguishing terms across `src/`, + `tests/`, `examples/` and `*.md` for the crate and its dependents, fix every + hit or say why it is out of scope, and record the sweep in the commit message. + +## M3: retire the duplicate + +- [ ] **CW-1.6** -- Delete the duplicated *implementation* in + [claim_layout.rs](src/claim_layout.rs), keeping only what `CW-2.3` leaves no + other way to measure. + + **This is not a decision about which layouts to offer.** That is settled -- + multiple layouts ship as caller-selectable options, per `M2`. This item is + only about the private copy of the reserving protocol in this crate, which + existed so the layouts could be measured without touching + `windows-waitable-queues`. + + **`M2` makes the copy obsolete.** Once the shipping crate takes the layout as + a compile-time parameter, the probe instantiates the *real* type at any layout + it wants to compare, including candidates that are not defaults -- so + exploring a new apportionment no longer needs a duplicate. + + Deleting it is not tidiness. A second implementation of the same protocol + drifts, and this one already did: `CW-1.4`'s first run measured 3.7x against + the shipping shape on an entirely different scaling curve, because the + duplicate had not cache-padded `head` and the claim word. Corrected, it still + sits about 1.26x off. A duplicate that diverges silently produces a + measurement that looks healthy and describes something nobody ships. From 1f7bb755e259e47faadd6f394cb0cbc54ac820a7 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 23:29:23 -0400 Subject: [PATCH 345/361] docs(platform-probes): merge CW-2.1 and CW-2.2, which cannot be verified apart Decoupling the reservation ceiling is numerically invisible at 32/32: MAX_RESERVED is 2^32-1 against a 2^31 capacity ceiling, so the cap can never bind and no test can reach it. It becomes observable only once a layout narrows the reservation half, so landing them separately would have committed a branch nothing could exercise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHECKLIST-claim-word-layout.md | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index d16ae804..b1c34128 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -82,29 +82,39 @@ review. That reasoning expired: `mikegrier/waitable-queues` has no pull request open, so there is no review to disturb, and the `u64` layouts need no new dependency at all -- only 64/64 does, which is `CW-2.3`. -- [ ] **CW-2.1** -- Decouple the reservation ceiling from the capacity, and - derive `BOUNDS_MAX` from both constraints rather than the ring bound alone. - - Deriving the ceiling from both constraints is not sufficient on its own: a - 16-bit `reserved` half would cap the capacity at 65535. What makes an - asymmetric split usable is capping *outstanding reservations* at - `MAX_RESERVED` while the capacity stays bounded only by the ring. +- [ ] **CW-2.1** -- Introduce the layout as a compile-time parameter, widen the + position to 64 bits, and decouple the reservation ceiling from the capacity. + + **Merged from two items during execution, because they cannot be verified + apart.** Decoupling the ceiling is numerically invisible at 32/32: + `MAX_RESERVED` is 2^32-1 while `BOUNDS_MAX` is 2^31, so a cap on outstanding + reservations can never bind and no test can reach it. It becomes observable + only once a layout makes the reservation half narrow. Landing them separately + would have meant committing a branch nothing could exercise and calling it + done. + + The three parts: + + - Cap *outstanding reservations* at `MAX_RESERVED` in `reserve`, and drop the + `BOUNDS_MAX <= MAX_RESERVED` const assertion that ties the capacity to the + reservation field. `BOUNDS_MAX` then follows from ring arithmetic and the + crate-wide bound alone. + - Widen `position`, `head`, and the per-slot `sequence` to 64 bits for every + layout, since a position of more than 32 bits cannot be read out through + `position_of`'s `u32`. Uniform 64-bit metadata is measured-safe rather than + assumed: `CW-1.4` compared 32/32 with 32-bit metadata against 16/48 with + 64-bit metadata and found no difference, and for a `u64` payload the slot is + 16 bytes either way once alignment is applied. + - Add the layout parameter with a default preserving today's behaviour. + Generic defaults are permitted on types but not on functions, so `bounded` + keeps its signature and returns the defaulted types, and a second entry + point names a layout explicitly. **This is a contract change**: the shipping shape promises every slot may be reserved at once, and this replaces that with a fixed reservation ceiling. A different promise rather than a broken one, but it must be stated, not slipped in. -- [ ] **CW-2.2** -- Introduce the layout as a compile-time parameter with named - layouts, keeping `head` and the per-slot `sequence` at 64 bits for every - layout. Uniform 64-bit metadata is measured-safe rather than assumed: - `CW-1.4` compared 32/32 with 32-bit metadata against 16/48 with 64-bit - metadata and found no difference, and for a `u64` payload the slot is 16 bytes - either way once alignment is applied. - - Generic defaults are permitted on types but not on functions, so the entry - points need deciding rather than assuming. - - [ ] **CW-2.3** -- Decide whether a 128-bit claim word ships at all, and therefore whether `portable-atomic` becomes a dependency of a published crate. `D-7` sets the burden of proof for a Cargo feature and `D-37` discharged it From 7c1c7752b675ccd812c1f86937de45a5fa0e750e Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 23:35:24 -0400 Subject: [PATCH 346/361] refactor(waitable-queues): widen the reserving position to 64 bits Behaviour-preserving groundwork for CW-2.1's configurable apportionment: the position, head, and per-slot sequence become 64-bit, so a position wider than 32 bits can be represented at all. The split stays 32/32, so nothing observable changes and all 310 tests pass unmodified in substance. The wrapping arithmetic is centralised in advance() and distance(). A position is now carried in a u64 but is only POSITION_BITS wide, so it wraps where the packing says rather than where the type does -- and an omitted mask is not a compile error, it is a position that escapes its half of the claim word. The Drop loop was the sharpest case: unmasked it would have walked past the wrap and never reached its terminating position. The packing tests were rewritten against POSITION_MASK and MAX_RESERVED rather than u32's extremes, which would have silently stopped testing the edges once the apportionment changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/reserving_mpsc.rs | 85 ++++++++++++------- .../src/reserving_mpsc/tests.rs | 25 ++++-- 2 files changed, 70 insertions(+), 40 deletions(-) diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 7cfd4d02..e47ea0e1 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -121,7 +121,7 @@ use core::cell::{Cell, UnsafeCell}; use core::fmt; use core::marker::PhantomData; use core::mem::MaybeUninit; -use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::io; use std::os::windows::io::{BorrowedHandle, OwnedHandle}; use std::sync::Arc; @@ -147,6 +147,28 @@ const POSITION_BITS: u32 = 32; /// Isolates the position half of the claim word. const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; +/// The position after `position`, wrapping at the width the packing gives it. +/// +/// **Centralised because the width is no longer the type's.** A position is +/// carried in a `u64` but is only [`POSITION_BITS`] wide, so it wraps where the +/// packing says rather than where `u64` would. Spelling that as +/// `wrapping_add(1) & POSITION_MASK` at each of the dozen sites that need it +/// would be a dozen chances to omit the mask, and an omitted mask is not a +/// compile error -- it is a position that escapes its half of the claim word +/// and silently corrupts the reservation count beside it. +const fn advance(position: u64) -> u64 { + position.wrapping_add(1) & POSITION_MASK +} + +/// How far `position` leads `head`, in the modular arithmetic the position +/// width defines. +/// +/// Masked for [`advance`]'s reason. When the position was a `u32` the type +/// supplied this wrap for free; it no longer does. +const fn distance(position: u64, head: u64) -> u64 { + position.wrapping_sub(head) & POSITION_MASK +} + /// What this shape accepts as a capacity. /// /// The minimum is two for the same reason [`slotwise_mpsc`](crate::slotwise_mpsc)'s is: with a @@ -236,8 +258,8 @@ const _: () = { }; /// Reads the position out of a claim word. -const fn position_of(word: u64) -> u32 { - (word & POSITION_MASK) as u32 +const fn position_of(word: u64) -> u64 { + word & POSITION_MASK } /// Reads the outstanding-reservation count out of a claim word. @@ -263,8 +285,8 @@ const fn reserved_of(word: u64) -> u32 { /// them apart. `|` is kept because it says "these are separate fields" where the /// others say "these are numbers"; the equivalence is recorded here so it is not /// investigated again. -const fn claim_word(reserved: u32, position: u32) -> u64 { - ((reserved as u64) << POSITION_BITS) | position as u64 +const fn claim_word(reserved: u32, position: u64) -> u64 { + ((reserved as u64) << POSITION_BITS) | (position & POSITION_MASK) } /// Creates a reserving multi-producer, single-consumer bounded array queue. @@ -346,7 +368,7 @@ fn build( // first serves, so the consumer sees it as unpublished. The // position's own value is the natural choice and matches the state // the slot returns to on every later lap. - sequence: AtomicU32::new(index as u32), + sequence: AtomicU64::new(index as u64), value: UnsafeCell::new(MaybeUninit::uninit()), }); } @@ -357,7 +379,7 @@ fn build( slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, - head: CacheAligned(AtomicU32::new(0)), + head: CacheAligned(AtomicU64::new(0)), claim: CacheAligned(AtomicU64::new(claim_word(0, 0))), producers: AtomicUsize::new(1), consumer_live: AtomicBool::new(true), @@ -389,7 +411,7 @@ struct Slot { /// that position anyway, to count free slots for the reservations -- so /// nothing ever stores a "free again" value and the consumer's `pop` is one /// store shorter than `slotwise_mpsc`'s. - sequence: AtomicU32, + sequence: AtomicU64, value: UnsafeCell>, } @@ -410,7 +432,7 @@ struct Shared { /// twice over: unlike `slotwise_mpsc`, *every* producer reads this on *every* push, /// so letting the claim word share the line would put the consumer's writes /// directly in their path. - head: CacheAligned, + head: CacheAligned, /// The outstanding-reservation count and the claim position, packed. /// /// One word because they must be claimed together; see the [module @@ -454,9 +476,9 @@ impl Shared { /// The capacity as the width the positions are counted in. /// /// Lossless by construction: [`BOUNDS`] caps the capacity at 2^31. - fn capacity_u32(&self) -> u32 { + fn capacity_u64(&self) -> u64 { debug_assert!(self.capacity <= BOUNDS_MAX); - self.capacity as u32 + self.capacity as u64 } /// Whether a *best-effort* claim may take the slot at `position`, given the @@ -475,14 +497,14 @@ impl Shared { /// pair of readings that never coexisted. Callers therefore treat a `false` /// as provisional and re-read the claim before reporting it (see /// [`Producer::push`]). - fn has_room_beyond_reservations(&self, position: u32, reserved: u32) -> bool { - let capacity = self.capacity_u32(); + fn has_room_beyond_reservations(&self, position: u64, reserved: u32) -> bool { + let capacity = self.capacity_u64(); debug_assert!( - reserved <= capacity, + u64::from(reserved) <= capacity, "reservations may never exceed the capacity they are claimed from" ); - let occupied = position.wrapping_sub(self.head.0.load(Ordering::Acquire)); - occupied < capacity - reserved + let occupied = distance(position, self.head.0.load(Ordering::Acquire)); + occupied < capacity - u64::from(reserved) } /// Items currently held, as a snapshot. @@ -499,7 +521,7 @@ impl Shared { fn len(&self) -> usize { let position = position_of(self.claim.0.load(Ordering::Relaxed)); let head = self.head.0.load(Ordering::Acquire); - (position.wrapping_sub(head) as usize).min(self.capacity) + (distance(position, head) as usize).min(self.capacity) } /// How many further items a best-effort push could still place, as a @@ -520,9 +542,9 @@ impl Shared { fn remaining(&self) -> usize { let word = self.claim.0.load(Ordering::Relaxed); let head = self.head.0.load(Ordering::Acquire); - let capacity = self.capacity_u32(); - let occupied = position_of(word).wrapping_sub(head).min(capacity); - let spoken_for = occupied.saturating_add(reserved_of(word)); + let capacity = self.capacity_u64(); + let occupied = distance(position_of(word), head).min(capacity); + let spoken_for = occupied.saturating_add(u64::from(reserved_of(word))); capacity.saturating_sub(spoken_for) as usize } @@ -543,7 +565,7 @@ impl Shared { // the load mean, at this point in the source, what it appears to mean. let position = self.head.0.load(Ordering::Acquire); let slot = &self.slots[position as usize & self.mask]; - slot.sequence.load(Ordering::Acquire) == position.wrapping_add(1) + slot.sequence.load(Ordering::Acquire) == advance(position) } /// Give up one unit of the producer count, signalling if it was the last. @@ -580,7 +602,7 @@ impl Shared { /// The caller must have claimed `position` by advancing the claim word, and /// must not have published it already. A position is claimed by exactly one /// producer, so this is the only writer of the slot. - unsafe fn publish(&self, position: u32, item: T) { + unsafe fn publish(&self, position: u64, item: T) { // Gated, matching `slotwise_mpsc`: untracked, this costs one predictable // branch on a field written once at construction, and the shared `head` // line is not touched at all. @@ -645,12 +667,12 @@ impl Shared { // invariant makes the condition already true in modification order -- // this waits to *see* it, not for it to *become* true. let mut head = self.head.0.load(Ordering::Acquire); - while position.wrapping_sub(head) >= self.capacity_u32() { + while distance(position, head) >= self.capacity_u64() { std::hint::spin_loop(); head = self.head.0.load(Ordering::Acquire); } if self.metrics.tracks_high_water() { - let depth = position.wrapping_sub(head).wrapping_add(1) as usize; + let depth = (distance(position, head) + 1) as usize; // **The clamp is unreachable from here, and is kept deliberately.** // The wait above exits only once `position - head < capacity`, so // `depth <= capacity` already holds and `min` never binds. It was @@ -691,8 +713,7 @@ impl Shared { // and this is what forbids the compiler and the processor from moving it // earlier. Until it lands, the consumer sees the slot as // claimed-but-empty and skips it. - slot.sequence - .store(position.wrapping_add(1), Ordering::Release); + slot.sequence.store(advance(position), Ordering::Release); // After the publication, never before: the doorbell says "there is // something to take", and that must not become true before the item is @@ -724,7 +745,7 @@ impl Drop for Shared { let tail = position_of(*self.claim.0.get_mut()); let mut position = head; while position != tail { - let published = position.wrapping_add(1); + let published = advance(position); let slot = &mut self.slots[position as usize & mask]; if *slot.sequence.get_mut() == published { // SAFETY: the slot's sequence says the producer finished writing @@ -734,7 +755,7 @@ impl Drop for Shared { let item = unsafe { slot.value.get_mut().assume_init_read() }; self.teardown.dispose(item); } - position = position.wrapping_add(1); + position = advance(position); } } } @@ -812,7 +833,7 @@ impl Producer { // than have its increment silently overwritten. match self.shared.claim.0.compare_exchange_weak( word, - claim_word(reserved, position.wrapping_add(1)), + claim_word(reserved, advance(position)), Ordering::Relaxed, Ordering::Relaxed, ) { @@ -1032,7 +1053,7 @@ impl Reservation { match self.shared.claim.0.compare_exchange_weak( word, - claim_word(reserved - 1, position.wrapping_add(1)), + claim_word(reserved - 1, advance(position)), Ordering::Relaxed, Ordering::Relaxed, ) { @@ -1136,7 +1157,7 @@ impl Consumer { let slot = &self.shared.slots[position as usize & self.shared.mask]; // Acquire: pairs with the producer's release store, so an item it // published is visible here. - if slot.sequence.load(Ordering::Acquire) != position.wrapping_add(1) { + if slot.sequence.load(Ordering::Acquire) != advance(position) { return None; } @@ -1158,7 +1179,7 @@ impl Consumer { self.shared .head .0 - .store(position.wrapping_add(1), Ordering::Release); + .store(advance(position), Ordering::Release); Some(item) } diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index 12875820..c574ce51 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -13,8 +13,8 @@ //! slot -- so the interesting cases all put the queue under pressure first. use super::{ - BOUNDS_MAX, Consumer, Producer, Reservation, bounded, bounded_with, claim_word, position_of, - reserved_of, + BOUNDS_MAX, Consumer, MAX_RESERVED, POSITION_MASK, Producer, Reservation, advance, bounded, + bounded_with, claim_word, position_of, reserved_of, }; use crate::race_hooks; use crate::{Disposal, Options}; @@ -63,8 +63,14 @@ fn fill(producer: &Producer, item: T) -> usize { #[test] fn the_claim_word_round_trips_both_halves() { - for &reserved in &[0_u32, 1, 2, 1000, u32::MAX - 1, u32::MAX] { - for &position in &[0_u32, 1, 2, 1000, u32::MAX - 1, u32::MAX] { + // Written against the split's own constants rather than against `u32`'s + // extremes, because the position is no longer 32 bits by definition: it is + // carried in a `u64` and bounded by `POSITION_MASK`. Spelling the edges as + // `u32::MAX` would have quietly stopped testing the edges the moment the + // apportionment changed, while still passing. + let max_reserved = MAX_RESERVED as u32; + for &reserved in &[0_u32, 1, 2, 1000, max_reserved - 1, max_reserved] { + for &position in &[0_u64, 1, 2, 1000, POSITION_MASK - 1, POSITION_MASK] { let word = claim_word(reserved, position); assert_eq!( (reserved_of(word), position_of(word)), @@ -79,14 +85,14 @@ fn the_claim_word_round_trips_both_halves() { fn the_two_halves_do_not_bleed_into_each_other() { // The mistake packing invites: a position that wraps must not carry into // the reservation count, and a count must not appear as a position. - let word = claim_word(0, u32::MAX); + let word = claim_word(0, POSITION_MASK); assert_eq!( reserved_of(word), 0, "a maximal position leaves the count at zero" ); - let word = claim_word(u32::MAX, 0); + let word = claim_word(MAX_RESERVED as u32, 0); assert_eq!( position_of(word), 0, @@ -96,7 +102,7 @@ fn the_two_halves_do_not_bleed_into_each_other() { // And an increment of the position at its maximum wraps within its own half // rather than incrementing the count, which is what the queue relies on // every time a position laps. - let wrapped = claim_word(7, u32::MAX.wrapping_add(1)); + let wrapped = claim_word(7, advance(POSITION_MASK)); assert_eq!((reserved_of(wrapped), position_of(wrapped)), (7, 0)); } @@ -1360,7 +1366,10 @@ fn publish_waits_for_a_head_that_has_freed_the_slot() { // `send` claims position 0, so this leaves `position - head == 11` on a // four-slot queue: a view in which the slot is not free. - tx.shared.head.0.store(u32::MAX - 10, Ordering::Release); + tx.shared + .head + .0 + .store(POSITION_MASK - 10, Ordering::Release); // `Arc` rather than a `static`, for the reason `DropCounter` // gives: tests share a process, so a module-scope flag would be visible to From 0016682603220831417f8038de866eaa7b51b8f4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 23:45:35 -0400 Subject: [PATCH 347/361] feat(waitable-queues)!: make the reserving claim-word layout caller-selectable Offers `Balanced` (32/32, the default), `Enduring` (16/48), and `Perpetual` (8/56) as compile-time choices, and decouples the reservation ceiling from the capacity so that an asymmetric division is usable at all. The claim word packs a reservation count and a position into one `u64` because one compare-and-swap must update both. The 32/32 split was not forced by the platform: it followed from requiring the count's half to hold the entire capacity, since every slot could be reserved at once. Capping outstanding reservations at the layout's own ceiling instead leaves the capacity bounded only by the ring, which is what lets the position take 48 or 56 bits. That is the breaking part, and it is a changed promise rather than a broken one: `reserve` now returns `None` once `L::MAX_RESERVED` reservations are outstanding, however empty the queue is. Under the default layout the ceiling is 2^32 against a 2^31 capacity, so it can never bind and nothing observable changes -- which is also why this could not be verified without the layouts, and why the two checklist items were merged during execution. What it buys, measured by probe-queue-contention: a deeper position costs nothing outside noise, because all three issue the same `lock cmpxchg` on the same `u64` and differ only in shift and mask constants. The recurrence behind SH-14.1 moves from 2^32 pushes to 2^48 or 2^56 -- roughly 37 seconds, 28 days, and 20 years at the crate's disclosed sustained rate. `ClaimLayout::VALID` is forced by `build` rather than left to be evaluated, because an associated constant in a generic context is only checked where it is used. Verified by sabotage: a layout with 31 position bits fails the build naming the assertion, and a first attempt that appeared to pass turned out not to have applied the edit at all. Completed item: CW-2.1: Introduce the layout as a compile-time parameter, widen the position to 64 bits, and decouple the reservation ceiling from the capacity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/reserving_mpsc.rs | 533 ++++++++++++------ .../src/reserving_mpsc/tests.rs | 149 ++++- 2 files changed, 500 insertions(+), 182 deletions(-) diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index e47ea0e1..88a8f690 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -136,28 +136,187 @@ use crate::error::{CapacityError, Disconnected, PushError, RecvError, RecvTimeou use crate::metrics::Metrics; use crate::options::Options; -/// How many of the claim word's bits carry the position. +/// How the claim word's 64 bits are divided between the two things it packs. /// -/// The other half carries the outstanding-reservation count. Changing this -/// changes [`BOUNDS`] and is a breaking change to the capacities this shape -/// accepts; see the [module documentation](self) for why an even split is the -/// only sensible one. -const POSITION_BITS: u32 = 32; +/// The word carries an outstanding-reservation count and a claim position, and +/// it must carry both because a single compare-and-swap has to update them +/// together (see the [module documentation](self)). Dividing 64 bits between +/// them is therefore a trade, and this trait is where a caller chooses which +/// side to spend them on. +/// +/// **The two things being traded are not equally valuable, and the shipping +/// default spends the bits on the less valuable one.** The reservation count +/// bounds how many messages may be held in flight at once -- in practice the +/// number of producers mid-send, so hundreds or thousands. The position decides +/// how many pushes occur before it recurs, and a recurrence is the `SH-14.1` +/// hazard: a producer descheduled across a full wrap can claim against a +/// numerically identical but generations-later value. +/// +/// | Layout | reserved / position | Outstanding reservations | Pushes to recurrence | +/// |---|---|---|---| +/// | [`Balanced`] | 32 / 32 | 2^32 | 2^32 | +/// | [`Enduring`] | 16 / 48 | 65,535 | 2^48 | +/// | [`Perpetual`] | 8 / 56 | 255 | 2^56 | +/// +/// At this crate's disclosed sustained rate of about 116 million pushes per +/// second, those recurrences are roughly **37 seconds**, **28 days**, and +/// **20 years** respectively. The rate is the one `reserving_mpsc`'s own hazard +/// note quotes; a queue that must drain cannot sustain the fastest rate +/// measured, so treat these as a floor on time rather than a forecast. +/// +/// **Choosing a deeper position costs nothing measurable.** All three issue the +/// same `lock cmpxchg` on the same `u64` and differ only in shift and mask +/// constants; a probe comparing them found no difference outside noise. The +/// trade is entirely against the reservation ceiling. +/// +/// This trait is sealed: the layouts are a fixed set because each one's +/// constants are checked against each other at compile time, and a caller +/// supplying its own could pick a division this shape cannot honour. +pub trait ClaimLayout: sealed::Sealed { + /// How many of the claim word's bits carry the position. + const POSITION_BITS: u32; + + /// Isolates the position half of the claim word. + const POSITION_MASK: u64 = (1u64 << Self::POSITION_BITS) - 1; + + /// The largest outstanding-reservation count the word's other half holds. + /// + /// **A ceiling on reservations, not on capacity.** An earlier form of this + /// shape required the count's half to be wide enough for the whole + /// capacity, because every slot could be reserved at once. That is what made + /// a large capacity consume the position's bits. Capping the reservations + /// instead leaves the capacity bounded only by the ring. + const MAX_RESERVED: u64 = u64::MAX >> Self::POSITION_BITS; + + /// The largest capacity this layout accepts. + /// + /// A wrapping position difference is unambiguous only up to half the + /// position space, and the crate-wide ceiling applies as well -- on a + /// 32-bit target it is the narrower of the two, and a shift by the position + /// width would overflow `usize` outright. + const BOUNDS_MAX: usize = { + let ring_bits = Self::POSITION_BITS - 1; + if ring_bits >= usize::BITS { + MAX_ADMISSIBLE_CAPACITY + } else { + let packed = 1_usize << ring_bits; + if packed <= MAX_ADMISSIBLE_CAPACITY { + packed + } else { + MAX_ADMISSIBLE_CAPACITY + } + } + }; + + /// The relationships this layout's constants depend on. + /// + /// **Forced at construction rather than left to be evaluated.** An + /// associated constant in a generic context is only evaluated where it is + /// used, so assertions written here and never mentioned would compile for + /// every layout including a broken one. [`build`] names this so that + /// creating a queue is what checks it. + /// + /// Note what is deliberately *not* asserted: that `BOUNDS_MAX` is at most + /// `MAX_RESERVED`. That assertion is what previously tied the capacity to + /// the reservation field, and removing it is the point of the decoupling + /// above. + const VALID: () = { + assert!( + Self::POSITION_BITS >= 32, + "the reservation count is read out as a u32, so a count field wider than 32 bits -- \ + that is, a position narrower than 32 -- would be truncated on the way out" + ); + assert!( + Self::POSITION_BITS < 64, + "the count needs at least one bit, so the position cannot take the whole word" + ); + assert!( + Self::MAX_RESERVED <= u32::MAX as u64, + "the count is read back out through `reserved_of`'s cast to `u32`, so a field the \ + word could hold but the cast could not would make this constant's name a lie" + ); + assert!( + Self::MAX_RESERVED >= 1, + "a layout that permits no reservation at all would make `reserve` always fail, which \ + is the one capability this shape exists to provide" + ); + assert!( + Self::BOUNDS_MAX >= 2, + "two is the smallest capacity this shape accepts, so a layout offering less accepts \ + nothing" + ); + assert!( + Self::BOUNDS_MAX.is_power_of_two(), + "the maximum is offered to a caller as a capacity it could use, so it must itself be \ + one this shape would accept" + ); + assert!( + Self::BOUNDS_MAX <= WRAPPING_MAX_CAPACITY, + "a shape may be narrower than the crate-wide bound but never wider" + ); + }; +} -/// Isolates the position half of the claim word. -const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; +mod sealed { + /// Prevents a caller outside this crate from adding a layout. + pub trait Sealed {} +} + +/// The shipping division: 32 bits each. +/// +/// Holds 2^32 outstanding reservations and recurs after 2^32 pushes -- about +/// **37 seconds** of sustained maximum-rate pushing. This is the default +/// because it is what the shape shipped with, not because it is the best +/// choice: the reservation ceiling it buys is far beyond any real use, and it +/// is paid for with the whole of the `SH-14.1` exposure. Prefer [`Enduring`] or +/// [`Perpetual`] unless you genuinely hold more than 65,535 reservations at +/// once. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Balanced; +impl sealed::Sealed for Balanced {} +impl ClaimLayout for Balanced { + const POSITION_BITS: u32 = 32; +} + +/// A deeper position: 16 bits of reservations, 48 of position. +/// +/// Holds 65,535 outstanding reservations and recurs after 2^48 pushes -- about +/// **28 days** of sustained maximum-rate pushing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Enduring; +impl sealed::Sealed for Enduring {} +impl ClaimLayout for Enduring { + const POSITION_BITS: u32 = 48; +} + +/// The deepest position: 8 bits of reservations, 56 of position. +/// +/// Holds 255 outstanding reservations and recurs after 2^56 pushes -- about +/// **20 years** of sustained maximum-rate pushing, which puts the recurrence +/// beyond any real deployment rather than merely far away. +/// +/// 255 reservations is the whole of the trade, and it is a real limit rather +/// than a nominal one: [`Producer::reserve`] returns `None` once that many are +/// outstanding, however empty the queue is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Perpetual; +impl sealed::Sealed for Perpetual {} +impl ClaimLayout for Perpetual { + const POSITION_BITS: u32 = 56; +} -/// The position after `position`, wrapping at the width the packing gives it. +/// The position after `position`, wrapping at the width the layout gives it. /// /// **Centralised because the width is no longer the type's.** A position is -/// carried in a `u64` but is only [`POSITION_BITS`] wide, so it wraps where the +/// carried in a `u64` but is only `L::POSITION_BITS` wide, so it wraps where the /// packing says rather than where `u64` would. Spelling that as -/// `wrapping_add(1) & POSITION_MASK` at each of the dozen sites that need it +/// `wrapping_add(1) & L::POSITION_MASK` at each of the dozen sites that need it /// would be a dozen chances to omit the mask, and an omitted mask is not a /// compile error -- it is a position that escapes its half of the claim word /// and silently corrupts the reservation count beside it. -const fn advance(position: u64) -> u64 { - position.wrapping_add(1) & POSITION_MASK +#[inline] +const fn advance(position: u64) -> u64 { + position.wrapping_add(1) & L::POSITION_MASK } /// How far `position` leads `head`, in the modular arithmetic the position @@ -165,106 +324,70 @@ const fn advance(position: u64) -> u64 { /// /// Masked for [`advance`]'s reason. When the position was a `u32` the type /// supplied this wrap for free; it no longer does. -const fn distance(position: u64, head: u64) -> u64 { - position.wrapping_sub(head) & POSITION_MASK +#[inline] +const fn distance(position: u64, head: u64) -> u64 { + position.wrapping_sub(head) & L::POSITION_MASK } -/// What this shape accepts as a capacity. +/// The two handles a constructor hands back. /// -/// The minimum is two for the same reason [`slotwise_mpsc`](crate::slotwise_mpsc)'s is: with a -/// single slot, "published at `p`" and "free again on the next lap" would be the -/// same sequence number. -/// -/// The maximum is 2^31 rather than the crate-wide bound, because a position is -/// half of the packed claim word rather than a whole [`usize`]. A wrapping -/// 32-bit difference is unambiguous only up to 2^31, and that is exactly the -/// most items this shape can hold. -/// -/// **Bounded by the `usize` width as well as by the packing, because on a -/// 32-bit target the packing is the *wider* of the two.** The crate-wide -/// ceiling below which a wrapping position difference stays unambiguous is -/// `usize::MAX / 2`, which on a 32-bit target is `2^31 - 1` -- narrower than -/// the packing. A flat `1 << 31` therefore exceeds it, and the const assertion -/// below rejects it, failing the build for every capacity including the small -/// valid ones. Taking the narrower of the two limits keeps this a power of two -/// on every target, which matters because the value is offered to a caller as a -/// capacity it could actually use. -pub const BOUNDS_MAX: usize = { - let packed = 1_usize << (POSITION_BITS - 1); - if packed <= MAX_ADMISSIBLE_CAPACITY { - packed - } else { - MAX_ADMISSIBLE_CAPACITY - } -}; - -/// The capacities this shape accepts. See [`BOUNDS_MAX`]. -const BOUNDS: Bounds = Bounds { - min: 2, - max: BOUNDS_MAX, -}; - -/// The largest reservation count the word's other half can hold. -const MAX_RESERVED: u64 = u64::MAX >> POSITION_BITS; - -// The relationships the packing depends on, checked by the compiler rather than -// by a test. They are facts about constants, so a test could only ever report -// after the fact, on a build somebody chose to run; here, moving the split -// without re-deriving what depends on it does not compile. -// -// **Note what is deliberately NOT asserted.** That `BOUNDS_MAX` equals -// `1 << (POSITION_BITS - 1)` is tautological -- it is the definition -- and an -// earlier version of this block asserted exactly that, which is to say nothing. -// Widening the position to 40 bits sailed past it while silently narrowing the -// reservation field to 24, which is the real breakage. The assertions below are -// the ones that catch it. +/// Named because the layout parameter makes the pair long enough to obscure the +/// error type beside it, not because a caller is expected to write it: the +/// constructors return it and a caller destructures it immediately. +pub type Pair = (Producer, Consumer); + +// The layouts' relationship to one another, checked by the compiler rather than +// by a test. These are facts about constants, so a test could only report after +// the fact, on a build somebody chose to run -- and the trade they describe is +// the whole reason more than one layout exists. const _: () = { assert!( - POSITION_BITS >= 32, - "the reservation count is read out as a u32, so a field wider than 32 bits would be \ - truncated on the way out" + ::MAX_RESERVED < ::MAX_RESERVED, + "a deeper position must cost reservations, or it would be free and there would be no \ + choice to offer" ); assert!( - MAX_RESERVED <= u32::MAX as u64, - "the count is read back out through `reserved_of`'s cast to `u32`, so a field the word \ - could hold but the cast could not would make this constant's name a lie -- and the \ - assertion below would then be satisfied by a ceiling that truncates on the way out" + ::MAX_RESERVED < ::MAX_RESERVED, + "the layouts must order consistently, or the table documenting them is wrong" ); assert!( - BOUNDS_MAX as u64 <= MAX_RESERVED, - "every slot may be reserved at once, so the count's half of the word must be able to hold \ - the whole capacity -- widening the position narrows this and is the way the packing \ - actually breaks" + ::POSITION_MASK > ::POSITION_MASK, + "and the reservations given up must buy positions with them" ); assert!( - BOUNDS.max <= WRAPPING_MAX_CAPACITY, - "a shape may be narrower than the crate-wide bound but never wider" + ::POSITION_MASK > ::POSITION_MASK, + "as above, across the whole ordering" ); - assert!( - BOUNDS.max.is_power_of_two(), - "the maximum is offered to a caller as a capacity it could use, so it must itself be one \ - this shape would accept -- and on a 32-bit target the crate-wide ceiling is not a power \ - of two, so clamping to it directly would have produced a suggestion that is rejected" - ); - assert!( - BOUNDS.min <= BOUNDS.max, - "a shape that accepts nothing would reject every capacity with a suggestion it would also \ - reject" - ); - // The clamp's own shape -- that it is the *widest* such power of two -- is - // asserted where it is defined, in `capacity::MAX_ADMISSIBLE_CAPACITY`, so - // every shape that clamps against it inherits the check rather than - // restating it. }; +/// The largest capacity the default layout accepts. +/// +/// Retained as a plain constant because it is public API and a caller may name +/// it. It is [`Balanced`]'s ceiling; other layouts have their own, reachable as +/// `::BOUNDS_MAX`. +pub const BOUNDS_MAX: usize = ::BOUNDS_MAX; + +/// The capacities a layout accepts. +/// +/// The minimum is two for the same reason [`slotwise_mpsc`](crate::slotwise_mpsc)'s is: with a +/// single slot, "published at `p`" and "free again on the next lap" would be the +/// same sequence number. The maximum is the layout's own, since the position +/// width decides how large a wrapping difference stays unambiguous. +const fn bounds() -> Bounds { + Bounds { + min: 2, + max: L::BOUNDS_MAX, + } +} + /// Reads the position out of a claim word. -const fn position_of(word: u64) -> u64 { - word & POSITION_MASK +const fn position_of(word: u64) -> u64 { + word & L::POSITION_MASK } /// Reads the outstanding-reservation count out of a claim word. -const fn reserved_of(word: u64) -> u32 { - (word >> POSITION_BITS) as u32 +const fn reserved_of(word: u64) -> u32 { + (word >> L::POSITION_BITS) as u32 } /// Builds a claim word from its two halves. @@ -285,8 +408,8 @@ const fn reserved_of(word: u64) -> u32 { /// them apart. `|` is kept because it says "these are separate fields" where the /// others say "these are numbers"; the equivalence is recorded here so it is not /// investigated again. -const fn claim_word(reserved: u32, position: u64) -> u64 { - ((reserved as u64) << POSITION_BITS) | (position & POSITION_MASK) +const fn claim_word(reserved: u32, position: u64) -> u64 { + ((reserved as u64) << L::POSITION_BITS) | (position & L::POSITION_MASK) } /// Creates a reserving multi-producer, single-consumer bounded array queue. @@ -328,10 +451,54 @@ const fn claim_word(reserved: u32, position: u64) -> u64 { /// assert_eq!(rx.pop(), Some(99)); /// # Ok::<(), windows_waitable_queues::CapacityError>(()) /// ``` -pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), CapacityError> { +pub fn bounded(capacity: usize) -> Result, CapacityError> { build(capacity, Options::new()) } +/// Creates a queue whose claim word is divided as `L` says. +/// +/// [`bounded`] is this with `L` left at [`Balanced`], and the two are otherwise +/// identical. See [`ClaimLayout`] for what the division trades: a lower ceiling +/// on outstanding reservations against a longer run before the claim position +/// recurs. +/// +/// A separate entry point rather than a defaulted parameter on [`bounded`], +/// because Rust permits generic defaults on types but not on functions. The +/// types carry the default, so a caller who never names a layout never sees +/// one. +/// +/// ``` +/// use windows_waitable_queues::reserving_mpsc::{self, Perpetual}; +/// +/// // 255 outstanding reservations, and a claim position that recurs after +/// // 2^56 pushes rather than 2^32. +/// let (tx, rx) = reserving_mpsc::bounded_as::(4)?; +/// tx.push(1).expect("an empty queue has room"); +/// assert_eq!(rx.pop(), Some(1)); +/// # Ok::<(), windows_waitable_queues::CapacityError>(()) +/// ``` +/// +/// # Errors +/// +/// As [`bounded`], against `L`'s own capacity ceiling. +pub fn bounded_as(capacity: usize) -> Result, CapacityError> { + build(capacity, Options::new()) +} + +/// Creates a queue with both a layout and non-default behaviour. +/// +/// [`bounded_as`] with [`Options`], as [`bounded_with`] is to [`bounded`]. +/// +/// # Errors +/// +/// As [`bounded`], against `L`'s own capacity ceiling. +pub fn bounded_with_as( + capacity: usize, + options: Options, +) -> Result, CapacityError> { + build(capacity, options) +} + /// Creates a queue with something other than the default behaviour. /// /// Identical to [`bounded`] except for what [`Options`] asks for. @@ -349,17 +516,14 @@ pub fn bounded(capacity: usize) -> Result<(Producer, Consumer), Capacit /// # Errors /// /// As [`bounded`]. -pub fn bounded_with( - capacity: usize, - options: Options, -) -> Result<(Producer, Consumer), CapacityError> { +pub fn bounded_with(capacity: usize, options: Options) -> Result, CapacityError> { build(capacity, options) } -fn build( +fn build( capacity: usize, options: Options, -) -> Result<(Producer, Consumer), CapacityError> { - validate_capacity(capacity, BOUNDS)?; +) -> Result, CapacityError> { + validate_capacity(capacity, bounds::())?; let mut slots = Vec::with_capacity(capacity); for index in 0..capacity { @@ -373,14 +537,21 @@ fn build( }); } + // Names `L::VALID` so the layout's own const assertions are evaluated. + // An associated constant in a generic context is only checked where it is + // used, so a layout whose constants contradict each other would otherwise + // compile untouched until something happened to mention them. + let () = L::VALID; + let shared = Arc::new(Shared { + layout: PhantomData, teardown: Teardown::new(options.disposal), metrics: Metrics::new(options.track_high_water), slots: slots.into_boxed_slice(), mask: capacity - 1, capacity, head: CacheAligned(AtomicU64::new(0)), - claim: CacheAligned(AtomicU64::new(claim_word(0, 0))), + claim: CacheAligned(AtomicU64::new(claim_word::(0, 0))), producers: AtomicUsize::new(1), consumer_live: AtomicBool::new(true), doorbell: Doorbell::new(), @@ -415,7 +586,12 @@ struct Slot { value: UnsafeCell>, } -struct Shared { +struct Shared { + /// Ties the shared state to the layout its arithmetic is done in. + /// + /// Carries no data: the layout is entirely a set of compile-time constants, + /// so this exists only because a type parameter must appear in the type. + layout: PhantomData, /// What becomes of undrained items at teardown. /// /// Read only by [`Shared::drop`], which holds `&mut self`, so it needs no @@ -468,11 +644,11 @@ struct Shared { // is private, no method reads it, and the only access is from Drop, which // holds &mut self and runs when the last handle is already gone. So no two // threads can reach it at all, concurrently or otherwise. -unsafe impl Sync for Shared {} +unsafe impl Sync for Shared {} // SAFETY: as above; sending the shared state is sending the items it holds. -unsafe impl Send for Shared {} +unsafe impl Send for Shared {} -impl Shared { +impl Shared { /// The capacity as the width the positions are counted in. /// /// Lossless by construction: [`BOUNDS`] caps the capacity at 2^31. @@ -503,7 +679,7 @@ impl Shared { u64::from(reserved) <= capacity, "reservations may never exceed the capacity they are claimed from" ); - let occupied = distance(position, self.head.0.load(Ordering::Acquire)); + let occupied = distance::(position, self.head.0.load(Ordering::Acquire)); occupied < capacity - u64::from(reserved) } @@ -519,9 +695,9 @@ impl Shared { /// subtraction produce a number near `u32::MAX`. A bounded queue must never /// report holding more than it can. fn len(&self) -> usize { - let position = position_of(self.claim.0.load(Ordering::Relaxed)); + let position = position_of::(self.claim.0.load(Ordering::Relaxed)); let head = self.head.0.load(Ordering::Acquire); - (distance(position, head) as usize).min(self.capacity) + (distance::(position, head) as usize).min(self.capacity) } /// How many further items a best-effort push could still place, as a @@ -543,8 +719,8 @@ impl Shared { let word = self.claim.0.load(Ordering::Relaxed); let head = self.head.0.load(Ordering::Acquire); let capacity = self.capacity_u64(); - let occupied = distance(position_of(word), head).min(capacity); - let spoken_for = occupied.saturating_add(u64::from(reserved_of(word))); + let occupied = distance::(position_of::(word), head).min(capacity); + let spoken_for = occupied.saturating_add(u64::from(reserved_of::(word))); capacity.saturating_sub(spoken_for) as usize } @@ -565,7 +741,7 @@ impl Shared { // the load mean, at this point in the source, what it appears to mean. let position = self.head.0.load(Ordering::Acquire); let slot = &self.slots[position as usize & self.mask]; - slot.sequence.load(Ordering::Acquire) == advance(position) + slot.sequence.load(Ordering::Acquire) == advance::(position) } /// Give up one unit of the producer count, signalling if it was the last. @@ -667,12 +843,12 @@ impl Shared { // invariant makes the condition already true in modification order -- // this waits to *see* it, not for it to *become* true. let mut head = self.head.0.load(Ordering::Acquire); - while distance(position, head) >= self.capacity_u64() { + while distance::(position, head) >= self.capacity_u64() { std::hint::spin_loop(); head = self.head.0.load(Ordering::Acquire); } if self.metrics.tracks_high_water() { - let depth = (distance(position, head) + 1) as usize; + let depth = (distance::(position, head) + 1) as usize; // **The clamp is unreachable from here, and is kept deliberately.** // The wait above exits only once `position - head < capacity`, so // `depth <= capacity` already holds and `min` never binds. It was @@ -713,7 +889,8 @@ impl Shared { // and this is what forbids the compiler and the processor from moving it // earlier. Until it lands, the consumer sees the slot as // claimed-but-empty and skips it. - slot.sequence.store(advance(position), Ordering::Release); + slot.sequence + .store(advance::(position), Ordering::Release); // After the publication, never before: the doorbell says "there is // something to take", and that must not become true before the item is @@ -728,7 +905,7 @@ impl Shared { } } -impl Drop for Shared { +impl Drop for Shared { fn drop(&mut self) { // Every handle is gone, so no synchronization is needed and the // positions can be read directly. A slot between the two positions still @@ -742,10 +919,10 @@ impl Drop for Shared { // instead of leaving it to that argument. let mask = self.mask; let head = *self.head.0.get_mut(); - let tail = position_of(*self.claim.0.get_mut()); + let tail = position_of::(*self.claim.0.get_mut()); let mut position = head; while position != tail { - let published = advance(position); + let published = advance::(position); let slot = &mut self.slots[position as usize & mask]; if *slot.sequence.get_mut() == published { // SAFETY: the slot's sequence says the producer finished writing @@ -755,7 +932,7 @@ impl Drop for Shared { let item = unsafe { slot.value.get_mut().assume_init_read() }; self.teardown.dispose(item); } - position = advance(position); + position = advance::(position); } } } @@ -765,14 +942,14 @@ impl Drop for Shared { /// [`Clone`], so producers multiply by cloning rather than by sharing: each /// thread owns its own handle. Not [`Sync`], so a handle is used by one thread /// at a time. -pub struct Producer { - shared: Arc>, +pub struct Producer { + shared: Arc>, /// Removes [`Sync`] without removing [`Send`]. A [`Cell`] is exactly that /// shape, and no value of it is ever created. not_sync: PhantomData>, } -impl Producer { +impl Producer { /// Appends an item, best-effort. /// /// **Cannot take a reserved slot.** A queue with one free slot and one @@ -790,8 +967,8 @@ impl Producer { // costs a retry rather than correctness. let mut word = self.shared.claim.0.load(Ordering::Relaxed); let position = loop { - let position = position_of(word); - let reserved = reserved_of(word); + let position = position_of::(word); + let reserved = reserved_of::(word); #[cfg(test)] crate::race_hooks::CLAIM.run(); @@ -833,7 +1010,7 @@ impl Producer { // than have its increment silently overwritten. match self.shared.claim.0.compare_exchange_weak( word, - claim_word(reserved, advance(position)), + claim_word::(reserved, advance::(position)), Ordering::Relaxed, Ordering::Relaxed, ) { @@ -860,11 +1037,11 @@ impl Producer { /// The queue stays connected while a reservation is outstanding, so a /// consumer will not be told the stream ended and then handed the item. #[must_use = "a reservation withholds capacity from every other producer until it is used or dropped"] - pub fn reserve(&self) -> Option> { + pub fn reserve(&self) -> Option> { let mut word = self.shared.claim.0.load(Ordering::Relaxed); loop { - let position = position_of(word); - let reserved = reserved_of(word); + let position = position_of::(word); + let reserved = reserved_of::(word); #[cfg(test)] crate::race_hooks::CLAIM.run(); @@ -881,13 +1058,29 @@ impl Producer { return None; } + // The count's own half of the word can overflow into the position's + // before the capacity is exhausted, once a layout gives it fewer + // bits than the capacity has slots. Refusing here is what decouples + // the two ceilings: the capacity is bounded by the ring, and the + // reservations by whatever the layout left room for. + // + // **Checked against the word this iteration read, not against a + // separate load.** The count and the position share the word + // precisely so a decision about one cannot be made against a stale + // reading of the other, and the exchange below re-validates the + // whole word -- so a racing `reserve` that got there first makes + // this one fail and re-read rather than exceed the ceiling. + if u64::from(reserved) >= L::MAX_RESERVED { + return None; + } + // The position is carried through unchanged: a reservation claims // capacity, not an order. Where the item lands is decided when the // reservation is redeemed, so a slot held for a long time does not // stall everything queued behind it. match self.shared.claim.0.compare_exchange_weak( word, - claim_word(reserved + 1, position), + claim_word::(reserved + 1, position), Ordering::Relaxed, Ordering::Relaxed, ) { @@ -930,7 +1123,7 @@ impl Producer { /// snapshot. #[must_use] pub fn outstanding_reservations(&self) -> usize { - reserved_of(self.shared.claim.0.load(Ordering::Relaxed)) as usize + reserved_of::(self.shared.claim.0.load(Ordering::Relaxed)) as usize } /// Whether the next best-effort push would be refused, as a snapshot. @@ -962,7 +1155,7 @@ impl Producer { } } -impl Clone for Producer { +impl Clone for Producer { fn clone(&self) -> Self { // Relaxed, for the reason given in `reserve`. self.shared.producers.fetch_add(1, Ordering::Relaxed); @@ -977,7 +1170,7 @@ impl Clone for Producer { // would make a handle to a queue of non-`Debug` items un-printable for no // reason. The item type is not the handle's business, so the handle reports the // queue's state instead. -impl fmt::Debug for Producer { +impl fmt::Debug for Producer { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("reserving_mpsc::Producer") .field("capacity", &self.capacity()) @@ -989,7 +1182,7 @@ impl fmt::Debug for Producer { } } -impl Drop for Producer { +impl Drop for Producer { fn drop(&mut self) { self.shared.release_producer(); } @@ -1006,14 +1199,14 @@ impl Drop for Producer { /// /// Dropping it returns the slot to the queue. #[must_use = "a reservation withholds capacity from every other producer until it is used or dropped"] -pub struct Reservation { - shared: Arc>, +pub struct Reservation { + shared: Arc>, /// See [`Producer::not_sync`]. A reservation may be *moved* between threads /// but is used by one at a time, exactly like the handle that made it. not_sync: PhantomData>, } -impl Reservation { +impl Reservation { /// Delivers into the reserved slot. /// /// **This cannot fail for want of room**, which is the entire purpose: the @@ -1044,8 +1237,8 @@ impl Reservation { // consumer has already finished with. let mut word = self.shared.claim.0.load(Ordering::Relaxed); let position = loop { - let position = position_of(word); - let reserved = reserved_of(word); + let position = position_of::(word); + let reserved = reserved_of::(word); debug_assert!( reserved >= 1, "this reservation is outstanding, so the count cannot be zero" @@ -1053,7 +1246,7 @@ impl Reservation { match self.shared.claim.0.compare_exchange_weak( word, - claim_word(reserved - 1, advance(position)), + claim_word::(reserved - 1, advance::(position)), Ordering::Relaxed, Ordering::Relaxed, ) { @@ -1096,7 +1289,7 @@ impl Reservation { } } -impl fmt::Debug for Reservation { +impl fmt::Debug for Reservation { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("reserving_mpsc::Reservation") .field("disconnected", &self.is_disconnected()) @@ -1104,20 +1297,20 @@ impl fmt::Debug for Reservation { } } -impl Drop for Reservation { +impl Drop for Reservation { fn drop(&mut self) { // Give the slot back. Only the count moves: the position is untouched, // because an unredeemed reservation never occupied a position. let mut word = self.shared.claim.0.load(Ordering::Relaxed); loop { - let reserved = reserved_of(word); + let reserved = reserved_of::(word); debug_assert!( reserved >= 1, "this reservation is outstanding, so the count cannot be zero" ); match self.shared.claim.0.compare_exchange_weak( word, - claim_word(reserved - 1, position_of(word)), + claim_word::(reserved - 1, position_of::(word)), Ordering::Relaxed, Ordering::Relaxed, ) { @@ -1133,13 +1326,13 @@ impl Drop for Reservation { /// /// Neither [`Clone`] nor [`Sync`], which is what makes "single consumer" a fact /// the compiler checks rather than a rule to remember. -pub struct Consumer { - shared: Arc>, +pub struct Consumer { + shared: Arc>, /// See [`Producer::not_sync`]. not_sync: PhantomData>, } -impl Consumer { +impl Consumer { /// Takes the oldest item, or `None` if there is none right now. /// /// `None` does not mean the queue is finished, and here it does not even @@ -1157,7 +1350,7 @@ impl Consumer { let slot = &self.shared.slots[position as usize & self.shared.mask]; // Acquire: pairs with the producer's release store, so an item it // published is visible here. - if slot.sequence.load(Ordering::Acquire) != advance(position) { + if slot.sequence.load(Ordering::Acquire) != advance::(position) { return None; } @@ -1179,7 +1372,7 @@ impl Consumer { self.shared .head .0 - .store(advance(position), Ordering::Release); + .store(advance::(position), Ordering::Release); Some(item) } @@ -1209,7 +1402,7 @@ impl Consumer { /// a drained queue with an outstanding reservation is not an idle one. #[must_use] pub fn outstanding_reservations(&self) -> usize { - reserved_of(self.shared.claim.0.load(Ordering::Relaxed)) as usize + reserved_of::(self.shared.claim.0.load(Ordering::Relaxed)) as usize } /// How many further items a best-effort push could still place, as a @@ -1328,7 +1521,7 @@ impl Consumer { } } -impl Parked for Consumer { +impl Parked for Consumer { type Item = T; fn pop(&self) -> Option { @@ -1353,7 +1546,7 @@ impl Parked for Consumer { } /// See [`Producer`]'s impl for why this is hand-written. -impl fmt::Debug for Consumer { +impl fmt::Debug for Consumer { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("reserving_mpsc::Consumer") .field("capacity", &self.capacity()) @@ -1365,13 +1558,13 @@ impl fmt::Debug for Consumer { } } -impl Drop for Consumer { +impl Drop for Consumer { fn drop(&mut self) { self.shared.consumer_live.store(false, Ordering::Release); } } -impl crate::Producer for Producer { +impl crate::Producer for Producer { type Item = T; fn push(&self, item: T) -> Result<(), PushError> { @@ -1383,7 +1576,7 @@ impl crate::Producer for Producer { } } -impl crate::Claim for Reservation { +impl crate::Claim for Reservation { type Item = T; fn send(self, item: T) -> Result<(), Disconnected> { @@ -1395,14 +1588,14 @@ impl crate::Claim for Reservation { } } -impl crate::Reserving for Producer { +impl crate::Reserving for Producer { type Item = T; type Reservation<'a> - = Reservation + = Reservation where Self: 'a; - fn reserve(&self) -> Option> { + fn reserve(&self) -> Option> { Self::reserve(self) } @@ -1411,7 +1604,7 @@ impl crate::Reserving for Producer { } } -impl crate::Consumer for Consumer { +impl crate::Consumer for Consumer { type Item = T; fn pop(&self) -> Option { @@ -1423,7 +1616,7 @@ impl crate::Consumer for Consumer { } } -impl crate::Bounded for Producer { +impl crate::Bounded for Producer { fn capacity(&self) -> usize { Self::capacity(self) } @@ -1444,7 +1637,7 @@ impl crate::Bounded for Producer { } } -impl crate::Bounded for Consumer { +impl crate::Bounded for Consumer { fn capacity(&self) -> usize { Self::capacity(self) } @@ -1465,7 +1658,7 @@ impl crate::Bounded for Consumer { } } -impl Shared { +impl Shared { /// The counters, as the [`Observable`](crate::Observable) trait reports /// them. Written once so the two handles cannot drift apart. fn refused(&self) -> u64 { @@ -1481,7 +1674,7 @@ impl Shared { } } -impl Producer { +impl Producer { /// How many pushes have been refused for want of room. #[must_use] pub fn refused(&self) -> u64 { @@ -1501,7 +1694,7 @@ impl Producer { } } -impl Consumer { +impl Consumer { /// How many pushes have been refused for want of room. #[must_use] pub fn refused(&self) -> u64 { @@ -1521,7 +1714,7 @@ impl Consumer { } } -impl crate::Observable for Producer { +impl crate::Observable for Producer { fn refused(&self) -> u64 { Self::refused(self) } @@ -1535,7 +1728,7 @@ impl crate::Observable for Producer { } } -impl crate::Observable for Consumer { +impl crate::Observable for Consumer { fn refused(&self) -> u64 { Self::refused(self) } @@ -1549,7 +1742,7 @@ impl crate::Observable for Consumer { } } -impl crate::Waitable for Consumer { +impl crate::Waitable for Consumer { fn doorbell(&self) -> io::Result> { Self::doorbell(self) } diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index c574ce51..8ebe38cd 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -13,9 +13,15 @@ //! slot -- so the interesting cases all put the queue under pressure first. use super::{ - BOUNDS_MAX, Consumer, MAX_RESERVED, POSITION_MASK, Producer, Reservation, advance, bounded, - bounded_with, claim_word, position_of, reserved_of, + BOUNDS_MAX, Balanced, ClaimLayout, Consumer, Enduring, Perpetual, Producer, Reservation, + advance, bounded, bounded_as, bounded_with, claim_word, position_of, reserved_of, }; + +/// The default layout's constants, named once so the packing tests read as +/// prose rather than as turbofish. +const POSITION_MASK: u64 = ::POSITION_MASK; +/// As [`POSITION_MASK`]. +const MAX_RESERVED: u64 = ::MAX_RESERVED; use crate::race_hooks; use crate::{Disposal, Options}; // The trait is imported anonymously because this module also names the concrete @@ -71,9 +77,9 @@ fn the_claim_word_round_trips_both_halves() { let max_reserved = MAX_RESERVED as u32; for &reserved in &[0_u32, 1, 2, 1000, max_reserved - 1, max_reserved] { for &position in &[0_u64, 1, 2, 1000, POSITION_MASK - 1, POSITION_MASK] { - let word = claim_word(reserved, position); + let word = claim_word::(reserved, position); assert_eq!( - (reserved_of(word), position_of(word)), + (reserved_of::(word), position_of::(word)), (reserved, position), "packing must be lossless in both halves, including at their extremes" ); @@ -85,16 +91,16 @@ fn the_claim_word_round_trips_both_halves() { fn the_two_halves_do_not_bleed_into_each_other() { // The mistake packing invites: a position that wraps must not carry into // the reservation count, and a count must not appear as a position. - let word = claim_word(0, POSITION_MASK); + let word = claim_word::(0, POSITION_MASK); assert_eq!( - reserved_of(word), + reserved_of::(word), 0, "a maximal position leaves the count at zero" ); - let word = claim_word(MAX_RESERVED as u32, 0); + let word = claim_word::(MAX_RESERVED as u32, 0); assert_eq!( - position_of(word), + position_of::(word), 0, "a maximal count leaves the position at zero" ); @@ -102,8 +108,14 @@ fn the_two_halves_do_not_bleed_into_each_other() { // And an increment of the position at its maximum wraps within its own half // rather than incrementing the count, which is what the queue relies on // every time a position laps. - let wrapped = claim_word(7, advance(POSITION_MASK)); - assert_eq!((reserved_of(wrapped), position_of(wrapped)), (7, 0)); + let wrapped = claim_word::(7, advance::(POSITION_MASK)); + assert_eq!( + ( + reserved_of::(wrapped), + position_of::(wrapped) + ), + (7, 0) + ); } // The relationship between the split and the ceiling is deliberately NOT tested @@ -1298,7 +1310,10 @@ fn the_gauges_are_clamped_when_head_has_passed_the_sampled_position() { // a sabotage run caught it doing. let (tx, _rx) = bounded::(4).expect("4 is a valid capacity"); - tx.shared.claim.0.store(claim_word(0, 1), Ordering::Release); + tx.shared + .claim + .0 + .store(claim_word::(0, 1), Ordering::Release); tx.shared.head.0.store(2, Ordering::Release); assert_eq!( @@ -1318,7 +1333,10 @@ fn the_gauges_are_clamped_when_head_has_passed_the_sampled_position() { // held, so leaving `head` ahead sets that walk a `u32::MAX`-length loop and // the test hangs rather than fails. Measured the hard way. tx.shared.head.0.store(0, Ordering::Release); - tx.shared.claim.0.store(claim_word(0, 0), Ordering::Release); + tx.shared + .claim + .0 + .store(claim_word::(0, 0), Ordering::Release); } #[test] @@ -1429,3 +1447,110 @@ fn the_high_water_mark_is_untracked_by_default_on_this_shape() { tx.push(1).expect("room"); assert_eq!(tx.high_water(), None); } + +// --------------------------------------------------------------------------- +// The claim-word layouts. +// +// The apportionment is arithmetic over compile-time constants, so most of it is +// checked by `ClaimLayout::VALID` at build time. What a test is needed for is +// the part that is *behaviour*: that a narrow reservation half actually refuses +// at its ceiling, and that the ceiling no longer drags the capacity down with +// it. Neither is observable under `Balanced`, whose ceiling of 2^32 cannot be +// reached on any queue that fits in memory -- which is why these tests are +// written against `Perpetual`. +// --------------------------------------------------------------------------- + +#[test] +fn each_layout_divides_the_word_as_documented() { + // The documentation states these numbers to callers choosing between the + // layouts, so they are asserted rather than left to be re-derived by a + // reader who wants to check the table. + assert_eq!(::POSITION_BITS, 32); + assert_eq!(::MAX_RESERVED, u64::from(u32::MAX)); + + assert_eq!(::POSITION_BITS, 48); + assert_eq!(::MAX_RESERVED, 65_535); + + assert_eq!(::POSITION_BITS, 56); + assert_eq!(::MAX_RESERVED, 255); +} + +#[test] +fn a_layout_may_hold_more_slots_than_it_can_reserve() { + // **This is the decoupling, and it is the whole point of the change.** + // Before it, the reservation half had to be wide enough for the entire + // capacity, because every slot could be reserved at once -- so a layout + // with 255 reservations would have been limited to 255 slots, and a large + // capacity would have been unbuildable. A queue of 1024 slots on a layout + // that can reserve 255 of them is exactly what that rule forbade. + let capacity = 1024; + assert!( + capacity > ::MAX_RESERVED, + "the fixture must exceed the reservation ceiling or it tests nothing" + ); + + let (tx, rx) = bounded_as::(capacity as usize) + .expect("a capacity far below the layout's ceiling"); + for value in 0..capacity as u32 { + tx.push(value).expect("every slot is free"); + } + assert!(tx.is_full(), "all 1024 slots hold an item"); + for expected in 0..capacity as u32 { + assert_eq!(rx.pop(), Some(expected)); + } +} + +#[test] +fn reservations_stop_at_the_layouts_ceiling_not_at_the_capacity() { + // The other half of the decoupling: the ceiling is real and refuses, rather + // than being a number that silently overflows into the position beside it. + let ceiling = ::MAX_RESERVED as usize; + let (tx, _rx) = + bounded_as::(1024).expect("a capacity far above the reservation ceiling"); + + let held: Vec<_> = (0..ceiling) + .map(|index| { + tx.reserve() + .unwrap_or_else(|| panic!("reservation {index} is within the ceiling")) + }) + .collect(); + assert_eq!(held.len(), ceiling); + assert_eq!(tx.outstanding_reservations(), ceiling); + + assert!( + tx.reserve().is_none(), + "the reservation past the ceiling must be refused even though 769 slots are still free" + ); + assert!( + !tx.is_full(), + "and the refusal must be the layout's ceiling rather than a full queue -- otherwise this \ + test would pass for the wrong reason" + ); + + // Releasing one makes room for exactly one more, so the ceiling is a live + // count rather than a latch. + drop( + held.into_iter() + .next_back() + .expect("the ceiling is not zero"), + ); + assert!( + tx.reserve().is_some(), + "a released reservation returns its place under the ceiling" + ); +} + +#[test] +fn a_reservation_on_a_deep_layout_still_delivers_its_message() { + // The layouts are not merely constants: each is a distinct instantiation of + // the whole protocol, so the capability the shape exists for is exercised + // on a non-default one rather than assumed to follow. + let (tx, rx) = bounded_as::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("an empty queue has room"); + tx.push(1).expect("room beyond the reservation"); + slot.send(99).expect("the consumer is still here"); + + assert_eq!(rx.pop(), Some(1)); + assert_eq!(rx.pop(), Some(99)); + assert_eq!(rx.pop(), None); +} From 44139f0fee8a95a9de42d9f241e9b5abb165143d Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 23:45:47 -0400 Subject: [PATCH 348/361] docs(platform-probes): check off CW-2.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-platform-probes/CHECKLIST-claim-word-layout.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index b1c34128..fe557c31 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -82,7 +82,7 @@ review. That reasoning expired: `mikegrier/waitable-queues` has no pull request open, so there is no review to disturb, and the `u64` layouts need no new dependency at all -- only 64/64 does, which is `CW-2.3`. -- [ ] **CW-2.1** -- Introduce the layout as a compile-time parameter, widen the +- [x] **CW-2.1** -- Introduce the layout as a compile-time parameter, widen the position to 64 bits, and decouple the reservation ceiling from the capacity. **Merged from two items during execution, because they cannot be verified From eac9d2f641f0fec64e31bdad24b00906c8801c7c Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Fri, 4 Sep 2026 23:58:04 -0400 Subject: [PATCH 349/361] docs(platform-probes): correct CW-2.3, which framed the 128-bit choice as a dependency question core::arch::x86_64::cmpxchg16b is stable on the pinned toolchain, so a 64/64 layout needs no third-party crate and D-7's and D-37's dependency cost does not apply. The real costs are hand-written unsafe, x86-64 only, and a target-feature decision -- against a 2-3x measured cost on the claim and Perpetual already reaching ~20 years on a plain AtomicU64. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHECKLIST-claim-word-layout.md | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index fe557c31..55c6eb6d 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -115,12 +115,25 @@ dependency at all -- only 64/64 does, which is `CW-2.3`. different promise rather than a broken one, but it must be stated, not slipped in. -- [ ] **CW-2.3** -- Decide whether a 128-bit claim word ships at all, and - therefore whether `portable-atomic` becomes a dependency of a published crate. - `D-7` sets the burden of proof for a Cargo feature and `D-37` discharged it - conditionally; `CW-1.4` supplies the number both were missing. The engineer - has said 32-bit Windows deployment is not a present concern, which changes - `D-18`'s premise and must be recorded rather than assumed. +- [ ] **CW-2.3** -- Decide whether a 128-bit claim word ships at all. + + **Not a dependency question.** An earlier form of this item framed it as + whether `portable-atomic` becomes a dependency of a published crate, which was + wrong: `core::arch::x86_64::cmpxchg16b` is stable on the pinned toolchain, so + a 64/64 layout needs no third-party crate. `D-7`'s and `D-37`'s dependency + cost does not apply, and the decision must not be made on it. + + What it actually costs: hand-written `unsafe` with manual orderings in the + file where that is worst to get wrong, x86-64 only (no ARM64 `casp`, no + i686), and a `target-feature` or runtime-detection decision. Against that, + `CW-1.4` measured the 128-bit exchange 2-3x slower on the claim in the + isolated regime, and `CW-2.1` has since made `Perpetual` reach about 20 years + before recurrence on a plain `AtomicU64` at no measured cost. + + So the question is narrow: is going from unreachable-in-any-deployment to + unreachable-in-principle worth that? The engineer has said 32-bit Windows + deployment is not a present concern, which changes `D-18`'s premise and must + be recorded rather than assumed. **`CW-1.6`'s scope is decided by this item**: if `portable-atomic` is declined, this crate must keep its `wide` implementation, because a layout the From 45dcd8e3a16327f861434f3dd272ad4f01a0ea33 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sat, 5 Sep 2026 00:04:41 -0400 Subject: [PATCH 350/361] feat(waitable-queues): add an opt-in dwcas feature and the Wide claim layout The claim word's integer type becomes part of the layout, so `reserving_mpsc` can pack into a `u128` where a caller asks for it. `Wide` divides that word 64 / 64: the position needs 2^64 pushes to recur, which is a guarantee rather than an argument about deployment lifetimes. **Off by default, and that is the point.** Rust's standard library has no 128-bit atomic, so this is the only thing in the crate that costs a third-party dependency. Without the feature the dependency tree is `windows-sys` alone and every layout uses `AtomicU64`; with it, `portable-atomic` appears. Verified with `cargo tree` in both configurations rather than assumed from the manifest. `default-features = false` on the dependency is load-bearing: with its defaults, `portable-atomic` silently substitutes a global lock where the native instruction is unavailable, which would put a mutex in the claim path while still compiling. The word type is abstracted behind `ClaimWord` rather than by widening everything to 128 bits, so a `u64` layout still issues `u64` instructions exactly as before -- the arithmetic is monomorphised to each width. Positions stay in a `u64` throughout, since no layout gives them more than 64 bits, and the reservation ceiling stays capped at `u32::MAX` because the count is reported to callers as a `u32`. Two const assertions moved with it: the position must be narrower than its own word rather than narrower than 64, and it may not exceed the `u64` it is carried in. 314 tests pass by default, 319 with all features, including five that exercise `Wide` through the whole protocol rather than only its constants -- the packing at a maximal position, where a carry into the count would show, and delivery of both a push and a reservation. Completed item: CW-2.3: Decide whether a 128-bit claim word ships at all -- decided yes, behind an opt-in feature, so callers who do not want the dependency do not carry it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + crates/windows-waitable-queues/Cargo.toml | 25 ++ .../src/reserving_mpsc.rs | 265 ++++++++++++++++-- .../src/reserving_mpsc/tests.rs | 87 ++++++ 4 files changed, 352 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8af5f99..3228b5b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -312,6 +312,7 @@ dependencies = [ name = "windows-waitable-queues" version = "0.0.1" dependencies = [ + "portable-atomic", "windows-sys", ] diff --git a/crates/windows-waitable-queues/Cargo.toml b/crates/windows-waitable-queues/Cargo.toml index 991cd187..b8de78e9 100644 --- a/crates/windows-waitable-queues/Cargo.toml +++ b/crates/windows-waitable-queues/Cargo.toml @@ -43,9 +43,34 @@ targets = ["x86_64-pc-windows-msvc"] # by accident. experimental-permit-claim = [] +# A 128-bit claim word for `reserving_mpsc`, adding the `Wide` layout. +# +# **The only thing in this crate that costs a third-party dependency, which is +# why it is a feature rather than always present.** Rust's standard library has +# no 128-bit atomic -- `core::sync::atomic` stops at 64 bits -- so a +# double-width compare-and-swap needs `portable-atomic`. Without this feature +# the crate depends on `windows-sys` alone and every layout uses `AtomicU64`. +# +# `default-features = false` on the dependency is load-bearing: with its +# defaults, `portable-atomic` silently substitutes a global lock where the +# native instruction is unavailable, which would put a mutex in the claim path +# while still compiling. With them off, `AtomicU128` does not exist on such a +# target and the build fails naming it. +# +# Most callers should not need this. `Perpetual` reaches roughly twenty years +# before its claim position recurs, on a plain `AtomicU64` at no measured cost, +# whereas the 128-bit exchange measured 2-3x slower on the claim itself. See +# `ClaimLayout` for the comparison. +dwcas = ["dep:portable-atomic"] + [lib] path = "src/lib.rs" +[dependencies.portable-atomic] +version = "1.15.0" +default-features = false +optional = true + [dependencies.windows-sys] version = "0.61.2" default-features = false diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 88a8f690..750a378d 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -173,11 +173,31 @@ use crate::options::Options; /// constants are checked against each other at compile time, and a caller /// supplying its own could pick a division this shape cannot honour. pub trait ClaimLayout: sealed::Sealed { + /// The integer the two halves are packed into. + /// + /// `u64` for every layout the crate offers by default. The `dwcas` feature + /// adds [`Wide`], whose word is a `u128` -- and the arithmetic is done in + /// this type rather than uniformly in the wider one, so a `u64` layout + /// issues `u64` instructions exactly as it did before the type became a + /// parameter. + type Word: ClaimWord; + + /// How wide [`Self::Word`] is, in bits. + const WORD_BITS: u32; + /// How many of the claim word's bits carry the position. const POSITION_BITS: u32; /// Isolates the position half of the claim word. - const POSITION_MASK: u64 = (1u64 << Self::POSITION_BITS) - 1; + /// + /// A position is carried in a `u64` whatever the word's width, since no + /// layout gives it more than 64 bits. At exactly 64 the shift that would + /// build this mask overflows, so the whole-width case is spelled out. + const POSITION_MASK: u64 = if Self::POSITION_BITS >= 64 { + u64::MAX + } else { + (1u64 << Self::POSITION_BITS) - 1 + }; /// The largest outstanding-reservation count the word's other half holds. /// @@ -186,7 +206,18 @@ pub trait ClaimLayout: sealed::Sealed { /// capacity, because every slot could be reserved at once. That is what made /// a large capacity consume the position's bits. Capping the reservations /// instead leaves the capacity bounded only by the ring. - const MAX_RESERVED: u64 = u64::MAX >> Self::POSITION_BITS; + /// + /// **Capped at [`u32::MAX`] however wide the field is**, because the count + /// is reported to callers as a `u32`. A field wider than that would let the + /// queue hold a count it could not describe. + const MAX_RESERVED: u64 = { + let field = Self::WORD_BITS - Self::POSITION_BITS; + if field >= 32 { + u32::MAX as u64 + } else { + (1u64 << field) - 1 + } + }; /// The largest capacity this layout accepts. /// @@ -227,9 +258,14 @@ pub trait ClaimLayout: sealed::Sealed { that is, a position narrower than 32 -- would be truncated on the way out" ); assert!( - Self::POSITION_BITS < 64, + Self::POSITION_BITS < Self::WORD_BITS, "the count needs at least one bit, so the position cannot take the whole word" ); + assert!( + Self::POSITION_BITS <= 64, + "a position is carried in a u64 between the packing and the ring, so a layout giving \ + it more bits than that would lose them on the way out" + ); assert!( Self::MAX_RESERVED <= u32::MAX as u64, "the count is read back out through `reserved_of`'s cast to `u32`, so a field the \ @@ -260,6 +296,141 @@ pub trait ClaimLayout: sealed::Sealed { mod sealed { /// Prevents a caller outside this crate from adding a layout. pub trait Sealed {} + /// Prevents a caller outside this crate from adding a word width. + pub trait SealedWord {} +} + +/// The integer a claim word is packed into, and the atomic that holds it. +/// +/// Exists so a layout can choose between a `u64` and a `u128` word without the +/// narrow layouts paying for the wide one: each is monomorphised to the +/// instructions its own width needs. Sealed for [`ClaimLayout`]'s reason, and +/// because an implementation that got the packing wrong would corrupt the two +/// halves into each other. +pub trait ClaimWord: sealed::SealedWord + Copy + PartialEq { + /// The atomic this word lives in. + type Atomic; + + /// A fresh atomic holding a zeroed word. + fn zeroed() -> Self::Atomic; + + /// Read the word. + fn load(cell: &Self::Atomic, order: Ordering) -> Self; + + /// Attempt to replace `current` with `new`. + fn compare_exchange_weak( + cell: &Self::Atomic, + current: Self, + new: Self, + success: Ordering, + failure: Ordering, + ) -> Result; + + /// Read the word through a unique borrow, without synchronization. + fn read_mut(cell: &mut Self::Atomic) -> Self; + + /// Pack a reservation count and a position together. + fn pack(reserved: u32, position: u64, position_bits: u32, position_mask: u64) -> Self; + + /// Read the position back out. + fn position(self, position_mask: u64) -> u64; + + /// Read the reservation count back out. + fn reserved(self, position_bits: u32) -> u32; +} + +impl sealed::SealedWord for u64 {} +impl ClaimWord for u64 { + type Atomic = AtomicU64; + + #[inline] + fn zeroed() -> Self::Atomic { + AtomicU64::new(0) + } + + #[inline] + fn load(cell: &Self::Atomic, order: Ordering) -> Self { + cell.load(order) + } + + #[inline] + fn compare_exchange_weak( + cell: &Self::Atomic, + current: Self, + new: Self, + success: Ordering, + failure: Ordering, + ) -> Result { + cell.compare_exchange_weak(current, new, success, failure) + } + + #[inline] + fn read_mut(cell: &mut Self::Atomic) -> Self { + *cell.get_mut() + } + + #[inline] + fn pack(reserved: u32, position: u64, position_bits: u32, position_mask: u64) -> Self { + ((reserved as u64) << position_bits) | (position & position_mask) + } + + #[inline] + fn position(self, position_mask: u64) -> u64 { + self & position_mask + } + + #[inline] + fn reserved(self, position_bits: u32) -> u32 { + (self >> position_bits) as u32 + } +} + +#[cfg(feature = "dwcas")] +impl sealed::SealedWord for u128 {} +#[cfg(feature = "dwcas")] +impl ClaimWord for u128 { + type Atomic = portable_atomic::AtomicU128; + + #[inline] + fn zeroed() -> Self::Atomic { + portable_atomic::AtomicU128::new(0) + } + + #[inline] + fn load(cell: &Self::Atomic, order: Ordering) -> Self { + cell.load(order) + } + + #[inline] + fn compare_exchange_weak( + cell: &Self::Atomic, + current: Self, + new: Self, + success: Ordering, + failure: Ordering, + ) -> Result { + cell.compare_exchange_weak(current, new, success, failure) + } + + #[inline] + fn read_mut(cell: &mut Self::Atomic) -> Self { + *cell.get_mut() + } + + #[inline] + fn pack(reserved: u32, position: u64, position_bits: u32, position_mask: u64) -> Self { + ((reserved as u128) << position_bits) | ((position & position_mask) as u128) + } + + #[inline] + fn position(self, position_mask: u64) -> u64 { + (self as u64) & position_mask + } + + #[inline] + fn reserved(self, position_bits: u32) -> u32 { + (self >> position_bits) as u32 + } } /// The shipping division: 32 bits each. @@ -275,6 +446,8 @@ mod sealed { pub struct Balanced; impl sealed::Sealed for Balanced {} impl ClaimLayout for Balanced { + type Word = u64; + const WORD_BITS: u32 = 64; const POSITION_BITS: u32 = 32; } @@ -286,6 +459,8 @@ impl ClaimLayout for Balanced { pub struct Enduring; impl sealed::Sealed for Enduring {} impl ClaimLayout for Enduring { + type Word = u64; + const WORD_BITS: u32 = 64; const POSITION_BITS: u32 = 48; } @@ -302,9 +477,40 @@ impl ClaimLayout for Enduring { pub struct Perpetual; impl sealed::Sealed for Perpetual {} impl ClaimLayout for Perpetual { + type Word = u64; + const WORD_BITS: u32 = 64; const POSITION_BITS: u32 = 56; } +/// A 128-bit claim word: 64 bits of position, and the count in the other half. +/// +/// Requires the `dwcas` feature, which is what brings in the `portable-atomic` +/// dependency this crate otherwise does not have. The position needs 2^64 +/// pushes to recur, which no deployment reaches -- not "not for twenty years", +/// but not at all. +/// +/// **Read the cost before choosing it.** The 128-bit exchange measured 2-3x +/// slower than a `u64` one on the claim itself, and the penalty grows with +/// producer count; against a draining consumer the difference is much smaller. +/// [`Perpetual`] reaches about twenty years on a plain `AtomicU64` at no +/// measured cost, so this is worth taking only when a guarantee is wanted in +/// place of an argument about deployment lifetimes. +/// +/// The reservation ceiling is [`u32::MAX`] rather than the 64 bits the field +/// could hold, because the count is reported to callers as a `u32`. +#[cfg(feature = "dwcas")] +#[cfg_attr(docsrs, doc(cfg(feature = "dwcas")))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Wide; +#[cfg(feature = "dwcas")] +impl sealed::Sealed for Wide {} +#[cfg(feature = "dwcas")] +impl ClaimLayout for Wide { + type Word = u128; + const WORD_BITS: u32 = 128; + const POSITION_BITS: u32 = 64; +} + /// The position after `position`, wrapping at the width the layout gives it. /// /// **Centralised because the width is no longer the type's.** A position is @@ -381,13 +587,15 @@ const fn bounds() -> Bounds { } /// Reads the position out of a claim word. -const fn position_of(word: u64) -> u64 { - word & L::POSITION_MASK +#[inline] +fn position_of(word: L::Word) -> u64 { + word.position(L::POSITION_MASK) } /// Reads the outstanding-reservation count out of a claim word. -const fn reserved_of(word: u64) -> u32 { - (word >> L::POSITION_BITS) as u32 +#[inline] +fn reserved_of(word: L::Word) -> u32 { + word.reserved(L::POSITION_BITS) } /// Builds a claim word from its two halves. @@ -408,8 +616,9 @@ const fn reserved_of(word: u64) -> u32 { /// them apart. `|` is kept because it says "these are separate fields" where the /// others say "these are numbers"; the equivalence is recorded here so it is not /// investigated again. -const fn claim_word(reserved: u32, position: u64) -> u64 { - ((reserved as u64) << L::POSITION_BITS) | (position & L::POSITION_MASK) +#[inline] +fn claim_word(reserved: u32, position: u64) -> L::Word { + L::Word::pack(reserved, position, L::POSITION_BITS, L::POSITION_MASK) } /// Creates a reserving multi-producer, single-consumer bounded array queue. @@ -551,7 +760,7 @@ fn build( mask: capacity - 1, capacity, head: CacheAligned(AtomicU64::new(0)), - claim: CacheAligned(AtomicU64::new(claim_word::(0, 0))), + claim: CacheAligned(::zeroed()), producers: AtomicUsize::new(1), consumer_live: AtomicBool::new(true), doorbell: Doorbell::new(), @@ -614,7 +823,7 @@ struct Shared { /// One word because they must be claimed together; see the [module /// documentation](self) for why two atomics cannot be made correct with any /// amount of fencing. - claim: CacheAligned, + claim: CacheAligned<::Atomic>, /// How many producer handles and outstanding reservations are alive. /// /// **A reservation counts as a producer**, which is not bookkeeping @@ -695,7 +904,7 @@ impl Shared { /// subtraction produce a number near `u32::MAX`. A bounded queue must never /// report holding more than it can. fn len(&self) -> usize { - let position = position_of::(self.claim.0.load(Ordering::Relaxed)); + let position = position_of::(L::Word::load(&self.claim.0, Ordering::Relaxed)); let head = self.head.0.load(Ordering::Acquire); (distance::(position, head) as usize).min(self.capacity) } @@ -716,7 +925,7 @@ impl Shared { /// together to avoid. `head` is still a second load, so the result is /// clamped for the reason `len` is. fn remaining(&self) -> usize { - let word = self.claim.0.load(Ordering::Relaxed); + let word = L::Word::load(&self.claim.0, Ordering::Relaxed); let head = self.head.0.load(Ordering::Acquire); let capacity = self.capacity_u64(); let occupied = distance::(position_of::(word), head).min(capacity); @@ -919,7 +1128,7 @@ impl Drop for Shared { // instead of leaving it to that argument. let mask = self.mask; let head = *self.head.0.get_mut(); - let tail = position_of::(*self.claim.0.get_mut()); + let tail = position_of::(L::Word::read_mut(&mut self.claim.0)); let mut position = head; while position != tail { let published = advance::(position); @@ -965,7 +1174,7 @@ impl Producer { // Relaxed: this load only proposes a claim. The compare-and-swap below // is what makes it, and fails if the proposal was stale, so a stale read // costs a retry rather than correctness. - let mut word = self.shared.claim.0.load(Ordering::Relaxed); + let mut word = L::Word::load(&self.shared.claim.0, Ordering::Relaxed); let position = loop { let position = position_of::(word); let reserved = reserved_of::(word); @@ -980,7 +1189,7 @@ impl Producer { // subtraction wraps, so an *empty* queue reports full. Re-read // the claim: if it moved, this answer was computed from a // snapshot that never existed, so retry rather than refuse. - let current = self.shared.claim.0.load(Ordering::Relaxed); + let current = L::Word::load(&self.shared.claim.0, Ordering::Relaxed); if current != word { word = current; continue; @@ -1008,7 +1217,8 @@ impl Producer { // The reservation count is carried through unchanged, which is what // makes a racing `reserve` fail its own exchange and re-read rather // than have its increment silently overwritten. - match self.shared.claim.0.compare_exchange_weak( + match L::Word::compare_exchange_weak( + &self.shared.claim.0, word, claim_word::(reserved, advance::(position)), Ordering::Relaxed, @@ -1038,7 +1248,7 @@ impl Producer { /// consumer will not be told the stream ended and then handed the item. #[must_use = "a reservation withholds capacity from every other producer until it is used or dropped"] pub fn reserve(&self) -> Option> { - let mut word = self.shared.claim.0.load(Ordering::Relaxed); + let mut word = L::Word::load(&self.shared.claim.0, Ordering::Relaxed); loop { let position = position_of::(word); let reserved = reserved_of::(word); @@ -1050,7 +1260,7 @@ impl Producer { // stale `word` and a freshly-read `head` need not describe the // same instant, and once `head` passes a stale `position` the // subtraction wraps and an empty queue refuses a reservation. - let current = self.shared.claim.0.load(Ordering::Relaxed); + let current = L::Word::load(&self.shared.claim.0, Ordering::Relaxed); if current != word { word = current; continue; @@ -1078,7 +1288,8 @@ impl Producer { // capacity, not an order. Where the item lands is decided when the // reservation is redeemed, so a slot held for a long time does not // stall everything queued behind it. - match self.shared.claim.0.compare_exchange_weak( + match L::Word::compare_exchange_weak( + &self.shared.claim.0, word, claim_word::(reserved + 1, position), Ordering::Relaxed, @@ -1123,7 +1334,7 @@ impl Producer { /// snapshot. #[must_use] pub fn outstanding_reservations(&self) -> usize { - reserved_of::(self.shared.claim.0.load(Ordering::Relaxed)) as usize + reserved_of::(L::Word::load(&self.shared.claim.0, Ordering::Relaxed)) as usize } /// Whether the next best-effort push would be refused, as a snapshot. @@ -1235,7 +1446,7 @@ impl Reservation { // invariant `occupied + reserved <= capacity` with `reserved >= 1` means // `occupied < capacity`, so the slot at this position is one the // consumer has already finished with. - let mut word = self.shared.claim.0.load(Ordering::Relaxed); + let mut word = L::Word::load(&self.shared.claim.0, Ordering::Relaxed); let position = loop { let position = position_of::(word); let reserved = reserved_of::(word); @@ -1244,7 +1455,8 @@ impl Reservation { "this reservation is outstanding, so the count cannot be zero" ); - match self.shared.claim.0.compare_exchange_weak( + match L::Word::compare_exchange_weak( + &self.shared.claim.0, word, claim_word::(reserved - 1, advance::(position)), Ordering::Relaxed, @@ -1301,14 +1513,15 @@ impl Drop for Reservation { fn drop(&mut self) { // Give the slot back. Only the count moves: the position is untouched, // because an unredeemed reservation never occupied a position. - let mut word = self.shared.claim.0.load(Ordering::Relaxed); + let mut word = L::Word::load(&self.shared.claim.0, Ordering::Relaxed); loop { let reserved = reserved_of::(word); debug_assert!( reserved >= 1, "this reservation is outstanding, so the count cannot be zero" ); - match self.shared.claim.0.compare_exchange_weak( + match L::Word::compare_exchange_weak( + &self.shared.claim.0, word, claim_word::(reserved - 1, position_of::(word)), Ordering::Relaxed, @@ -1402,7 +1615,7 @@ impl Consumer { /// a drained queue with an outstanding reservation is not an idle one. #[must_use] pub fn outstanding_reservations(&self) -> usize { - reserved_of::(self.shared.claim.0.load(Ordering::Relaxed)) as usize + reserved_of::(L::Word::load(&self.shared.claim.0, Ordering::Relaxed)) as usize } /// How many further items a best-effort push could still place, as a diff --git a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs index 8ebe38cd..a409ca03 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc/tests.rs @@ -1554,3 +1554,90 @@ fn a_reservation_on_a_deep_layout_still_delivers_its_message() { assert_eq!(rx.pop(), Some(99)); assert_eq!(rx.pop(), None); } + +// --------------------------------------------------------------------------- +// The 128-bit layout. +// +// Gated with the feature that supplies it, so the default build compiles none +// of this and a `dwcas` build runs the same protocol tests against a word twice +// as wide. The point is that `Wide` is a full instantiation of the protocol +// rather than a constant: nothing about it is shared with the `u64` layouts +// below the `ClaimWord` trait, so it is exercised rather than assumed. +// --------------------------------------------------------------------------- + +#[cfg(feature = "dwcas")] +mod dwcas { + use super::*; + use crate::reserving_mpsc::Wide; + + #[test] + fn the_wide_layout_divides_a_128_bit_word() { + assert_eq!(::WORD_BITS, 128); + assert_eq!(::POSITION_BITS, 64); + assert_eq!( + ::POSITION_MASK, + u64::MAX, + "a 64-bit position occupies the whole of the u64 it is carried in, which is the case \ + the mask's shift cannot express and must special-case" + ); + assert_eq!( + ::MAX_RESERVED, + u64::from(u32::MAX), + "the field holds 64 bits but the count is reported as a u32, so the ceiling is the \ + narrower of the two rather than what the word could carry" + ); + } + + #[test] + fn the_wide_word_round_trips_both_halves() { + // The packing is the part that differs from the `u64` layouts, and a + // position at its maximum is where a carry into the count would show. + for &reserved in &[0_u32, 1, 1000, u32::MAX] { + for &position in &[0_u64, 1, 1000, u64::MAX - 1, u64::MAX] { + let word = claim_word::(reserved, position); + assert_eq!( + (reserved_of::(word), position_of::(word)), + (reserved, position), + "packing must be lossless in both halves of the wider word too" + ); + } + } + } + + #[test] + fn a_position_at_its_maximum_wraps_without_touching_the_count() { + let wrapped = claim_word::(7, advance::(u64::MAX)); + assert_eq!( + (reserved_of::(wrapped), position_of::(wrapped)), + (7, 0), + "the position laps within its own half rather than incrementing the count" + ); + } + + #[test] + fn the_wide_layout_delivers_items_and_reservations() { + let (tx, rx) = bounded_as::(4).expect("4 is a valid capacity"); + let slot = tx.reserve().expect("an empty queue has room"); + tx.push(1).expect("room beyond the reservation"); + slot.send(99).expect("the consumer is still here"); + + assert_eq!(rx.pop(), Some(1)); + assert_eq!(rx.pop(), Some(99)); + assert_eq!(rx.pop(), None); + } + + #[test] + fn the_wide_layout_refuses_when_full_and_recovers() { + let (tx, rx) = bounded_as::(2).expect("2 is a valid capacity"); + tx.push(1).expect("an empty queue has room"); + tx.push(2).expect("one slot remains"); + assert!( + tx.push(3).is_err(), + "a full queue refuses rather than overwriting, whatever the word's width" + ); + assert_eq!(rx.pop(), Some(1)); + tx.push(3).expect("the popped slot is free again"); + assert_eq!(rx.pop(), Some(2)); + assert_eq!(rx.pop(), Some(3)); + } +} From 5c413bfa3824072339d3f3bb051d83765df6dbaa Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sat, 5 Sep 2026 00:04:58 -0400 Subject: [PATCH 351/361] docs(platform-probes): record the CW-2.3 decision and its effect on CW-1.6 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHECKLIST-claim-word-layout.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index 55c6eb6d..41ce75fa 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -115,7 +115,18 @@ dependency at all -- only 64/64 does, which is `CW-2.3`. different promise rather than a broken one, but it must be stated, not slipped in. -- [ ] **CW-2.3** -- Decide whether a 128-bit claim word ships at all. +- [x] **CW-2.3** -- Decide whether a 128-bit claim word ships at all. + + **Decided: yes, behind an opt-in `dwcas` feature.** The `Wide` layout packs a + `u128` divided 64 / 64. Without the feature the crate depends on + `windows-sys` alone and every layout uses `AtomicU64`; with it, + `portable-atomic` appears. So a caller who does not want the dependency does + not carry it, and one who wants a guarantee rather than a twenty-year + argument can have it. + + This resolves `CW-1.6`'s scope the other way from what the item anticipated: + the shipping crate *can* now express a 128-bit layout, so the probe does not + need to keep its own `wide` implementation to measure one. **Not a dependency question.** An earlier form of this item framed it as whether `portable-atomic` becomes a dependency of a published crate, which was From 950dcccf71df4363d280d1991149c3527a2a82e3 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sat, 5 Sep 2026 00:15:06 -0400 Subject: [PATCH 352/361] docs(waitable-queues): restate the wrap hazard as a layout choice, superseding D-36 D-36 decided 0.1.0 would ship SH-14.1 disclosed rather than fixed, because the only known fix was the D-35 claim-protocol replacement, gated on an open question -- so disclosing beat delaying. That premise is false. Re-apportioning the claim word is a second fix neither D-36 nor D-37 considered, and it costs nothing measurable, so the hazard is now a number the caller sets rather than one the crate imposes. Recorded as D-41, which supersedes D-36. D-37 is partly superseded too: the wide word ships as a layout behind the `dwcas` feature rather than as a separate `reserving_mpsc_wide` shape gated by target, though its reasoning about `portable-atomic`'s default features still stands. Swept `2^32` / `32-bit position` / `sound below the wrap` across src/, tests and *.md for the crate: 21 sites, 9 rewritten, 12 already correctly scoped or out of scope. The out-of-scope ones are `slotwise_mpsc`'s note about `usize` on 32-bit targets, which is a different subject, and the table rows and prose that now say `Balanced` explicitly. `permit_mpsc`'s rationale needed care rather than a scoping edit: a deeper position moves the recurrence out of reach without removing the decision/operation separation that causes it, which is why that shape remains interesting. The three new examples are doctests, so a renamed layout breaks the build rather than leaving the documentation teaching a name that no longer exists -- doctests go from 9 to 12. Two defects the CI rustdoc flags caught, both invisible to `cargo test`: a public doc linking to the private `build`, and a link to `Wide` that resolves only when `dwcas` is on. Docs now build clean under all three feature configurations. Completed item: CW-2.4: Document the layouts as a choice, in the crate documentation and the README, with the rollover table Completed item: CW-2.5: Reopen D-36 with the measurement in hand, then sweep every statement of the hazard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CHECKLIST-claim-word-layout.md | 4 +- .../windows-waitable-queues/DESIGN-NOTES.md | 5 +- crates/windows-waitable-queues/README.md | 100 ++++++++++++------ .../src/capacity/tests.rs | 8 +- crates/windows-waitable-queues/src/lib.rs | 93 +++++++++++----- .../src/permit_mpsc.rs | 13 ++- .../src/reserving_mpsc.rs | 43 +++++--- 7 files changed, 185 insertions(+), 81 deletions(-) diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index 41ce75fa..d12927b7 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -150,7 +150,7 @@ dependency at all -- only 64/64 does, which is `CW-2.3`. declined, this crate must keep its `wide` implementation, because a layout the queue crate cannot express is one the probe cannot instantiate. -- [ ] **CW-2.4** -- Document the layouts as a choice, in the crate documentation +- [x] **CW-2.4** -- Document the layouts as a choice, in the crate documentation and the README, with the rollover table and the two axes a caller trades between: outstanding reservations against time-to-recurrence. Lead with what `CW-1.4` measured -- re-apportioning is free, widening is not -- so a caller @@ -163,7 +163,7 @@ dependency at all -- only 64/64 does, which is `CW-2.3`. layout breaks the build instead of leaving the documentation teaching a name that no longer exists. -- [ ] **CW-2.5** -- Reopen `D-36` with the measurement in hand, then sweep every +- [x] **CW-2.5** -- Reopen `D-36` with the measurement in hand, then sweep every statement of the hazard. **`D-36`'s premise is falsified, and that is the finding, not the sweep.** It diff --git a/crates/windows-waitable-queues/DESIGN-NOTES.md b/crates/windows-waitable-queues/DESIGN-NOTES.md index d57f9a86..9ddcb4a2 100644 --- a/crates/windows-waitable-queues/DESIGN-NOTES.md +++ b/crates/windows-waitable-queues/DESIGN-NOTES.md @@ -59,11 +59,12 @@ preferred. | D-33 | **`PushError` is `#[non_exhaustive]`, and the one-directional doorbell is disclosed rather than fixed before 0.1.0.** The receive-side errors already carried the attribute and the send side lacked it by omission; adding it after publication is itself breaking, so it is taken now while the crate has no external consumers. Whether a producer can *wait* for room stays open as [M32.3](../../CHECKLIST-io-domains.md) -- it is additive, so it does not gate the release -- but the absence is stated in both the crate docs and the README, because `crossbeam-channel`'s `send` blocks and a reader arriving from it will assume this one does too. | | D-34 | **Every bounded queue surveyed is ABA-safe for one of two reasons, and this crate's `reserving_mpsc` has neither.** Either the claim counter is a whole machine word, so recurrence is unreachable -- crossbeam, concurrent-queue, thingbuf, Vyukov, SCQ's `Head`/`Tail` -- or the authorizing compare-exchange is moved onto the cell, so the decision and the write are validated together (CRQ, SCQ). Ours packs the position into a 32-bit *subfield* and authorizes with an exchange that does not cover the separately-read `head`. Nikolaev (DISC 2019, section 3) states the width assumption the field relies on and states it for **CPU-word** width, which a subfield does not satisfy; DPDK's `rte_ring` is the same protocol as ours and its published justification covers modular arithmetic only. The generalisation -- ours, unstated in any source -- is that **the atomic operation authorizing the write must cover everything the decision depended on.** Survey in [DESIGN-SESSION-2026-09-02](design-sessions/DESIGN-SESSION-2026-09-02-claim-protocol-prior-art.md); the fix is M15 in [CHECKLIST-ship-topology-and-queues.md](../../CHECKLIST-ship-topology-and-queues.md). | | D-35 | **Measured: the permit claim is 2.7x faster than `reserving_mpsc` at 16-32 producers, and 1.45x slower at one.** The safer claim is also the faster one everywhere contention exists, which was not the expected result -- it touches *two* shared lines where the shipping shape touches one plus a read, and [D-26](#d-26) had established that the shared line is what collapses. The mechanism is that both of its operations are unconditional read-modify-writes that never retry, where the shipping shape's compare-exchange retries once per lost race; the retries dominate long before the second line does. It is the only shape measured that gets *faster* per push as producers are added (42.8 ns at two to 19.5 at thirty-two) and the only one that stays within 1.5x of a bare contended `fetch_add`. **This decides the shape of the fix but not the fix**: the drained regime's refusal counts differ by orders of magnitude in a way this harness cannot attribute, which SH-15.5.1 exists to settle before SH-15.6 adopts anything. | -| D-36 | **0.1.0 ships [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) disclosed rather than fixed, and the disclosure is a release blocker.** Following [D-31](#d-31)'s principle -- the disclosure, not the deferral, is the decision -- because the fix is a claim-protocol replacement ([D-35](#d-35)) whose adoption is still gated on an open question, and holding the release for it would trade a *documented* hazard for an undocumented rush. **The two gaps are not equally forgiving and the text says so**: an unverified ordering is a risk of a bug, this is a known one with a computed exposure, and its failure mode is silent -- no error, panic, or counter -- so a caller can neither detect nor mitigate it. That is precisely why it may not ship in silence. Stated in the crate docs, the README, and the shape's own module docs, each leading with **"on every target, not only 32-bit ones"**, because the natural spelling "32-bit position" invites the opposite reading and SH-6.1 already had to be corrected for exactly that. The shape-selection guidance in both documents was also amended: it previously said "start with `reserving_mpsc`" with no caveat, pointing callers at the hazardous shape by default. | -| D-37 | **The reserving claim word ships in two widths: the narrow one on every target, the wide one only where a 128-bit exchange is genuinely lock-free.** `reserving_mpsc` keeps its packed 64-bit word, keeps [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md)'s hazard, keeps [D-36](#d-36)'s warnings, and is **never silently swapped** for the wide shape on targets that could host one -- a contract that changes with the target is what PLATFORM INTEGRITY rule 2 forbids, and a caller who read "2^32" must get 2^32. `reserving_mpsc_wide` is the same protocol with a `u128` word split 64/64: recurrence needs 2^64 pushes, and the capacity ceiling rises to 2^62. **The gate is one line of `Cargo.toml`: `default-features = false`.** Measured, not designed -- with the default feature set `portable-atomic` compiles on i686 and silently substitutes a global lock, but with defaults off `AtomicU128` **does not exist** there (`no AtomicU128 in the root`), nor on x86_64 built without `cmpxchg16b`. It exists exactly where a native lock-free exchange is guaranteed at compile time, so the `use` statement is the gate and it fails loudly. A `cfg(target_has_atomic = "128")` would be the *wrong* gate -- it is emitted even with `cmpxchg16b` disabled -- and a `const` assertion on `is_always_lock_free()`, though genuinely const-evaluable, is redundant where the type exists and unreachable where it does not. That is the standard [SH-14.2](../../CHECKLIST-ship-topology-and-queues.md) already set when it probed i686 to confirm `AtomicU64` was lock-free before widening `slotwise_mpsc`, recording that a hidden mutex "would have made this a bad trade". [D-7](#d-7)'s burden of proof for adding a Cargo feature is **discharged, not waived**: D-7 rejected feature-gating because the only benefit was compile time, and the cost here is a third-party dependency, which dead-code elimination does not remove from `Cargo.lock` or from an auditor's review. | +| D-36 | **Superseded by [D-41](#d-41): the hazard is now a layout choice, not a defect that must ship.** The reasoning below stands as the record of why it was right to disclose rather than delay while the only known fix was the claim-protocol replacement. **0.1.0 ships [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) disclosed rather than fixed, and the disclosure is a release blocker.** Following [D-31](#d-31)'s principle -- the disclosure, not the deferral, is the decision -- because the fix is a claim-protocol replacement ([D-35](#d-35)) whose adoption is still gated on an open question, and holding the release for it would trade a *documented* hazard for an undocumented rush. **The two gaps are not equally forgiving and the text says so**: an unverified ordering is a risk of a bug, this is a known one with a computed exposure, and its failure mode is silent -- no error, panic, or counter -- so a caller can neither detect nor mitigate it. That is precisely why it may not ship in silence. Stated in the crate docs, the README, and the shape's own module docs, each leading with **"on every target, not only 32-bit ones"**, because the natural spelling "32-bit position" invites the opposite reading and SH-6.1 already had to be corrected for exactly that. The shape-selection guidance in both documents was also amended: it previously said "start with `reserving_mpsc`" with no caveat, pointing callers at the hazardous shape by default. | +| D-37 | **Partly superseded by [D-41](#d-41): the wide word ships as a *layout* behind the non-default `dwcas` feature, not as a separate `reserving_mpsc_wide` shape, and the gate is the feature rather than the target.** What stands is the reasoning below about `portable-atomic`: `default-features = false` is load-bearing, because with defaults on it silently substitutes a global lock, and D-7's burden of proof is discharged rather than waived. What does not is the shape's name and the premise that the narrow word must keep [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) -- re-apportioning the narrow word removes the exposure for free, so the wide word is no longer the only way out. **The reserving claim word ships in two widths: the narrow one on every target, the wide one only where a 128-bit exchange is genuinely lock-free.** `reserving_mpsc` keeps its packed 64-bit word, keeps [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md)'s hazard, keeps [D-36](#d-36)'s warnings, and is **never silently swapped** for the wide shape on targets that could host one -- a contract that changes with the target is what PLATFORM INTEGRITY rule 2 forbids, and a caller who read "2^32" must get 2^32. `reserving_mpsc_wide` is the same protocol with a `u128` word split 64/64: recurrence needs 2^64 pushes, and the capacity ceiling rises to 2^62. **The gate is one line of `Cargo.toml`: `default-features = false`.** Measured, not designed -- with the default feature set `portable-atomic` compiles on i686 and silently substitutes a global lock, but with defaults off `AtomicU128` **does not exist** there (`no AtomicU128 in the root`), nor on x86_64 built without `cmpxchg16b`. It exists exactly where a native lock-free exchange is guaranteed at compile time, so the `use` statement is the gate and it fails loudly. A `cfg(target_has_atomic = "128")` would be the *wrong* gate -- it is emitted even with `cmpxchg16b` disabled -- and a `const` assertion on `is_always_lock_free()`, though genuinely const-evaluable, is redundant where the type exists and unreachable where it does not. That is the standard [SH-14.2](../../CHECKLIST-ship-topology-and-queues.md) already set when it probed i686 to confirm `AtomicU64` was lock-free before widening `slotwise_mpsc`, recording that a hidden mutex "would have made this a bad trade". [D-7](#d-7)'s burden of proof for adding a Cargo feature is **discharged, not waived**: D-7 rejected feature-gating because the only benefit was compile time, and the cost here is a third-party dependency, which dead-code elimination does not remove from `Cargo.lock` or from an auditor's review. | | D-38 | **One atomic, one discipline: an atomic that carries any acquire/release operation has acquire/release on *every* operation, and a relaxed load is never mixed in.** A relaxed operation is still **atomic** -- indivisible, untorn, and free of data-race UB -- but it is *unordered*: it behaves like a plain load or store with respect to placement, unanchored relative to the ordered operations on the same object and free to be moved by the optimizer or the processor. It is not pinned to its textual site, so reasoning about it in statement order is nonsense. **The two axes are independent, and conflating them is the mistake in both directions:** relaxed does not mean "no guarantees" (see [D-40](#d-40) -- the atomicity is often the whole point), and it does not mean "ordered but weakly". The failure mode is that it usually does what the source appears to say, until a change of code generator or a weaker processor makes it not; on x86-64 TSO a decorative `Acquire` and a `Relaxed` load emit near-identical code, so a test suite on this host cannot see the difference at all -- which is the same blindness [D-31](#d-31) measured. **When the two resolutions differ, promote the load.** The exception is a reference count ([D-39](#d-39)), and it is an exception for a stated reason rather than by convention. | | D-39 | **The reference counts keep a relaxed increment against an `AcqRel` decrement, and that is the one sanctioned departure from [D-38](#d-38).** It is sanctioned because *no dependent memory is read on the strength of the relaxed increment*: a thread incrementing `producers` already holds a handle, so it needs no edge to learn the object exists, and the cache-coherence effect an acquire would buy is not required until the count reaches zero -- at which point the `AcqRel` decrement supplies it. That is the same argument `std::sync::Arc` makes, and it is a property of what the count is used for, not a general licence. A count whose value ever decided whether to *dereference* something would not qualify. | | D-40 | **A relaxed atomic is chosen for its *atomicity*, and dropping to a plain field is never the way to "simplify" one.** [D-38](#d-38) says a relaxed operation is unordered; it is still indivisible, and that is frequently the entire reason the field is an atomic at all. Without it the implementation may synthesise a wide access out of narrower ones and observe a **torn** value, and concurrent access to a non-atomic field is a data race and therefore UB regardless. `reserving_mpsc`'s claim word is the worked example: it packs `reserved` and `position` into one `u64` and is uniformly relaxed ([D-38](#d-38)), yet a torn read would yield a pair that never existed as a state and would break the compare-and-swap protocol outright. On `i686-pc-windows-msvc` -- which [D-18](#d-18) deliberately keeps supported -- that load must be a `cmpxchg8b` or an 8-byte SSE load, *more* expensive than the two `mov`s a plain `u64` would get, and the compiler is obliged to pay it for exactly this reason. **Relaxed is a statement about ordering only; it is never a step toward removing the atomic.** | +| D-41 | **The claim word's apportionment is a caller's choice, and the recurrence behind [SH-14.1](../../CHECKLIST-ship-topology-and-queues.md) is a number the caller sets rather than one this crate imposes.** Supersedes [D-36](#d-36), whose premise was that the only fix was the [D-35](#d-35) claim-protocol replacement, gated on an open question -- so disclosing beat delaying. That premise was false, and measurement is what showed it: the 32/32 split followed from requiring the reservation half to hold the *entire capacity*, because every slot could be reserved at once. Capping outstanding reservations instead leaves the capacity bounded only by the ring, and the position is free to take 48 or 56 bits. `Balanced` (32/32), `Enduring` (16/48) and `Perpetual` (8/56) issue the **same** `lock cmpxchg` on the same `u64`, differing only in shift constants, and measured indistinguishable outside noise -- so the recurrence moves from about 37 seconds to about 20 years for no throughput and no dependency. The reservation half was the wrong half to spend bits on: it held 2^32 where the real bound is however many producers are mid-send. `Wide` (64/64 over a `u128`) is offered behind the non-default `dwcas` feature, because it is the one thing here that costs a third-party crate -- the standard library has no 128-bit atomic -- and it measured 2-3x slower on the claim; it buys a guarantee rather than a lifetime argument. **The default stays `Balanced`** so introducing the choice changed no existing caller's behaviour, and it is documented as *not* the recommended layout: leaving it because it is the status quo would preserve the hazard by inertia. | ## D-2: capabilities are sliced, not gathered diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index f7f22a21..3df7744f 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -67,14 +67,47 @@ says so rather than the documentation: `spsc` accepts one slot, and `slotwise_mp two, because its per-slot sequence cannot distinguish "just published" from "free again next lap" in a one-slot ring. -## A known defect in `reserving_mpsc`, disclosed rather than fixed - -**`reserving_mpsc` can lose an item after 2^32 pushes, on every target -- not -only 32-bit ones.** Its claim position is a 32-bit half of a packed word by -construction, so this reaches x86-64 and ARM64 exactly as it reaches i686. Read -that sentence before the paragraph below, because the phrase "32-bit position" -invites the opposite reading and this project has already had to correct that -misreading once. +## How long `reserving_mpsc` runs before its claim position recurs + +**`reserving_mpsc` can lose an item after 2^32 pushes under its default layout, +on every target -- not only 32-bit ones.** That layout gives the claim position +a 32-bit half of a packed word, so this reaches x86-64 and ARM64 exactly as it +reaches i686. Read that sentence before the paragraph below, because the phrase +"32-bit position" invites the opposite reading and this project has already had +to correct that misreading once. + +**This is a property of the default layout, not of the shape**, and that is a +change: it was previously a defect a caller had to live with. The claim word +packs an outstanding-reservation count beside the position, and how its bits are +divided is now a caller's choice. Reservations are bounded by how many producers +are mid-send -- hundreds at most -- so giving up a ceiling nobody reaches buys +positions: + +| Layout | Outstanding reservations | Pushes to recurrence | At sustained maximum rate | +|---|---|---|---| +| `Balanced` (default) | 2^32 | 2^32 | about 37 seconds | +| `Enduring` | 65,535 | 2^48 | about 28 days | +| `Perpetual` | 255 | 2^56 | about 20 years | +| `Wide` (needs `dwcas`) | 2^32 | 2^64 | unreachable | + +```rust +use windows_waitable_queues::reserving_mpsc::{self, Perpetual}; + +// The same queue, with a claim position that outlives the process. +let (tx, rx) = reserving_mpsc::bounded_as::(64)?; +# let _ = (tx, rx); +# Ok::<(), windows_waitable_queues::CapacityError>(()) +``` + +**A deeper position costs nothing measurable.** `Balanced`, `Enduring`, and +`Perpetual` all issue the same exchange on the same 64-bit word and differ only +in shift and mask constants; a probe comparing them found no difference outside +noise. `Wide` is the exception: it needs a 128-bit exchange, which measured 2-3x +slower on the claim, and it is the only thing in this crate that costs a +third-party dependency. + +The default remains `Balanced` so that no existing caller's behaviour changed +when the choice was introduced. It is not the recommended layout. **What happens.** A producer checks that there is room, is descheduled, and resumes after other producers have driven the position field through a complete @@ -88,33 +121,38 @@ receives a different item than the one that was sent, and nothing observable says so -- which is why this is documented here rather than left to a caller to discover, and why it cannot be mitigated after the fact. -**The exposure, measured rather than estimated.** 2^32 pushes is 37 seconds to -roughly four minutes of *sustained* pushing at this crate's own measured rates --- about two minutes at two producers, which is the smallest count that can -trigger it at all. That is sustained throughput, not a total accumulated over an -uptime. Reaching the wrap is necessary but not sufficient: a producer must also -be stalled inside a window a few instructions wide. Rare, but a preemption is -enough, and "rare" over billions of pushes is not "never". - -**What to do about it.** The choice is a real one, which is why the crate states -the facts instead of quietly picking: - -- **`slotwise_mpsc` does not have this hazard.** Its positions are 64 bits on - every target, so the equivalent wrap needs 2^64 claims and cannot be reached. - Prefer it unless you need `Reserving`. +**The exposure, measured rather than estimated.** Under `Balanced`, 2^32 pushes +is 37 seconds to roughly four minutes of *sustained* pushing at this crate's own +measured rates -- about two minutes at two producers, which is the smallest +count that can trigger it at all. That is sustained throughput, not a total +accumulated over an uptime. Reaching the wrap is necessary but not sufficient: a +producer must also be stalled inside a window a few instructions wide. Rare, but +a preemption is enough, and "rare" over billions of pushes is not "never". + +The figures in the table above scale that same measurement by the position +width, so they are a floor on time rather than a forecast: a queue that must +drain cannot sustain the fastest rate measured, and a slower producer takes +proportionally longer to reach its wrap. + +**What to do about it.** + +- **Name a layout.** `Perpetual` puts the recurrence about twenty years out at + no measured cost, which takes it past any real deployment. This is the answer + for almost every caller who is exposed at all. +- **`slotwise_mpsc` does not have this hazard** under any layout. Its positions + are 64 bits on every target, so the equivalent wrap needs 2^64 claims. Prefer + it unless you need `Reserving`. - **`spsc` never had it**, having no contended claim to race. -- **`reserving_mpsc` is sound below the wrap.** A queue that will not push 4.3 +- **The default layout is sound below its wrap.** A queue that will not push 4.3 billion items in one run, or that is not driven at sustained maximum rate by - two or more producers, is not exposed. -- If you need reservations *and* those volumes, say so -- the fix is prototyped - and measured, and it is the shipping decision that is open, not the - engineering. + two or more producers, is not exposed even on `Balanced`. This is disclosed on the same principle as the ordering gap below: an adopter -gets the information we have rather than an assurance we cannot support. The two -are not equally forgiving, though, and the difference is worth stating plainly --- an unverified ordering is a *risk* of a bug, while this is a known one with a -computed exposure. +gets the information we have rather than an assurance we cannot support. The +difference between the two is worth stating plainly -- an unverified ordering is +a *risk* of a bug, while this is a known one with a computed exposure. What has +changed is that the exposure is now a number the caller sets rather than one the +crate imposes. ## How far the memory orderings are verified, and how far they are not diff --git a/crates/windows-waitable-queues/src/capacity/tests.rs b/crates/windows-waitable-queues/src/capacity/tests.rs index d81ca487..6c7816d8 100644 --- a/crates/windows-waitable-queues/src/capacity/tests.rs +++ b/crates/windows-waitable-queues/src/capacity/tests.rs @@ -203,9 +203,11 @@ fn the_shapes_ceilings_are_what_the_public_documentation_claims() { "slotwise_mpsc is bounded by allocation rather than by its own positions" ); - // `reserving_mpsc` packs a 32-bit position beside a reservation count, so - // its own ceiling is 2^31 -- but it is *also* clamped, and on a 32-bit - // target the clamp is the binding constraint. + // `reserving_mpsc`'s default layout gives the position 32 bits, so its own + // ceiling is 2^31 -- but it is *also* clamped, and on a 32-bit target the + // clamp is the binding constraint. `BOUNDS_MAX` is the default layout's + // ceiling by definition; a deeper layout has its own, reachable through + // `ClaimLayout`, and is not what this constant reports. let packed = 1_usize << 31; let expected = if packed <= MAX_ADMISSIBLE_CAPACITY { packed diff --git a/crates/windows-waitable-queues/src/lib.rs b/crates/windows-waitable-queues/src/lib.rs index 3a1a2ceb..b326d3f8 100644 --- a/crates/windows-waitable-queues/src/lib.rs +++ b/crates/windows-waitable-queues/src/lib.rs @@ -75,14 +75,48 @@ //! here has a single consumer, two of them have many *producers*, so a //! "there is room" signal has N waiters and is not the doorbell mirrored. //! -//! # A known defect in `reserving_mpsc`, disclosed rather than fixed -//! -//! **[`reserving_mpsc`] can lose an item after 2^32 pushes, on every target -- -//! not only 32-bit ones.** Its claim position is a 32-bit half of a packed word -//! by construction, so this reaches x86-64 and ARM64 exactly as it reaches -//! i686. Read that sentence before the paragraph below, because the phrase -//! "32-bit position" invites the opposite reading and this project has already -//! had to correct that misreading once. +//! # How long `reserving_mpsc` runs before its claim position recurs +//! +//! **[`reserving_mpsc`] can lose an item after 2^32 pushes under its default +//! layout, on every target -- not only 32-bit ones.** That layout gives the +//! claim position a 32-bit half of a packed word, so this reaches x86-64 and +//! ARM64 exactly as it reaches i686. Read that sentence before the paragraph +//! below, because the phrase "32-bit position" invites the opposite reading and +//! this project has already had to correct that misreading once. +//! +//! **This is a property of the default layout, not of the shape**, and that is +//! a change: it was previously a defect a caller had to live with. The claim +//! word packs an outstanding-reservation count beside the position, and how its +//! bits are divided is now a caller's choice. Reservations are bounded by how +//! many producers are mid-send -- hundreds at most -- so giving up a ceiling +//! nobody reaches buys positions: +//! +//! | Layout | Outstanding reservations | Pushes to recurrence | At sustained maximum rate | +//! |---|---|---|---| +//! | `Balanced` (default) | 2^32 | 2^32 | about 37 seconds | +//! | `Enduring` | 65,535 | 2^48 | about 28 days | +//! | `Perpetual` | 255 | 2^56 | about 20 years | +//! | `Wide` (needs `dwcas`) | 2^32 | 2^64 | unreachable | +//! +//! ``` +//! use windows_waitable_queues::reserving_mpsc::{self, Perpetual}; +//! +//! // The same queue, with a claim position that outlives the process. +//! let (tx, rx) = reserving_mpsc::bounded_as::(64)?; +//! # let _ = (tx, rx); +//! # Ok::<(), windows_waitable_queues::CapacityError>(()) +//! ``` +//! +//! **A deeper position costs nothing measurable.** `Balanced`, `Enduring`, and +//! `Perpetual` all issue the same exchange on the same 64-bit word and differ +//! only in shift and mask constants; a probe comparing them found no difference +//! outside noise. `Wide` is the exception: it needs a 128-bit exchange, which +//! measured 2-3x slower on the claim, and it is the only thing in this crate +//! that costs a third-party dependency. Prefer `Perpetual` unless you want the +//! guarantee rather than the twenty years. +//! +//! The default remains `Balanced` so that no existing caller's behaviour +//! changed when the choice was introduced. It is not the recommended layout. //! //! **What happens.** A producer checks that there is room, is descheduled, and //! resumes after other producers have driven the position field through a @@ -96,34 +130,39 @@ //! observable says so -- which is why this is documented here rather than left //! to a caller to discover, and why it cannot be mitigated after the fact. //! -//! **The exposure, measured rather than estimated.** 2^32 pushes is 37 seconds -//! to roughly four minutes of *sustained* pushing at this crate's own measured -//! rates -- about two minutes at two producers, which is the smallest count -//! that can trigger it at all. That is sustained throughput, not a total -//! accumulated over an uptime. Reaching the wrap is necessary but not -//! sufficient: a producer must also be stalled inside a window a few +//! **The exposure, measured rather than estimated.** Under `Balanced`, 2^32 +//! pushes is 37 seconds to roughly four minutes of *sustained* pushing at this +//! crate's own measured rates -- about two minutes at two producers, which is +//! the smallest count that can trigger it at all. That is sustained throughput, +//! not a total accumulated over an uptime. Reaching the wrap is necessary but +//! not sufficient: a producer must also be stalled inside a window a few //! instructions wide. Rare, but a preemption is enough, and "rare" over //! billions of pushes is not "never". //! -//! **What to do about it.** The choice is a real one, which is why the crate -//! states the facts instead of quietly picking: +//! The figures in the table above scale that same measurement by the position +//! width, so they are a floor on time rather than a forecast: a queue that must +//! drain cannot sustain the fastest rate measured, and a slower producer takes +//! proportionally longer to reach its wrap. +//! +//! **What to do about it.** //! -//! - **[`slotwise_mpsc`] does not have this hazard.** Its positions are 64 bits -//! on every target, so the equivalent wrap needs 2^64 claims and cannot be -//! reached. Prefer it unless you need [`Reserving`]. +//! - **Name a layout.** `Perpetual` puts the recurrence about twenty years out +//! at no measured cost, which takes it past any real deployment. This is the +//! answer for almost every caller who is exposed at all. +//! - **[`slotwise_mpsc`] does not have this hazard** under any layout. Its +//! positions are 64 bits on every target, so the equivalent wrap needs 2^64 +//! claims. Prefer it unless you need [`Reserving`]. //! - **[`spsc`] never had it**, having no contended claim to race. -//! - **[`reserving_mpsc`] is sound below the wrap.** A queue that will not push +//! - **The default layout is sound below its wrap.** A queue that will not push //! 4.3 billion items in one run, or that is not driven at sustained maximum -//! rate by two or more producers, is not exposed. -//! - If you need reservations *and* those volumes, say so -- the fix is -//! prototyped and measured, and it is the shipping decision that is open, not -//! the engineering. +//! rate by two or more producers, is not exposed even on `Balanced`. //! //! This is disclosed on the same principle as the ordering gap below: an //! adopter gets the information we have rather than an assurance we cannot -//! support. The two are not equally forgiving, though, and the difference is -//! worth stating plainly -- an unverified ordering is a *risk* of a bug, while -//! this is a known one with a computed exposure. +//! support. The difference between the two is worth stating plainly -- an +//! unverified ordering is a *risk* of a bug, while this is a known one with a +//! computed exposure. What has changed is that the exposure is now a number the +//! caller sets rather than one the crate imposes. //! //! # How far the memory orderings are verified, and how far they are not //! diff --git a/crates/windows-waitable-queues/src/permit_mpsc.rs b/crates/windows-waitable-queues/src/permit_mpsc.rs index 14f182b0..0d16e83e 100644 --- a/crates/windows-waitable-queues/src/permit_mpsc.rs +++ b/crates/windows-waitable-queues/src/permit_mpsc.rs @@ -20,9 +20,16 @@ //! and then compare-exchanges a claim word that does not contain `head`. The //! decision and the operation that acts on it are separate, which is //! [SH-14.1](../../../CHECKLIST-ship-topology-and-queues.md): a producer stalled -//! between them resumes after the 32-bit position field has recurred, its -//! exchange succeeds against a numerically equal but generations-later value, -//! and it writes a slot whose freedom was decided long ago. +//! between them resumes after the position field has recurred, its exchange +//! succeeds against a numerically equal but generations-later value, and it +//! writes a slot whose freedom was decided long ago. +//! +//! How wide that field is, and so how many pushes recurrence takes, is a layout +//! choice there -- 32 bits under the default and up to 64 under +//! [`reserving_mpsc::Wide`](crate::reserving_mpsc). **That moves the recurrence +//! out of reach without removing the separation that causes it**, which is why +//! this shape remains interesting: it addresses the structure rather than the +//! interval. //! //! Here the decision *is* the operation. A producer takes a permit from a count //! of unspoken-for slots with one atomic, and that single modification both diff --git a/crates/windows-waitable-queues/src/reserving_mpsc.rs b/crates/windows-waitable-queues/src/reserving_mpsc.rs index 750a378d..59dfcb59 100644 --- a/crates/windows-waitable-queues/src/reserving_mpsc.rs +++ b/crates/windows-waitable-queues/src/reserving_mpsc.rs @@ -6,10 +6,11 @@ //! claimed in advance, so that a later delivery cannot be refused for want of //! room. *Reserved is guaranteed, unreserved is best-effort.* //! -//! # Known defect: this shape can lose an item after 2^32 pushes +//! # The claim position recurs, and how soon is a layout choice //! -//! **On every target, not only 32-bit ones** -- the claim position is a 32-bit -//! half of the packed word below by construction, so this reaches x86-64 and +//! **Under the default layout this shape can lose an item after 2^32 pushes, on +//! every target and not only 32-bit ones** -- [`Balanced`] gives the claim +//! position a 32-bit half of the packed word below, so this reaches x86-64 and //! ARM64 exactly as it reaches i686. //! //! A producer that has checked for room, been descheduled, and resumed after @@ -19,15 +20,31 @@ //! silent**: the consumer receives a different item than was sent, and no error, //! panic, or counter reports it. //! -//! 2^32 pushes is 37 seconds to about four minutes of *sustained* pushing at -//! this crate's measured rates, roughly two minutes at two producers. The wrap -//! alone is not enough -- a producer must also stall inside a window a few -//! instructions wide -- but a preemption suffices. +//! Under `Balanced`, 2^32 pushes is 37 seconds to about four minutes of +//! *sustained* pushing at this crate's measured rates, roughly two minutes at +//! two producers. The wrap alone is not enough -- a producer must also stall +//! inside a window a few instructions wide -- but a preemption suffices. //! -//! [`slotwise_mpsc`](crate::slotwise_mpsc) does not have this hazard, its -//! positions being 64 bits on every target; [`spsc`](crate::spsc) never had it. -//! Below the wrap this shape is sound. The full statement, and what to do about -//! it, is in the [crate documentation](crate). +//! **[`ClaimLayout`] is how far away that is.** [`Perpetual`] moves it to 2^56 +//! pushes, about twenty years at the same rate, for the cost of a reservation +//! ceiling of 255 and nothing measurable besides -- it is the same exchange on +//! the same word, differing only in shift constants. [`Enduring`] sits between +//! them, and the `dwcas` feature adds a 128-bit word that removes the +//! recurrence outright. +//! +//! ``` +//! use windows_waitable_queues::reserving_mpsc::{self, Perpetual}; +//! +//! let (tx, rx) = reserving_mpsc::bounded_as::(64)?; +//! # let _ = (tx, rx); +//! # Ok::<(), windows_waitable_queues::CapacityError>(()) +//! ``` +//! +//! The default stays `Balanced` so that introducing the choice changed no +//! existing caller's behaviour; it is not the recommended layout. +//! [`slotwise_mpsc`](crate::slotwise_mpsc) does not have this hazard under any +//! layout, its positions being 64 bits on every target; [`spsc`](crate::spsc) +//! never had it. The full statement is in the [crate documentation](crate). //! //! # Why this is a separate shape rather than a method on `slotwise_mpsc` //! @@ -176,7 +193,7 @@ pub trait ClaimLayout: sealed::Sealed { /// The integer the two halves are packed into. /// /// `u64` for every layout the crate offers by default. The `dwcas` feature - /// adds [`Wide`], whose word is a `u128` -- and the arithmetic is done in + /// adds `Wide`, whose word is a `u128` -- and the arithmetic is done in /// this type rather than uniformly in the wider one, so a `u64` layout /// issues `u64` instructions exactly as it did before the type became a /// parameter. @@ -244,7 +261,7 @@ pub trait ClaimLayout: sealed::Sealed { /// **Forced at construction rather than left to be evaluated.** An /// associated constant in a generic context is only evaluated where it is /// used, so assertions written here and never mentioned would compile for - /// every layout including a broken one. [`build`] names this so that + /// every layout including a broken one. The constructors name it, so /// creating a queue is what checks it. /// /// Note what is deliberately *not* asserted: that `BOUNDS_MAX` is at most From fab14feef957c6007d00cbeadbc4029bbcda0fc4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sat, 5 Sep 2026 00:24:19 -0400 Subject: [PATCH 353/361] refactor(platform-probes): measure the shipping layouts, deleting the duplicate `windows-waitable-queues` takes the claim layout as a parameter now, so the probe instantiates the real type at each of `Balanced`, `Enduring`, `Perpetual` and `Wide` rather than carrying its own copy of the protocol. The duplicate existed so the layouts could be compared before the shipping crate had them; that reason is spent, and 501 lines go with it. The direct `portable-atomic` dependency goes too. The probe now reaches a 128-bit word through the queue crate's `dwcas` feature, so there is one place that decides how that atomic is configured instead of two that could disagree. The two timing functions are generic over the layout where their `permit_mpsc` neighbour is deliberately duplicated, and the difference is worth stating: that twin compares two *different types*, which a generic could only unify behind a trait, putting an indirection inside the timed region. These are the *same type* at different layout parameters, so this monomorphises to what a hand-written copy would produce. **Re-measuring on the real type corrected the result.** The duplicate reported the 128-bit exchange at 2.37x and 2.99x the default at sixteen and thirty-two producers; the shipping type reports 3.83x and 3.99x. The stand-in was understating the cost of the very layout it was built to evaluate, by the widest margin exactly where the decision is most sensitive. The apportionment finding survived unchanged -- both `u64` re-apportionments still track the default within noise, so `Perpetual`'s twenty years of headroom really is free. The 1.26x offset the duplicate carried is gone: running the same configuration twice through the shipping type agrees within noise, 50.3 ns against 52.1 ns at thirty-two producers, because both rows are now the same code. Completed item: CW-1.6: Delete the duplicated implementation in claim_layout.rs, keeping only what CW-2.3 leaves no other way to measure -- which, since Wide ships, is nothing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 - .../CHECKLIST-claim-word-layout.md | 2 +- crates/windows-platform-probes/Cargo.toml | 4 +- .../windows-platform-probes/DESIGN-NOTES.md | 39 ++ .../src/bin/queue_contention.rs | 45 +- .../src/claim_layout.rs | 406 ------------------ .../src/claim_layout/tests.rs | 95 ---- crates/windows-platform-probes/src/lib.rs | 1 - .../src/queue_contention.rs | 262 +++-------- 9 files changed, 138 insertions(+), 717 deletions(-) delete mode 100644 crates/windows-platform-probes/src/claim_layout.rs delete mode 100644 crates/windows-platform-probes/src/claim_layout/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 3228b5b1..590c1ad3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,7 +245,6 @@ dependencies = [ name = "windows-platform-probes" version = "0.0.0" dependencies = [ - "portable-atomic", "windows-namespace-request-sys", "windows-placement-probe", "windows-sys", diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md index d12927b7..59a5c765 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md @@ -188,7 +188,7 @@ dependency at all -- only 64/64 does, which is `CW-2.3`. ## M3: retire the duplicate -- [ ] **CW-1.6** -- Delete the duplicated *implementation* in +- [x] **CW-1.6** -- Delete the duplicated *implementation* in [claim_layout.rs](src/claim_layout.rs), keeping only what `CW-2.3` leaves no other way to measure. diff --git a/crates/windows-platform-probes/Cargo.toml b/crates/windows-platform-probes/Cargo.toml index d78f52c6..e619813c 100644 --- a/crates/windows-platform-probes/Cargo.toml +++ b/crates/windows-platform-probes/Cargo.toml @@ -116,9 +116,11 @@ windows-namespace-request-sys = { path = "../windows-namespace-request-sys" } # on the same host, in the same run, by the same harness. windows-waitable-queues = { path = "../windows-waitable-queues", features = [ "experimental-permit-claim", + # So the probe can instantiate the 128-bit layout. The 64-bit ones need no + # feature; this is the only one that costs the queue crate a dependency. + "dwcas", ] } wtf-string = { path = "../wtf-string" } -portable-atomic = { version = "1.15.0", default-features = false } [dependencies.windows-sys] version = "0.61.2" diff --git a/crates/windows-platform-probes/DESIGN-NOTES.md b/crates/windows-platform-probes/DESIGN-NOTES.md index 91289060..5e0bc687 100644 --- a/crates/windows-platform-probes/DESIGN-NOTES.md +++ b/crates/windows-platform-probes/DESIGN-NOTES.md @@ -765,3 +765,42 @@ without reopening `D-18`'s i686 question. So the candidates worth considering are **12/52 and 8/56**, not the 16/48 first sketched here: 16/48's 12.7 days at the conservative floor is still reachable by a busy long-lived process, and 12/52 is the first row that is not. + +### Re-measured on the shipping type, and the duplicate had understated the wide word + +`CW-1.6` deleted the duplicated protocol in this crate once +`windows-waitable-queues` took the layout as a parameter, so the probe now +instantiates the real type at each layout. The numbers below supersede the ones +above, which were taken from the stand-in. + +| producers | 16/48 vs 32/32 | 8/56 vs 32/32 | 64/64 vs 32/32 | +|---|---|---|---| +| 1 | 1.02x | 0.98x | 1.45x | +| 4 | 1.04x | 1.01x | 1.33x | +| 8 | 1.05x | 1.05x | 1.59x | +| 16 | 1.21x | 1.21x | **3.83x** | +| 32 | 1.05x | 1.13x | **3.99x** | + +**The finding about apportionment survives contact with the real type.** Both +`u64` re-apportionments track the default within noise, including `Perpetual`'s +8/56 -- so buying twenty years of headroom really is free, and it is now +measured on the code that ships rather than on something resembling it. + +**The finding about width did not survive unchanged.** The duplicate reported +the 128-bit exchange at 2.37x and 2.99x at sixteen and thirty-two producers; the +real type reports 3.83x and 3.99x. The stand-in was *understating* the cost of +the layout it was built to evaluate, and by the widest margin exactly where the +decision is most sensitive. The conclusion is unaltered in direction and firmer +in degree. + +**The residual offset is gone, which is the point of the deletion.** The +duplicate ran about 1.26x slower than `reserving_mpsc` at high producer counts, +an error that had to be carried as a caveat on every figure. Running the same +configuration twice through the shipping type now agrees within noise -- 50.3 ns +against 52.1 ns at thirty-two producers -- because both rows are the same code. + +The general lesson is worth keeping even though the duplicate is gone: +**a stand-in is only evidence about the thing it stands in for while something +checks that it still does.** This one was checked, which is how the missing +cache padding was caught; but the checking only ever bounded the error, and the +bound was loose enough to hide a third of the wide word's cost. diff --git a/crates/windows-platform-probes/src/bin/queue_contention.rs b/crates/windows-platform-probes/src/bin/queue_contention.rs index 6a767537..bc812b07 100644 --- a/crates/windows-platform-probes/src/bin/queue_contention.rs +++ b/crates/windows-platform-probes/src/bin/queue_contention.rs @@ -169,7 +169,15 @@ fn render() -> String { let _ = writeln!(out, "\n 3. claim-word layout\n"); let _ = writeln!( out, - " 32/32 is what ships; 16/48 is the same u64 exchange with the bits" + " Four apportionments of reserving_mpsc's claim word, measured on" + ); + let _ = writeln!( + out, + " the shipping type itself rather than on a stand-in. 32/32 is the" + ); + let _ = writeln!( + out, + " default; 16/48 and 8/56 are the same u64 exchange with the bits" ); let _ = writeln!( out, @@ -177,19 +185,19 @@ fn render() -> String { ); let _ = writeln!( out, - " 32/32 and 16/48 issue the SAME instruction, so a difference" + " The three u64 rows issue the SAME instruction, so a difference" ); let _ = writeln!( out, - " between them prices the wider head and per-slot sequence the" + " between them is noise or slot-metadata density, not the claim." ); let _ = writeln!( out, - " deeper position forces, not the claim. 64/64 vs 32/32 prices the" + " 64/64 vs 32/32 prices the double-width exchange -- what removing" ); let _ = writeln!( out, - " double-width exchange -- the cost of removing the wrap entirely.\n" + " the recurrence outright costs, against 8/56 merely deferring it.\n" ); for (label, regime) in [ ("isolated", &observation.isolated), @@ -198,21 +206,31 @@ fn render() -> String { let _ = writeln!(out, " -- {label} --"); let _ = writeln!( out, - " {:<12} {:>12} {:>12} {:>12} {:>12} {:>12}", - "producers", "32/32 ns", "16/48 ns", "64/64 ns", "16/48 vs", "64/64 vs" + " {:<10} {:>11} {:>11} {:>11} {:>11} {:>10} {:>10} {:>10}", + "producers", + "32/32 ns", + "16/48 ns", + "8/56 ns", + "64/64 ns", + "16/48 vs", + "8/56 vs", + "64/64 vs" ); for &producers in PRODUCER_COUNTS { let narrow = observation.find(regime, shapes::CLAIM_NARROW, producers); let deep = observation.find(regime, shapes::CLAIM_DEEP, producers); + let perpetual = observation.find(regime, shapes::CLAIM_PERPETUAL, producers); let wide = observation.find(regime, shapes::CLAIM_WIDE, producers); let _ = writeln!( out, - " {:<12} {:>12} {:>12} {:>12} {:>12} {:>12}", + " {:<10} {:>11} {:>11} {:>11} {:>11} {:>10} {:>10} {:>10}", producers, format_nanos(narrow), format_nanos(deep), + format_nanos(perpetual), format_nanos(wide), format_ratio(deep, narrow), + format_ratio(perpetual, narrow), format_ratio(wide, narrow) ); } @@ -220,25 +238,24 @@ fn render() -> String { } let _ = writeln!( out, - " the shipping reserving_mpsc row above is the control: 32/32 here" + " the 32/32 row and the reserving_mpsc row above are the same" ); let _ = writeln!( out, - " is a duplicate of it, so the two should agree. They will not match" + " configuration run twice, so they should agree within noise. They" ); let _ = writeln!( out, - " exactly -- the duplicate carries no metrics, doorbell, or" + " are no longer a control against a duplicated implementation: the" ); let _ = writeln!( out, - " disconnection checks -- but a large gap means the duplicate is not" + " shipping type takes the layout as a parameter, so there is nothing" ); let _ = writeln!( out, - " standing in faithfully and the comparison below is not trustworthy." + " left that could drift away from what callers actually run." ); - let _ = writeln!( out, "\n CAUTION: the drained regime has ONE consumer, because that is what" diff --git a/crates/windows-platform-probes/src/claim_layout.rs b/crates/windows-platform-probes/src/claim_layout.rs deleted file mode 100644 index 5dd8fc90..00000000 --- a/crates/windows-platform-probes/src/claim_layout.rs +++ /dev/null @@ -1,406 +0,0 @@ -// Copyright (c) Mike Grier. - -//! Three apportionments of the reserving claim word, for measurement. -//! -//! **An experiment, not a component.** These are deliberately duplicated -//! implementations of `windows-waitable-queues`' `reserving_mpsc` claim -//! protocol, built here so the shipping crate is not disturbed while the -//! layouts are compared. See CHECKLIST-claim-word-layout.md; the -//! merge-or-delete decision is `CW-1.6`. -//! -//! The protocol is the shipping one: producers claim a position by advancing a -//! packed `(reserved, position)` word with one compare-and-swap, then wait to -//! observe a `head` that has passed the slot's previous occupant before -//! writing it. Only the word's width and split differ between the three. -//! -//! | Layout | Word | reserved / position | Recurrence at | -//! |---|---|---|---| -//! | [`narrow`] | `u64` | 32 / 32 | 2^32 pushes | -//! | [`deep`] | `u64` | 16 / 48 | 2^48 pushes | -//! | [`wide`] | `u128` | 64 / 64 | 2^64 pushes | -//! -//! **`deep` decouples the reservation ceiling from the capacity.** The shipping -//! shape requires the `reserved` half to hold the entire capacity, because -//! every slot may be reserved at once; that is what makes a 2^31 capacity -//! ceiling consume 32 bits. Capping *outstanding reservations* at 65535 while -//! leaving the capacity bounded only by the ring lets the position keep 48 -//! bits. Reservations exist for messages that must not be lost, so a ceiling -//! far below the capacity is a different promise rather than a broken one -- -//! but it is a contract change, which is why it is measured before it is -//! proposed. -//! -//! Hand-written three times rather than made generic over a layout trait, for -//! the reason `time_isolated_permit` is a line-for-line twin of its neighbour: -//! an abstraction that might not inline identically would be reported as the -//! algorithm's cost, in a measurement whose whole output is a difference of a -//! few nanoseconds per push. -//! -//! Items are `u64` throughout. That keeps the slot payload identical across the -//! three so the comparison is of claim words and slot metadata, and it removes -//! drop glue from the timed region. - -#[cfg(test)] -mod tests; - -/// Isolates a field onto its own cache line. -/// -/// **Load-bearing, and measured to be.** The shipping shape puts both `head` -/// and the claim word behind this, because every producer reads `head` on -/// every push and the consumer writes it; sharing a line puts the consumer's -/// writes directly in every producer's path. A first version of this module -/// omitted the padding and measured 193.8 ns/push against the shipping shape's -/// 51.8 at 32 producers -- a 3.7x gap that was the missing alignment, not the -/// layouts being compared. 128 rather than 64 to match, which is what the -/// prefetcher pulling an adjacent line makes necessary. -#[repr(align(128))] -struct CacheAligned(T); - -pub mod narrow { - //! The shipping apportionment: a `u64` word split 32 / 32. - - use std::cell::UnsafeCell; - use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; - - /// Bits of the claim word given to the position. - const POSITION_BITS: u32 = 32; - - /// Isolates the position half of the claim word. - const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; - - /// One cell of the ring. - struct Slot { - /// `position + 1` once the claiming producer has finished writing. - sequence: AtomicU32, - value: UnsafeCell, - } - - /// A bounded MPSC whose claim word is split 32 / 32. - pub struct Queue { - claim: super::CacheAligned, - head: super::CacheAligned, - mask: u32, - capacity: u32, - slots: Box<[Slot]>, - } - - // SAFETY: a position is claimed by exactly one producer, which is therefore - // the slot's only writer, and it publishes with a release store the - // consumer acquires. The consumer is the only reader and advances `head` - // with a release store the producers acquire before reusing the slot. - unsafe impl Sync for Queue {} - // SAFETY: as above; the payload is `u64`, which is `Send`. - unsafe impl Send for Queue {} - - impl Queue { - /// Build a queue whose capacity is `capacity`, which must be a power of two. - #[must_use] - pub fn with_capacity(capacity: usize) -> Self { - assert!( - capacity.is_power_of_two(), - "capacity must be a power of two" - ); - let slots = (0..capacity) - .map(|_| Slot { - sequence: AtomicU32::new(0), - value: UnsafeCell::new(0), - }) - .collect::>() - .into_boxed_slice(); - Self { - claim: super::CacheAligned(AtomicU64::new(0)), - head: super::CacheAligned(AtomicU32::new(0)), - mask: (capacity - 1) as u32, - capacity: capacity as u32, - slots, - } - } - - /// Claim a position and publish `item`, or report the queue full. - pub fn push(&self, item: u64) -> bool { - let mut word = self.claim.0.load(Ordering::Relaxed); - let position = loop { - let position = (word & POSITION_MASK) as u32; - let reserved = (word >> POSITION_BITS) as u32; - let occupied = position.wrapping_sub(self.head.0.load(Ordering::Acquire)); - if occupied >= self.capacity - reserved { - // Provisional: `position` and `head` were read at different - // instants, so re-read the claim before believing it. - let current = self.claim.0.load(Ordering::Relaxed); - if current != word { - word = current; - continue; - } - return false; - } - let next = ((reserved as u64) << POSITION_BITS) | position.wrapping_add(1) as u64; - match self.claim.0.compare_exchange_weak( - word, - next, - Ordering::Relaxed, - Ordering::Relaxed, - ) { - Ok(_) => break position, - Err(actual) => word = actual, - } - }; - - // The acquire edge the slot write needs: the consumer frees a slot - // with a release store to `head`, and this must observe one that has - // passed this position's previous occupant. - while position.wrapping_sub(self.head.0.load(Ordering::Acquire)) >= self.capacity { - std::hint::spin_loop(); - } - - let slot = &self.slots[(position & self.mask) as usize]; - // SAFETY: this thread claimed `position`, so it is the only writer, - // and the loop above established the previous occupant is gone. - unsafe { *slot.value.get() = item }; - slot.sequence - .store(position.wrapping_add(1), Ordering::Release); - true - } - - /// Take the oldest published item. Single consumer only. - pub fn pop(&self) -> Option { - let head = self.head.0.load(Ordering::Relaxed); - let slot = &self.slots[(head & self.mask) as usize]; - if slot.sequence.load(Ordering::Acquire) != head.wrapping_add(1) { - return None; - } - // SAFETY: the sequence read above synchronizes-with the producer's - // release store, so the write of this item happens-before this read. - let item = unsafe { *slot.value.get() }; - self.head.0.store(head.wrapping_add(1), Ordering::Release); - Some(item) - } - } -} - -pub mod deep { - //! An asymmetric apportionment: a `u64` word split 16 / 48. - - use std::cell::UnsafeCell; - use std::sync::atomic::{AtomicU64, Ordering}; - - /// Bits of the claim word given to the position. - const POSITION_BITS: u32 = 48; - - /// Isolates the position half of the claim word. - const POSITION_MASK: u64 = (1 << POSITION_BITS) - 1; - - /// One cell of the ring. - struct Slot { - /// Widened to match the position: a sequence narrower than the position - /// would alias every 2^32 and reintroduce the recurrence on the - /// consumer's side, which is the defect the split exists to remove. - sequence: AtomicU64, - value: UnsafeCell, - } - - /// A bounded MPSC whose claim word is split 16 / 48. - pub struct Queue { - claim: super::CacheAligned, - head: super::CacheAligned, - mask: u64, - capacity: u64, - slots: Box<[Slot]>, - } - - // SAFETY: as `narrow`'s; the protocol is identical and only the split differs. - unsafe impl Sync for Queue {} - // SAFETY: as above. - unsafe impl Send for Queue {} - - impl Queue { - /// Build a queue whose capacity is `capacity`, which must be a power of two. - #[must_use] - pub fn with_capacity(capacity: usize) -> Self { - assert!( - capacity.is_power_of_two(), - "capacity must be a power of two" - ); - let slots = (0..capacity) - .map(|_| Slot { - sequence: AtomicU64::new(u64::MAX), - value: UnsafeCell::new(0), - }) - .collect::>() - .into_boxed_slice(); - Self { - claim: super::CacheAligned(AtomicU64::new(0)), - head: super::CacheAligned(AtomicU64::new(0)), - mask: (capacity - 1) as u64, - capacity: capacity as u64, - slots, - } - } - - /// Claim a position and publish `item`, or report the queue full. - pub fn push(&self, item: u64) -> bool { - let mut word = self.claim.0.load(Ordering::Relaxed); - let position = loop { - let position = word & POSITION_MASK; - let reserved = word >> POSITION_BITS; - // Masked because the position wraps at 2^48 rather than at the - // word's own width, which is the cost an asymmetric split pays - // and a 32 / 32 one gets free from `u32` truncation. - let occupied = - position.wrapping_sub(self.head.0.load(Ordering::Acquire)) & POSITION_MASK; - if occupied >= self.capacity - reserved { - let current = self.claim.0.load(Ordering::Relaxed); - if current != word { - word = current; - continue; - } - return false; - } - let next = (reserved << POSITION_BITS) | (position.wrapping_add(1) & POSITION_MASK); - match self.claim.0.compare_exchange_weak( - word, - next, - Ordering::Relaxed, - Ordering::Relaxed, - ) { - Ok(_) => break position, - Err(actual) => word = actual, - } - }; - - while (position.wrapping_sub(self.head.0.load(Ordering::Acquire)) & POSITION_MASK) - >= self.capacity - { - std::hint::spin_loop(); - } - - let slot = &self.slots[(position & self.mask) as usize]; - // SAFETY: as `narrow`'s -- sole claimant, previous occupant gone. - unsafe { *slot.value.get() = item }; - slot.sequence - .store(position.wrapping_add(1) & POSITION_MASK, Ordering::Release); - true - } - - /// Take the oldest published item. Single consumer only. - pub fn pop(&self) -> Option { - let head = self.head.0.load(Ordering::Relaxed); - let slot = &self.slots[(head & self.mask) as usize]; - if slot.sequence.load(Ordering::Acquire) != (head.wrapping_add(1) & POSITION_MASK) { - return None; - } - // SAFETY: as `narrow`'s. - let item = unsafe { *slot.value.get() }; - self.head - .0 - .store(head.wrapping_add(1) & POSITION_MASK, Ordering::Release); - Some(item) - } - } -} - -pub mod wide { - //! The double-width apportionment: a `u128` word split 64 / 64. - - use portable_atomic::AtomicU128; - use std::cell::UnsafeCell; - use std::sync::atomic::{AtomicU64, Ordering}; - - /// Bits of the claim word given to the position. - const POSITION_BITS: u32 = 64; - - /// One cell of the ring. - struct Slot { - sequence: AtomicU64, - value: UnsafeCell, - } - - /// A bounded MPSC whose claim word is a `u128` split 64 / 64. - pub struct Queue { - claim: super::CacheAligned, - head: super::CacheAligned, - mask: u64, - capacity: u64, - slots: Box<[Slot]>, - } - - // SAFETY: as `narrow`'s; the protocol is identical and only the width differs. - unsafe impl Sync for Queue {} - // SAFETY: as above. - unsafe impl Send for Queue {} - - impl Queue { - /// Build a queue whose capacity is `capacity`, which must be a power of two. - #[must_use] - pub fn with_capacity(capacity: usize) -> Self { - assert!( - capacity.is_power_of_two(), - "capacity must be a power of two" - ); - let slots = (0..capacity) - .map(|_| Slot { - sequence: AtomicU64::new(u64::MAX), - value: UnsafeCell::new(0), - }) - .collect::>() - .into_boxed_slice(); - Self { - claim: super::CacheAligned(AtomicU128::new(0)), - head: super::CacheAligned(AtomicU64::new(0)), - mask: (capacity - 1) as u64, - capacity: capacity as u64, - slots, - } - } - - /// Claim a position and publish `item`, or report the queue full. - pub fn push(&self, item: u64) -> bool { - let mut word = self.claim.0.load(Ordering::Relaxed); - let position = loop { - let position = word as u64; - let reserved = (word >> POSITION_BITS) as u64; - let occupied = position.wrapping_sub(self.head.0.load(Ordering::Acquire)); - if occupied >= self.capacity - reserved { - let current = self.claim.0.load(Ordering::Relaxed); - if current != word { - word = current; - continue; - } - return false; - } - let next = ((reserved as u128) << POSITION_BITS) | position.wrapping_add(1) as u128; - match self.claim.0.compare_exchange_weak( - word, - next, - Ordering::Relaxed, - Ordering::Relaxed, - ) { - Ok(_) => break position, - Err(actual) => word = actual, - } - }; - - while position.wrapping_sub(self.head.0.load(Ordering::Acquire)) >= self.capacity { - std::hint::spin_loop(); - } - - let slot = &self.slots[(position & self.mask) as usize]; - // SAFETY: as `narrow`'s -- sole claimant, previous occupant gone. - unsafe { *slot.value.get() = item }; - slot.sequence - .store(position.wrapping_add(1), Ordering::Release); - true - } - - /// Take the oldest published item. Single consumer only. - pub fn pop(&self) -> Option { - let head = self.head.0.load(Ordering::Relaxed); - let slot = &self.slots[(head & self.mask) as usize]; - if slot.sequence.load(Ordering::Acquire) != head.wrapping_add(1) { - return None; - } - // SAFETY: as `narrow`'s. - let item = unsafe { *slot.value.get() }; - self.head.0.store(head.wrapping_add(1), Ordering::Release); - Some(item) - } - } -} diff --git a/crates/windows-platform-probes/src/claim_layout/tests.rs b/crates/windows-platform-probes/src/claim_layout/tests.rs deleted file mode 100644 index c1f93597..00000000 --- a/crates/windows-platform-probes/src/claim_layout/tests.rs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright (c) Mike Grier. - -//! Correctness checks for the three claim-word layouts. -//! -//! A measurement of a queue that loses or duplicates items is worthless, so -//! each layout is checked to deliver exactly what was pushed before it is -//! timed. These are not a substitute for `windows-waitable-queues`' own suite; -//! they establish that the duplicated protocol in this crate behaves like the -//! one it is standing in for. - -use std::collections::HashSet; -use std::sync::Arc; -use std::thread; - -use super::{deep, narrow, wide}; - -/// How many items each producer pushes in the concurrent checks. -const PER_PRODUCER: u64 = 2_000; - -/// How many producers the concurrent checks run. -const PRODUCERS: u64 = 4; - -macro_rules! layout_suite { - ($module:ident, $name:ident) => { - mod $name { - use super::*; - - #[test] - fn delivers_in_order_from_one_producer() { - let queue = $module::Queue::with_capacity(8); - for value in 0..64u64 { - while !queue.push(value) { - assert!(queue.pop().is_some(), "the queue may only refuse when full"); - } - } - let mut drained = Vec::new(); - while let Some(value) = queue.pop() { - drained.push(value); - } - assert!( - drained.windows(2).all(|pair| pair[0] < pair[1]), - "a single producer's items must arrive in the order it pushed them" - ); - } - - #[test] - fn refuses_when_full_rather_than_overwriting() { - let queue = $module::Queue::with_capacity(4); - for value in 0..4u64 { - assert!(queue.push(value), "the first four fit"); - } - assert!(!queue.push(4), "the fifth must be refused, not overwrite"); - for expected in 0..4u64 { - assert_eq!(queue.pop(), Some(expected)); - } - assert_eq!(queue.pop(), None, "the refused item was never accepted"); - } - - #[test] - fn loses_nothing_under_concurrent_producers() { - let queue = Arc::new($module::Queue::with_capacity(64)); - let mut handles = Vec::new(); - for producer in 0..PRODUCERS { - let queue = Arc::clone(&queue); - handles.push(thread::spawn(move || { - for index in 0..PER_PRODUCER { - let value = producer * PER_PRODUCER + index; - while !queue.push(value) { - std::thread::yield_now(); - } - } - })); - } - - let expected = (PRODUCERS * PER_PRODUCER) as usize; - let mut seen = HashSet::with_capacity(expected); - while seen.len() < expected { - if let Some(value) = queue.pop() { - assert!(seen.insert(value), "item {value} was delivered twice"); - } - } - - for handle in handles { - handle.join().expect("no producer panicked"); - } - assert_eq!(seen.len(), expected, "every pushed item was delivered"); - assert_eq!(queue.pop(), None, "nothing extra was delivered"); - } - } - }; -} - -layout_suite!(narrow, narrow_layout); -layout_suite!(deep, deep_layout); -layout_suite!(wide, wide_layout); diff --git a/crates/windows-platform-probes/src/lib.rs b/crates/windows-platform-probes/src/lib.rs index 81b299ee..c59e1903 100644 --- a/crates/windows-platform-probes/src/lib.rs +++ b/crates/windows-platform-probes/src/lib.rs @@ -110,7 +110,6 @@ #![warn(missing_docs)] pub mod cancel_io; -pub mod claim_layout; pub mod completion_port; pub mod device_map; pub mod doorbell_cost; diff --git a/crates/windows-platform-probes/src/queue_contention.rs b/crates/windows-platform-probes/src/queue_contention.rs index 97fd4805..1af1d049 100644 --- a/crates/windows-platform-probes/src/queue_contention.rs +++ b/crates/windows-platform-probes/src/queue_contention.rs @@ -54,7 +54,7 @@ use std::time::Instant; use windows_waitable_queues::{permit_mpsc, reserving_mpsc, slotwise_mpsc}; -use crate::claim_layout; +use windows_waitable_queues::reserving_mpsc::{Balanced, ClaimLayout, Enduring, Perpetual, Wide}; /// How many pushes each producer thread performs in one timed run. const PUSHES_PER_PRODUCER: usize = 50_000; @@ -92,16 +92,18 @@ pub mod shapes { pub const PERMIT_MPSC: &str = "permit_mpsc"; /// The uncontended-atomic floor the queues are measured against. pub const BASELINE_FETCH_ADD: &str = "baseline_fetch_add"; - /// The reserving claim word as it ships: a `u64` split 32 / 32. + /// `reserving_mpsc` on its default layout: a `u64` split 32 / 32. /// - /// Measured beside [`RESERVING_MPSC`] rather than assumed equal to it: it - /// is a duplicated implementation, so a divergence between the two is - /// evidence the duplicate is not standing in faithfully. - pub const CLAIM_NARROW: &str = "claim_32_32"; - /// The reserving claim word split 16 / 48, still one `u64` exchange. - pub const CLAIM_DEEP: &str = "claim_16_48"; - /// The reserving claim word widened to a `u128` split 64 / 64. - pub const CLAIM_WIDE: &str = "claim_64_64"; + /// The same configuration as [`RESERVING_MPSC`], run again under its own + /// name so the layout comparison reads without a reader having to know + /// which layout the default is. + pub const CLAIM_NARROW: &str = "reserving(32/32)"; + /// `reserving_mpsc` on `Enduring`: a `u64` split 16 / 48. + pub const CLAIM_DEEP: &str = "reserving(16/48)"; + /// `reserving_mpsc` on `Perpetual`: a `u64` split 8 / 56. + pub const CLAIM_PERPETUAL: &str = "reserving(8/56)"; + /// `reserving_mpsc` on `Wide`: a `u128` split 64 / 64. + pub const CLAIM_WIDE: &str = "reserving(64/64)"; } /// One configuration's result. #[derive(Debug, Clone, Copy, PartialEq)] @@ -187,23 +189,29 @@ pub fn measure() -> Observation { })); isolated.push(median_run(shapes::CLAIM_NARROW, producers, |count| { - time_isolated_claim_narrow(count) + time_isolated_layout::(count) })); isolated.push(median_run(shapes::CLAIM_DEEP, producers, |count| { - time_isolated_claim_deep(count) + time_isolated_layout::(count) + })); + isolated.push(median_run(shapes::CLAIM_PERPETUAL, producers, |count| { + time_isolated_layout::(count) })); isolated.push(median_run(shapes::CLAIM_WIDE, producers, |count| { - time_isolated_claim_wide(count) + time_isolated_layout::(count) })); drained.push(median_run(shapes::CLAIM_NARROW, producers, |count| { - time_drained_claim_narrow(count) + time_drained_layout::(count) })); drained.push(median_run(shapes::CLAIM_DEEP, producers, |count| { - time_drained_claim_deep(count) + time_drained_layout::(count) + })); + drained.push(median_run(shapes::CLAIM_PERPETUAL, producers, |count| { + time_drained_layout::(count) })); drained.push(median_run(shapes::CLAIM_WIDE, producers, |count| { - time_drained_claim_wide(count) + time_drained_layout::(count) })); } @@ -545,86 +553,34 @@ fn time_drained_permit(producers: usize) -> Repetition { (elapsed, refusals) } -/// The shipping 32 / 32 claim word, in the regime that isolates the claim. +/// One claim-word layout, in the regime that isolates the claim. /// -/// A line-for-line twin of [`time_isolated_reserving`] with the duplicated -/// layout substituted, and duplicated again for each of the three layouts for -/// the reason [`time_isolated_permit`] gives: a generic over the layouts would -/// put an indirection that might not inline identically inside the timed -/// region, in a measurement whose whole output is a difference of a few -/// nanoseconds per push. -fn time_isolated_claim_narrow(producers: usize) -> Repetition { - let queue = Arc::new(claim_layout::narrow::Queue::with_capacity(capacity_for( - producers, - ))); - let gate = start_barrier(producers); - let started = thread::scope(|scope| { - for producer in 0..producers { - let queue = Arc::clone(&queue); - let gate = Arc::clone(&gate); - scope.spawn(move || { - gate.wait(); - for index in 0..PUSHES_PER_PRODUCER { - assert!( - queue.push((producer * PUSHES_PER_PRODUCER + index) as u64), - "the run fits in the capacity" - ); - } - }); - } - gate.wait(); - Instant::now() - }); - let elapsed = started.elapsed().as_nanos() as f64; - while queue.pop().is_some() {} - (elapsed, 0) -} - -/// The 16 / 48 claim word, in the regime that isolates the claim. -fn time_isolated_claim_deep(producers: usize) -> Repetition { - let queue = Arc::new(claim_layout::deep::Queue::with_capacity(capacity_for( - producers, - ))); - let gate = start_barrier(producers); - let started = thread::scope(|scope| { - for producer in 0..producers { - let queue = Arc::clone(&queue); - let gate = Arc::clone(&gate); - scope.spawn(move || { - gate.wait(); - for index in 0..PUSHES_PER_PRODUCER { - assert!( - queue.push((producer * PUSHES_PER_PRODUCER + index) as u64), - "the run fits in the capacity" - ); - } - }); - } - gate.wait(); - Instant::now() - }); - let elapsed = started.elapsed().as_nanos() as f64; - while queue.pop().is_some() {} - (elapsed, 0) -} - -/// The 64 / 64 claim word, in the regime that isolates the claim. -fn time_isolated_claim_wide(producers: usize) -> Repetition { - let queue = Arc::new(claim_layout::wide::Queue::with_capacity(capacity_for( - producers, - ))); +/// **Generic over the layout, where [`time_isolated_permit`] is deliberately +/// duplicated, and the difference is the point.** That twin compares two +/// *different types*, which a generic could only unify behind a trait, putting +/// an indirection that might not inline identically inside the timed region. +/// These are the *same type* at different layout parameters, so this +/// monomorphises to exactly the code a hand-written copy would produce -- there +/// is nothing left to dispatch. +/// +/// Measures `reserving_mpsc` itself rather than a stand-in. An earlier form of +/// this probe carried its own duplicated implementation of the claim protocol, +/// built so the layouts could be compared before the shipping crate had them; +/// it drifted from the original twice while doing so. The shipping type takes +/// the layout as a parameter now, so the duplicate is gone. +fn time_isolated_layout(producers: usize) -> Repetition { + let (tx, rx) = + reserving_mpsc::bounded_as::(capacity_for(producers)).expect("a valid capacity"); let gate = start_barrier(producers); let started = thread::scope(|scope| { for producer in 0..producers { - let queue = Arc::clone(&queue); + let tx = tx.clone(); let gate = Arc::clone(&gate); scope.spawn(move || { gate.wait(); for index in 0..PUSHES_PER_PRODUCER { - assert!( - queue.push((producer * PUSHES_PER_PRODUCER + index) as u64), - "the run fits in the capacity" - ); + tx.push((producer * PUSHES_PER_PRODUCER + index) as u64) + .expect("the run fits in the capacity"); } }); } @@ -632,137 +588,47 @@ fn time_isolated_claim_wide(producers: usize) -> Repetition { Instant::now() }); let elapsed = started.elapsed().as_nanos() as f64; - while queue.pop().is_some() {} - (elapsed, 0) + let refusals = tx.refused(); + while rx.pop().is_some() {} + (elapsed, refusals) } -/// The shipping 32 / 32 claim word, against a continuously draining consumer. -fn time_drained_claim_narrow(producers: usize) -> Repetition { - let queue = Arc::new(claim_layout::narrow::Queue::with_capacity(DRAINED_CAPACITY)); - let refusals = Arc::new(AtomicU64::new(0)); +/// One claim-word layout, against a continuously draining consumer. +/// +/// Generic for [`time_isolated_layout`]'s reason. +fn time_drained_layout(producers: usize) -> Repetition { + let (tx, rx) = + reserving_mpsc::bounded_as::(DRAINED_CAPACITY).expect("a valid capacity"); let done = Arc::new(AtomicBool::new(false)); - let gate = start_barrier(producers + 1); - - let consumer_queue = Arc::clone(&queue); let consumer_done = Arc::clone(&done); - let consumer_gate = Arc::clone(&gate); - let consumer = thread::spawn(move || { - consumer_gate.wait(); - while !consumer_done.load(Ordering::Relaxed) { - while consumer_queue.pop().is_some() {} - std::hint::spin_loop(); - } - while consumer_queue.pop().is_some() {} - }); - - let started = thread::scope(|scope| { - for producer in 0..producers { - let queue = Arc::clone(&queue); - let gate = Arc::clone(&gate); - let refusals = Arc::clone(&refusals); - scope.spawn(move || { - gate.wait(); - let mut refused = 0u64; - for index in 0..PUSHES_PER_PRODUCER { - let item = (producer * PUSHES_PER_PRODUCER + index) as u64; - while !queue.push(item) { - refused += 1; - std::hint::spin_loop(); - } - } - refusals.fetch_add(refused, Ordering::Relaxed); - }); - } - gate.wait(); - Instant::now() - }); - let elapsed = started.elapsed().as_nanos() as f64; - done.store(true, Ordering::Relaxed); - consumer.join().expect("the consumer did not panic"); - (elapsed, refusals.load(Ordering::Relaxed)) -} - -/// The 16 / 48 claim word, against a continuously draining consumer. -fn time_drained_claim_deep(producers: usize) -> Repetition { - let queue = Arc::new(claim_layout::deep::Queue::with_capacity(DRAINED_CAPACITY)); - let refusals = Arc::new(AtomicU64::new(0)); - let done = Arc::new(AtomicBool::new(false)); + // The consumer joins the gate for the reason its twins do: a run whose + // opening is undrained is not the regime being measured. let gate = start_barrier(producers + 1); - - let consumer_queue = Arc::clone(&queue); - let consumer_done = Arc::clone(&done); let consumer_gate = Arc::clone(&gate); - let consumer = thread::spawn(move || { - consumer_gate.wait(); - while !consumer_done.load(Ordering::Relaxed) { - while consumer_queue.pop().is_some() {} - std::hint::spin_loop(); - } - while consumer_queue.pop().is_some() {} - }); - - let started = thread::scope(|scope| { - for producer in 0..producers { - let queue = Arc::clone(&queue); - let gate = Arc::clone(&gate); - let refusals = Arc::clone(&refusals); - scope.spawn(move || { - gate.wait(); - let mut refused = 0u64; - for index in 0..PUSHES_PER_PRODUCER { - let item = (producer * PUSHES_PER_PRODUCER + index) as u64; - while !queue.push(item) { - refused += 1; - std::hint::spin_loop(); - } - } - refusals.fetch_add(refused, Ordering::Relaxed); - }); - } - gate.wait(); - Instant::now() - }); - let elapsed = started.elapsed().as_nanos() as f64; - done.store(true, Ordering::Relaxed); - consumer.join().expect("the consumer did not panic"); - (elapsed, refusals.load(Ordering::Relaxed)) -} -/// The 64 / 64 claim word, against a continuously draining consumer. -fn time_drained_claim_wide(producers: usize) -> Repetition { - let queue = Arc::new(claim_layout::wide::Queue::with_capacity(DRAINED_CAPACITY)); - let refusals = Arc::new(AtomicU64::new(0)); - let done = Arc::new(AtomicBool::new(false)); - let gate = start_barrier(producers + 1); - - let consumer_queue = Arc::clone(&queue); - let consumer_done = Arc::clone(&done); - let consumer_gate = Arc::clone(&gate); let consumer = thread::spawn(move || { consumer_gate.wait(); while !consumer_done.load(Ordering::Relaxed) { - while consumer_queue.pop().is_some() {} + while rx.pop().is_some() {} std::hint::spin_loop(); } - while consumer_queue.pop().is_some() {} + while rx.pop().is_some() {} + rx.refused() }); let started = thread::scope(|scope| { for producer in 0..producers { - let queue = Arc::clone(&queue); + let tx = tx.clone(); let gate = Arc::clone(&gate); - let refusals = Arc::clone(&refusals); scope.spawn(move || { gate.wait(); - let mut refused = 0u64; for index in 0..PUSHES_PER_PRODUCER { - let item = (producer * PUSHES_PER_PRODUCER + index) as u64; - while !queue.push(item) { - refused += 1; + let mut item = (producer * PUSHES_PER_PRODUCER + index) as u64; + while let Err(error) = tx.push(item) { + item = error.into_inner(); std::hint::spin_loop(); } } - refusals.fetch_add(refused, Ordering::Relaxed); }); } gate.wait(); @@ -770,6 +636,6 @@ fn time_drained_claim_wide(producers: usize) -> Repetition { }); let elapsed = started.elapsed().as_nanos() as f64; done.store(true, Ordering::Relaxed); - consumer.join().expect("the consumer did not panic"); - (elapsed, refusals.load(Ordering::Relaxed)) + let refusals = consumer.join().expect("the consumer must not panic"); + (elapsed, refusals) } From ea333d86252469cadffce50539bdbb2bc95b7644 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sat, 5 Sep 2026 00:24:58 -0400 Subject: [PATCH 354/361] docs(platform-probes): archive the completed claim-word-layout checklist All items complete, so the named-feature checklist moves to COMPLETED-CHECKLIST.md and its PLANS.md row moves to COMPLETED-PLANS.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...HECKLIST-claim-word-layout.md => COMPLETED-CHECKLIST.md} | 6 ++++++ crates/windows-platform-probes/COMPLETED-PLANS.md | 5 +++++ crates/windows-platform-probes/PLANS.md | 1 - 3 files changed, 11 insertions(+), 1 deletion(-) rename crates/windows-platform-probes/{CHECKLIST-claim-word-layout.md => COMPLETED-CHECKLIST.md} (98%) create mode 100644 crates/windows-platform-probes/COMPLETED-PLANS.md diff --git a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md b/crates/windows-platform-probes/COMPLETED-CHECKLIST.md similarity index 98% rename from crates/windows-platform-probes/CHECKLIST-claim-word-layout.md rename to crates/windows-platform-probes/COMPLETED-CHECKLIST.md index 59a5c765..9a975505 100644 --- a/crates/windows-platform-probes/CHECKLIST-claim-word-layout.md +++ b/crates/windows-platform-probes/COMPLETED-CHECKLIST.md @@ -1,3 +1,9 @@ +# Completed checklists: windows-platform-probes + +Append-only. Newest groups at the bottom. + +## Moved 2026-09-05 -- claim-word layout: measured the apportionment, then shipped it as a caller's choice + # Checklist: claim-word layout Measures how the `reserving_mpsc` claim word's bit apportionment and width diff --git a/crates/windows-platform-probes/COMPLETED-PLANS.md b/crates/windows-platform-probes/COMPLETED-PLANS.md new file mode 100644 index 00000000..7497bbda --- /dev/null +++ b/crates/windows-platform-probes/COMPLETED-PLANS.md @@ -0,0 +1,5 @@ +# Completed plans: windows-platform-probes + +| Path to CHECKLIST.md | Completion Date | Brief description | Design Notes | +|---|---|---|---| +| [COMPLETED-CHECKLIST.md](COMPLETED-CHECKLIST.md) | 2026-09-05 | Measured how `reserving_mpsc`'s claim word apportionment and width affect throughput, then shipped the layouts as caller-selectable options: `Balanced`, `Enduring`, `Perpetual`, and `Wide` behind the `dwcas` feature. Superseded D-36, whose premise was that fixing SH-14.1 required the claim-protocol replacement. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | diff --git a/crates/windows-platform-probes/PLANS.md b/crates/windows-platform-probes/PLANS.md index a88b1e68..6e557be8 100644 --- a/crates/windows-platform-probes/PLANS.md +++ b/crates/windows-platform-probes/PLANS.md @@ -4,5 +4,4 @@ Design decisions are in [DESIGN-NOTES.md](DESIGN-NOTES.md). | Path to CHECKLIST.md | Status | Brief description | Design Notes | |---|---|---|---| -| [CHECKLIST-claim-word-layout.md](CHECKLIST-claim-word-layout.md) | in progress | Measure how the `reserving_mpsc` claim word's bit apportionment (32/32 vs 16/48) and width (64 vs 128) affect push throughput, so the shipping layout is chosen on evidence. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | | [../../CHECKLIST-thread-ambient.md](../../CHECKLIST-thread-ambient.md) | in progress | M27: create the crate, migrate this session's probes into it under the three-tier scheme, and queue migration of the nine earlier measurements that still live only in git-ignored scratch. | [DESIGN-NOTES.md](DESIGN-NOTES.md) | From e52236aaad46dfc838a87fa38b13cdcb5e7533a4 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sat, 5 Sep 2026 00:28:46 -0400 Subject: [PATCH 355/361] docs: document the claim layouts and the dwcas feature in the crate READMEs The queues README gained a Cargo features section -- dwcas was user-facing and undocumented, and it is the only thing in the crate that costs a third-party dependency, so a reader deciding whether to enable it needs the trade stated. The status summary now says the claim word's apportionment is a caller's choice rather than describing reserving_mpsc as though it had one fixed layout. The probes README's 'what is measured' section gained the queue-contention entry, including that the probe instantiates the shipping type at each layout rather than a stand-in, and why. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-platform-probes/README.md | 17 ++++++++++++++++ crates/windows-waitable-queues/README.md | 26 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/crates/windows-platform-probes/README.md b/crates/windows-platform-probes/README.md index 3bc2d949..d3d0612d 100644 --- a/crates/windows-platform-probes/README.md +++ b/crates/windows-platform-probes/README.md @@ -60,3 +60,20 @@ once set at process scope -- irreversible, so no test performs it. its source, with the control that makes that attributable; that closing a duplicate leaves the source usable; and that single-shot metadata queries do not disturb an enumeration in progress, on the handle or on a duplicate. + +**Queue claim contention, and the claim word's layout.** How `slotwise_mpsc`, +`reserving_mpsc`, and the experimental permit claim scale as producers are +added, against an uncontended `fetch_add` floor, in two regimes: producers alone +so the compare-and-swap is the only thing happening, and producers against a +continuously draining consumer. Reports each run's refusal count, so a run that +was bounded by the consumer rather than by the claim is visible as a fact rather +than mistaken for contention. + +Also measures the four apportionments of `reserving_mpsc`'s claim word -- +32/32, 16/48, 8/56, and the 128-bit 64/64 -- which is what established that +re-apportioning the bits is free while widening the word is not. That decided +how the layouts ship. The probe instantiates the shipping type at each layout +rather than a stand-in, and the reason is recorded in +[DESIGN-NOTES.md](DESIGN-NOTES.md): an earlier version carried its own copy of +the protocol and was found to be *understating* the cost of the layout it +existed to evaluate. diff --git a/crates/windows-waitable-queues/README.md b/crates/windows-waitable-queues/README.md index 3df7744f..1d20ac4b 100644 --- a/crates/windows-waitable-queues/README.md +++ b/crates/windows-waitable-queues/README.md @@ -14,6 +14,13 @@ waited on alongside other handles. The capability traits over them -- `Producer`, `Consumer`, `Bounded`, `Waitable`, `Reserving` -- each ship with the second implementation that validated them. +`reserving_mpsc` packs its claim position beside a reservation count in one +word, and **how those bits are divided is a caller's choice**: `Balanced`, +`Enduring`, and `Perpetual` trade a reservation ceiling nobody reaches for a +claim position that lasts from about 37 seconds to about 20 years of sustained +maximum-rate pushing, at no measured cost. See +[the section on recurrence](#how-long-reserving_mpsc-runs-before-its-claim-position-recurs). + The decisions all of this was built against are in [DESIGN-NOTES.md](DESIGN-NOTES.md), and the remaining work is tracked in [CHECKLIST-io-domains.md](../../CHECKLIST-io-domains.md) at the workspace root. @@ -154,6 +161,25 @@ a *risk* of a bug, while this is a known one with a computed exposure. What has changed is that the exposure is now a number the caller sets rather than one the crate imposes. +## Cargo features + +Both are off by default, and the default build depends on `windows-sys` alone. + +**`dwcas`** adds the `Wide` claim layout, a 128-bit claim word for +`reserving_mpsc`. This is the only thing in the crate that costs a third-party +dependency: Rust's standard library has no 128-bit atomic -- `core::sync::atomic` +stops at 64 bits -- so the double-width compare-and-swap comes from +`portable-atomic`. Most callers do not need it; `Perpetual` reaches roughly +twenty years before its claim position recurs with no dependency and no measured +cost, while the 128-bit exchange measured 2-4x slower on the claim itself. Take +it when you want the recurrence gone as a guarantee rather than deferred by an +argument about deployment lifetimes. + +**`experimental-permit-claim`** adds `permit_mpsc`, a different claim protocol in +which the decision and the operation are one atomic rather than two. It is +**not** covered by this crate's semver promise: it will either be merged into +`reserving_mpsc` or deleted once it has been measured enough to decide. + ## How far the memory orderings are verified, and how far they are not Stated plainly, because a lock-free queue that is vague about this is asking to From 1f06f51e846e33a2517e92da03fe3b319cc72e21 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sat, 5 Sep 2026 14:18:54 -0400 Subject: [PATCH 356/361] fix(placement-probe): restore four blank separators dropped in the merge The merge resolution of origin/main's add/add conflicts consumed the blank line separating four items, so the branch differed from main by nothing but whitespace rustfmt does not restore -- it neither adds nor removes blank lines between items. Verified by diffing the working tree against origin/main: the crate is now byte-identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- crates/windows-placement-probe/src/record/tests.rs | 3 +++ crates/windows-placement-probe/src/report/tests.rs | 1 + 2 files changed, 4 insertions(+) diff --git a/crates/windows-placement-probe/src/record/tests.rs b/crates/windows-placement-probe/src/record/tests.rs index 38c599f9..60d3c659 100644 --- a/crates/windows-placement-probe/src/record/tests.rs +++ b/crates/windows-placement-probe/src/record/tests.rs @@ -197,6 +197,7 @@ fn populated_paths(value: &serde_json::Value) -> BTreeSet { walk(value, "", &mut paths); paths } + #[test] #[cfg(feature = "serde")] fn the_records_shape_matches_the_archived_schema_for_its_version() { @@ -373,6 +374,7 @@ fn days_from_civil(year: i64, month: u32, dom: u32) -> i64 { let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; era * 146_097 + day_of_era - 719_468 } + #[test] fn the_civil_conversion_round_trips_across_a_long_span() { // A property rather than a fixture: every day for eighty years must convert @@ -397,6 +399,7 @@ fn the_civil_conversion_round_trips_across_a_long_span() { "day {day} converted to {year}-{month:02}-{dom:02}, which is a \ different day" ); + if let Some(prev) = previous { assert_ne!(prev, (year, month, dom), "day {day} repeated a date"); } diff --git a/crates/windows-placement-probe/src/report/tests.rs b/crates/windows-placement-probe/src/report/tests.rs index 3286640e..8b35a575 100644 --- a/crates/windows-placement-probe/src/report/tests.rs +++ b/crates/windows-placement-probe/src/report/tests.rs @@ -659,6 +659,7 @@ fn the_disagreement_points_at_the_repository_rather_than_the_results_thread() { "both routes are offered so the reader picks one: {text}" ); } + #[test] fn the_ordering_caveat_is_separated_from_whatever_precedes_it() { // Not about prose, per this module's header, but about structure: the From 611cfcd41d26493b2fd9dd769c3d03d7f9bf716f Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Sun, 6 Sep 2026 11:11:30 -0400 Subject: [PATCH 357/361] docs: remove the 0.2.1 release pin, which events overtook D-46 pinned `windows-ioring-sys`' next release to 0.2.1, on the ground that two `topology`-scoped breaking commits had edited this crate's paths and release-please attributes by path rather than by scope. The reasoning was sound; the pin never happened. The `Release-As: 0.2.1` footer sat on `cdce13b`, which stayed on this branch. The release ran from `main` without it, so tag `windows-ioring-sys-v0.3.0` exists and the crate's CHANGELOG carries exactly the entry the pin was written to avoid -- a BREAKING CHANGES heading citing `**topology:** reshape the topology model around observed domains`. So the pin is removed rather than left standing as an intention: a `Release-As: 0.2.1` carried forward now would ask release-please to regress a crate already at 0.3.0, and a decision note that says a release is pinned when it has already shipped at a different version is worse than no note. Swept for every statement of it before removing, per the blast-radius rule. Four sites, all updated together: - D-46's index row and its section in the crate's DESIGN-NOTES, deleted. - SH-3.4.2's decision paragraph, which now records the outcome instead of the intention. - The bump table's ioring row, now 0.3.0 shipped rather than 0.2.1 pinned. - SH-3.4.2's "cheap correct fix" paragraph, which still read as a live proposal and now says the option was not taken. What survives is the general lesson, and it is now enforced rather than remembered: tools/check-commit-scope.ps1 is on `main` and flags a release-triggering commit spanning more than one released crate, which is the mechanism that produced this bump. The reasoning about path attribution, the three-commit deprecation dance for a cross-crate rename, and the measured cost of a blanket one-crate-per-commit rule is untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST-ship-topology-and-queues.md | 40 ++++++++-------- crates/windows-ioring-sys/DESIGN-NOTES.md | 56 ----------------------- 2 files changed, 22 insertions(+), 74 deletions(-) diff --git a/CHECKLIST-ship-topology-and-queues.md b/CHECKLIST-ship-topology-and-queues.md index 42678478..fce590b7 100644 --- a/CHECKLIST-ship-topology-and-queues.md +++ b/CHECKLIST-ship-topology-and-queues.md @@ -381,7 +381,7 @@ that previously stood in the way are gone: |---|---|---|---| | `windows-topology-sys` | 0.1.0 | **0.2.0** | 9 breaking | | `windows-waitable-queues` | 0.0.1 | **0.1.0** | 6 breaking, from a corrected starting point -- SH-3.4.1 | - | `windows-ioring-sys` | 0.2.0 | **0.2.1** | pinned by `Release-As` -- SH-3.4.2, [D-46](crates/windows-ioring-sys/DESIGN-NOTES.md#d-46) | + | `windows-ioring-sys` | 0.2.0 | **0.3.0** (shipped 2026-09-05) | 2 breaking by path attribution -- SH-3.4.2 | | `windows-file-watcher` | 0.1.3 | **0.2.0** | 1 breaking, the reopen-by-id removal | | `windows-thread-ambient-sys` | 0.2.0 | **0.2.1** | 3 `fix:` commits scoped to other crates that changed its `src/` | | `windows-file-watcher-example-test-harness` | 0.1.2 | **0.1.3** | not its own commits -- the `cargo-workspace` plugin, below | @@ -426,19 +426,21 @@ that previously stood in the way are gone: Not a defect -- a naming decision that is cheap now and permanent afterwards. - [x] **SH-3.4.2** -- **Decide what to do about `windows-ioring-sys`' unearned breaking bump.** - **DECIDED 2026-09-03: ioring ships as 0.2.1.** Pinned by a `Release-As: 0.2.1` footer on `cdce13b`, - a commit touching only `crates/windows-ioring-sys/`, which release-please applies per package by - path. Verified against its documentation that the footer works on any commit type -- including - `docs:` -- and overrides a breaking bump. Rationale recorded as - [D-46](crates/windows-ioring-sys/DESIGN-NOTES.md#d-46) in the crate that owns the consequence. - **The pin creates an obligation, and it is enforced rather than remembered**: a forced version - asserts the surface is compatible, so **no breaking change may enter `windows-ioring-sys` before - 0.2.1 ships**. If one becomes necessary the pin is removed and the crate takes its bump -- the - break is never absorbed under a version that says there is not one, which would ship a - compatible-looking version over an incompatible surface and is strictly worse than the overstated - 0.3.0 this avoids. [tools/check-commit-scope.ps1](tools/check-commit-scope.ps1) now fails when a - breaking commit lands in a crate pinned earlier in the range; sabotage-verified by injecting a - `feat(ioring)!` after the pin and confirming it named both commits. + **OUTCOME: the pin was never applied, and 0.3.0 shipped on 2026-09-05.** The decision recorded here + on 2026-09-03 was to pin ioring to 0.2.1 with a `Release-As: 0.2.1` footer on `cdce13b`. That commit + stayed on this branch; the release ran from `main` without it, so tag `windows-ioring-sys-v0.3.0` + exists and the crate's CHANGELOG carries the very entry the pin was written to avoid -- a + BREAKING CHANGES heading citing `**topology:** reshape the topology model around observed domains`. + The pin has been removed from both branches rather than left stating an intention that events have + overtaken: a `Release-As: 0.2.1` carried forward now would ask release-please to regress a crate + already at 0.3.0. + + **What survives is the general lesson, and it is now enforced rather than remembered.** + [tools/check-commit-scope.ps1](tools/check-commit-scope.ps1) is on `main` and flags a + release-triggering commit that spans more than one released crate, which is the mechanism that + produced this bump. The reasoning below about path attribution, the three-commit deprecation dance + for a cross-crate rename, and the measured cost of a blanket one-crate-per-commit rule is unchanged + and still applies; only the pin is gone. Release-please attributes a commit by the **paths it touches**, not by its Conventional Commits scope. Two `feat(topology)!` commits (`b9e0c35`, `36e397d`) touched `crates/windows-ioring-sys/`, so it @@ -469,10 +471,12 @@ that previously stood in the way are gone: `git rebase -i` is forbidden by this repository's own terminal rules; and retrofitting the alias step would mean fabricating a deprecation that never happened, for one changelog line. - **The cheap correct fix, if the bump is worth correcting:** a `Release-As: 0.2.1` footer on a commit - that touches **only** `crates/windows-ioring-sys/`. Verified against release-please's manifest-mode - documentation that the footer is applied **per package, by the paths the commit touches**, so it - pins ioring without disturbing the other five bumps. Decide between that and simply accepting 0.3.0. + **The option that was considered and NOT taken:** a `Release-As: 0.2.1` footer on a commit touching + **only** `crates/windows-ioring-sys/`. Verified against release-please's manifest-mode documentation + that the footer is applied **per package, by the paths the commit touches**, so it would have pinned + ioring without disturbing the other five bumps. Recorded because the mechanism is worth knowing -- + but see the outcome at the head of this item: the footer never reached `main`, 0.3.0 shipped, and + the pin has since been removed rather than carried forward against a released version. **Measured, because the rule had to be affordable before it could be recommended.** Nine release-triggering commits on this branch span more than one *released* crate -- and only two of diff --git a/crates/windows-ioring-sys/DESIGN-NOTES.md b/crates/windows-ioring-sys/DESIGN-NOTES.md index 01f50680..d12038e1 100644 --- a/crates/windows-ioring-sys/DESIGN-NOTES.md +++ b/crates/windows-ioring-sys/DESIGN-NOTES.md @@ -68,7 +68,6 @@ runs a continuation), this crate exposes the mechanism and documents the trade-o | D-43 | **Fixed in M18.6: `EventDelivery` hands out a `RingScope` -- every read-only part of `IoRing` plus batch construction, and no `&mut IoRing` -- because any `&mut IoRing` permits whole-value assignment, which let safe code replace the ring and silently stop delivery.** The defect, for the record: `EventDelivery::ring` returned `&Mutex`. Found by [M18.1's borrow-surface audit](#borrow-surface-audit-m181) and **measured, not argued**: `*delivery.ring().lock().unwrap() = IoRing::new(64, 64)?` compiles, and a probe recorded one completion delivered before the swap and **none** after it, despite four further operations being submitted and completed on the replacement. The mechanism is that the pool's wait holds its own duplicate of the *original* ring's completion event ([D-20](#d-20)); replacing the ring drops that ring and attaches nothing to the new one, so the armed wait can never be signalled again. **This is [D-35](#d-35)'s shape at a different layer** -- there, `&mut Vec` permitted `reserve` and reassignment where only byte writes were intended, and the fix was to narrow the returned type to `&mut [u8]`. Here the returned type permits replacing the whole ring where only submitting work was intended. Note the trap in the obvious fixes: a `Deref`/`DerefMut` newtype does **not** close it, because `*guard = ...` works through `DerefMut` just as well, and neither does a `with_ring(\|ring: &mut IoRing\| ...)` closure, for the same reason. Closing it meant never letting a `&mut IoRing` escape -- `RingScope` hands out a `Batch` instead -- which changed all nine call sites including the `epoch_log` example. The refusal is now enforced by a `compile_fail` doctest, itself verified by adding a `DerefMut` impl and watching that doctest fail, which is also the empirical proof of the claim above that a `Deref` newtype would not have closed the hole. **Severity was silent correctness, not unsoundness.** No use-after-free is reachable: the old ring runs down normally, the wait's duplicate handle stays valid, and completions on the replacement are still claimable. Delivery simply stops, which is the failure mode hardest to notice. The existing rustdoc warns against calling `completion_event` on the shared ring but says nothing about replacing it. | | D-44 | **A spike against the real kernel is a budgeted, first-class technique for every new Win32 surface this crate wraps -- not something that happens after a test fails mysteriously.** The full argument is in [Testing strategy](#testing-strategy-m185); the decision is that the budget is allocated *before* the wrapper is written. Two of the eight defects behind M15-M18 exist because a Win32 contract was assumed rather than measured: the completion event is edge-triggered ([D-19](#d-19)) and `BuildIoRingRegisterBuffers` reads its array when the operation *runs* ([D-32](#d-32)). No oracle, generator, allocator or mutation run supplies that knowledge, because each of them checks code against **our** stated contract -- and in both cases our stated contract was the thing that was wrong. What they detect is a *consequence*, and only on a path some test already walks: the guard allocator does turn D-32 into a hard `STATUS_ACCESS_VIOLATION`, measured in M17.4's calibration, but that is the crash after the mistake, not the knowledge that would have prevented it. A spike is also the only technique here that can be run *before* there is code to test. Two obligations follow, both learned the hard way and recorded in [design-sessions/spikes/README.md](design-sessions/spikes/README.md): a spike must carry a **control case**, because the first two drain-ordering spikes could not discriminate and would have returned confidently wrong answers; and it must be **kept**, as a standalone single-file program depending only on `windows-sys`, so that what it measures stays the operating system's behaviour rather than ours. | | D-45 | **A borrow-returning method must be audited on two questions, not one: what the returned value *permits*, and how long the *borrow* lasts. `RegisteredBuffers::get` therefore takes `&mut self`.** [M18.1's audit](#borrow-surface-audit-m181) asked only the first, of all nineteen items, and the second is where [D-36](#d-36)'s fix was still open: `get` checked `kernel_writes` at the instant of the call but returned a slice living as long as the borrow, and `Batch::read_registered` takes the registration by **shared** reference -- so safe code could take the borrow while the buffer was quiet, then submit a read into that same buffer and keep reading. Measured before being believed: a probe watched the bytes change from `0x11` to `0xEE` through the live slice while a fresh `get(0)` at that same instant correctly refused with `WouldBlock`. The guard worked; the borrow outlived it. **`&mut self` costs nothing real**, because no caller needs to read a buffer during the window it is refused -- while a read is in flight the bytes are indeterminate and only become meaningful once the completion is observed, so earlier or later is always available. That is not merely an argument: all ~40 read sites in this crate's tests, examples and the epoch-log sample already read at a quiescent point, and converting them needed nothing but `mut` on a local. The concession D-36 deliberately kept (reading a buffer whose own *write* is in flight, where the kernel only reads) is given up with it, and is likewise unused. The arena pattern survives, because a [`Token`] holds a [`RegisteredUse`] rather than a borrow of the registration, so quiet neighbours stay readable while operations are outstanding. Enforced by a `compile_fail` doctest, itself verified by reverting the signature and watching it fail, and paired with a `no_run` doctest asserting the neighbour case still compiles so the guard cannot become over-constraining unnoticed. `get_mut` never had the defect: `&mut self` already conflicted with the shared borrow. | -| D-46 | **This crate's next release is pinned to `0.2.1`, because the two breaking commits attributed to it broke nothing here.** release-please attributes a commit to a crate by the **paths it touches**, not by the `(scope)` in its subject. Two `topology`-scoped breaking commits edited this crate -- `b9e0c35` touched only `examples/ring_copy/`, and `36e397d` touched the example plus **one doc-comment heading** in `src/lib.rs` (`# Topology guidance` -> `# MachineMemoryTopology guidance`). Across the whole branch **no public item signature in this crate changed**, so a 0.3.0 announcing breaking changes would send consumers looking for a migration that does not exist. The pin is a `Release-As: 0.2.1` footer, which release-please applies per package by path. **The pin is a promise, and it constrains what may land here before the release**: it is only honest while this crate's public surface stays compatible, so no breaking change may enter `windows-ioring-sys` until 0.2.1 ships. If one becomes necessary, the pin is removed rather than the break being quietly absorbed -- changing our mind about a break *after* pinning is exactly the silent understatement the pin exists to prevent. | ## Durability on the ring @@ -826,58 +825,3 @@ absent. A model belongs here as an **oracle over observed sequences** assuming ([D-37](#d-37)): it works, and needs no SDK, but it is keyed by *image file name* and cargo rehashes test binaries on every meaningful rebuild -- so it would degrade silently to instrumenting nothing. - -## D-46: the next release is pinned to 0.2.1, and what that pin obliges - -release-please decides which crate a commit belongs to by the **paths it touches**, not by the -`(scope)` in its subject line. Two commits scoped to `topology` and marked breaking edited files -under `crates/windows-ioring-sys/`, so release-please counts two breaking changes *for this crate* -and would propose **0.3.0**. - -What those commits actually did here: - -| Commit | Changed in this crate | -|---|---| -| `b9e0c35` `feat(topology)!: remove Domain::id ...` | `examples/ring_copy/plan.rs`, `policy.rs` | -| `36e397d` `refactor(topology)!: rename Topology ...` | three example files, and **one line** of `src/lib.rs` | - -That one line is a doc-comment heading: - -``` --//! # Topology guidance -+//! # MachineMemoryTopology guidance -``` - -They touched this crate because the `ring_copy` example *consumes* `windows-topology-sys`; renaming -`Topology` and removing `Domain::id` forced the example to follow. The break is real, and it belongs -to `windows-topology-sys`, which takes its own 0.2.0 for it. Across the entire branch **no public -item signature in this crate changed** -- the only other `src/` edits are comments and one test-only -helper. - -So a 0.3.0 here would announce breaking changes under a heading consumers are trained to act on, and -send them looking for a migration that does not exist. The release is pinned instead: - -``` -Release-As: 0.2.1 -``` - -applied on a commit touching only this crate's paths, which release-please evaluates per package by -path. - -### The pin is a promise, and it constrains what may land here - -A forced version is only honest while the claim behind it holds. `0.2.1` asserts that this crate's -public surface is compatible with `0.2.0`, so **no breaking change may enter `windows-ioring-sys` -between this pin and the release of 0.2.1**. - -If a break becomes necessary before then, the answer is to **remove the pin** and let the crate take -its minor bump -- never to let the break land underneath a version that says there isn't one. -Changing our mind about a break *after* pinning, and absorbing it quietly, is precisely the silent -understatement the pin was written to prevent: it would ship a compatible-looking version over an -incompatible surface, which is worse than the overstated 0.3.0 this decision set out to avoid. - -The general defect this is one instance of -- a release-triggering commit that incidentally edits a -second released crate's files -- is guarded going forward by -[tools/check-commit-scope.ps1](../../tools/check-commit-scope.ps1), wired into the pre-commit gate. -It has bitten this crate before: the `**guard-alloc:**` entries in [CHANGELOG.md](CHANGELOG.md) are -there because those commits touched `tests/registration.rs`. From fa37bce6a0d070cf703c3a54c0ce51dcf829f817 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 7 Sep 2026 20:57:24 -0400 Subject: [PATCH 358/361] fix(tools): guard run-numa-spikes against PowerShell 5.1's terminating stderr Under Windows PowerShell 5.1 a native command that writes to stderr while `$ErrorActionPreference` is `Stop` raises a TERMINATING error when its stderr is redirected with `2>&1`. PowerShell 7 does not, which is why this survived review. `run-numa-spikes.ps1` had two such captures and no guard. The shape of the failure is what makes it dangerous. `cargo --quiet` writes nothing to stderr on a clean build, so under 5.1 this script ran to completion for as long as every spike was healthy. It threw only when cargo did write there -- a warning, or a failed compile -- which is exactly the case the script exists to report. The throw landed before `$buildExit` was assigned, so the broken-instrument branch never ran: no transcript, no summary, and the one artifact somebody downloads to diagnose a rotted spike was the one case that never produced it. Measured rather than argued, in both directions. Against a deliberately uncompilable crate on 5.1: unguarded, it threw `RemoteException` and captured nothing; guarded, it returned exit 101 with all seven diagnostic lines. On 7 both forms are fine. The healthy path was checked too -- removing the guard and re-running still passed, which is why the earlier reading that this script was simply broken under 5.1 was wrong. Both captures now go through an `Invoke-Native` helper matching the ones in `soak-flush-barrier.ps1` and `test-run-sabotage.ps1` line for line, and its `ConvertTo-OutputLines` also flattens the ErrorRecords `2>&1` produces. That removes the stray `System.Management.Automation.RemoteException` the old comment described working around, so the comment now points at the helper instead of at a workaround that is gone. `run-mutants.ps1` was inspected for the same defect and needs no change: it redirects nothing, and a bare native call raises no error record on 5.1 regardless of the preference. Confirmed by experiment on both hosts, and it parses clean under 5.1. Sweeping the class found six more captures of the same shape -- one `git check-ignore` in `run-sabotage.ps1` and five `git init` / `git add -A` calls in `test-run-sabotage.ps1`. They are latent rather than firing: the sabotage suite passes under 5.1 today because git stays silent on those operations. Queued as M34.4 rather than fixed here, because the fix would be a third and fourth copy of one guard and `tools/` has no module convention to share it -- a decision worth making before duplicating further. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHECKLIST.md | 22 ++++++++++++ tools/run-numa-spikes.ps1 | 76 ++++++++++++++++++++++++++++++++++----- 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index 369eaea7..b0aa4194 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -181,6 +181,28 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. discussion thread, so "can this be captured and asserted end to end?" has real value there rather than being architectural tidiness. +- [ ] **M34.4** -- **Guard the remaining native-command captures in the sabotage scripts against + Windows PowerShell 5.1's terminating-stderr rule.** Under 5.1, a native command that writes to + stderr while `$ErrorActionPreference` is `Stop` raises a **terminating** error whenever its stderr + is redirected with `2>&1`; PowerShell 7 does not, which is why this survives review. Measured both + ways against a deliberately uncompilable crate: unguarded, 5.1 threw and captured nothing; with the + preference flipped to `Continue` around the call, it returned exit 101 with all seven diagnostic + lines. + **Fixed already, so this item is only the remainder:** [soak-flush-barrier.ps1](tools/soak-flush-barrier.ps1), + [test-run-sabotage.ps1](tools/test-run-sabotage.ps1) (its harness invocation only) and + [run-numa-spikes.ps1](tools/run-numa-spikes.ps1) each carry the guard. + [run-mutants.ps1](tools/run-mutants.ps1) needs none -- it redirects nothing. + **What remains is six `git ... 2>&1 | Out-Null` captures**: one in + [run-sabotage.ps1](tools/run-sabotage.ps1) (`check-ignore`) and five in + [test-run-sabotage.ps1](tools/test-run-sabotage.ps1) (`init`, `add -A`). They are **latent, not + firing** -- the sabotage suite passes under 5.1 today, because git stays silent on those operations + in a clean temp repository. They would fire the first time git warned there, and `| Out-Null` means + the thrown record would carry no diagnostic at all. + Queued rather than fixed in place because the fix is a third and fourth copy of the same guard, and + `tools/` has no module or dot-sourcing convention to share one. **Decide that first**: either adopt + a shared tools module and route all the copies through it, or accept the duplication deliberately + and apply it to the remaining six. + ## M35 -- Measure what the long-path opt-in actually does - [x] **M35.1** -- **Measure whether the long-path opt-in lifts `MAX_PATH` for a *relative* path, and diff --git a/tools/run-numa-spikes.ps1 b/tools/run-numa-spikes.ps1 index e94faf9d..0bb0d62a 100644 --- a/tools/run-numa-spikes.ps1 +++ b/tools/run-numa-spikes.ps1 @@ -68,6 +68,64 @@ function Write-Report { } } +# Render a captured record as plain text. `2>&1` wraps a native command's stderr +# in ErrorRecords, and one of those stringifies to the literal text +# `System.Management.Automation.RemoteException` in the middle of a compiler +# diagnostic, so the transcript of a broken instrument was less legible than the +# console log of the same failure. +function ConvertTo-OutputLines { + param([Parameter(ValueFromPipeline = $true)] $Record) + process { + if ($Record -is [System.Management.Automation.ErrorRecord]) { + $Record.Exception.Message + } + else { + "$Record" + } + } +} + +# Run a native command, capturing merged stdout+stderr as plain strings. +# +# The ErrorActionPreference dance is what makes this work on Windows PowerShell +# 5.1. There, a native command writing to stderr under `Stop` raises a +# TERMINATING error. Restoring the preference afterwards keeps `Stop` for +# everything that is not a native call. +# +# Measured, because the shape of the failure is what makes it dangerous rather +# than merely annoying. `cargo --quiet` writes nothing to stderr on a clean +# build, so under 5.1 this script ran to completion for as long as every spike +# was healthy. It threw only when cargo did write there -- a warning, or a +# failed compile -- which is exactly the case this script exists to report. The +# throw landed before `$buildExit` was assigned, so the broken-instrument +# branch never ran: no transcript, no summary, and the one artifact somebody +# downloads to diagnose a rotted spike was the one case that never produced it. +# Confirmed both ways against a deliberately uncompilable crate: unguarded, 5.1 +# threw and captured nothing; guarded, it returned exit 101 with all seven lines +# of diagnostic. Under 7 both forms are fine, which is why this survived. +# +# `$LASTEXITCODE` is global, so a caller still reads the native command's exit +# code after this returns -- which is the whole point here, since this script +# distinguishes a broken instrument from a finding by exactly that code. +# +# This is the third copy of this guard in `tools/`; `soak-flush-barrier.ps1` and +# `test-run-sabotage.ps1` carry the same one. Kept as a copy rather than shared +# because `tools/` has no module or dot-sourcing convention, and inventing one +# here would change how every script in the directory is invoked. Written down +# so the duplication is a recorded choice rather than something a later reader +# has to rediscover -- three copies of one rule can drift. +function Invoke-Native { + param([Parameter(Mandatory = $true)][scriptblock] $Command) + $previous = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + & $Command 2>&1 | ConvertTo-OutputLines + } + finally { + $ErrorActionPreference = $previous + } +} + $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') $spikeDir = Join-Path $repoRoot 'crates\windows-ioring-sys\design-sessions\spikes' @@ -138,20 +196,20 @@ windows-sys = { version = "0.61.2", default-features = false, features = [$featu Write-Report "=== building $($spike.Name) ===" Push-Location $work try { - $build = & cargo build --quiet 2>&1 + $build = Invoke-Native { cargo build --quiet } $buildExit = $LASTEXITCODE $buildOutput = ($build | Out-String) if ($buildExit -ne 0) { # A build failure is a defect in the instrument, and is one of the # two things here worth failing over. Write-Report "spike $($spike.Name) failed to build" -Level error - # Echo the Out-String rendering rather than the raw objects. `2>&1` - # turns cargo's stderr into ErrorRecords, and one of those - # stringifies to the literal text `System.Management.Automation. - # RemoteException` in the middle of the compiler diagnostic. Piping - # the already-rendered text keeps the log and the transcript - # identical, instead of the artifact being the more legible of the - # two records of the same failure. + # Echo the same text the transcript gets, so the log and the + # artifact are two renderings of one capture rather than two + # records of one failure that a reader has to reconcile. + # `Invoke-Native` has already flattened the ErrorRecords `2>&1` + # produces into plain strings, so neither carries the stray + # `System.Management.Automation.RemoteException` this used to + # splice into the middle of a compiler diagnostic. $buildOutput.TrimEnd() -split "`n" | ForEach-Object { Write-Report $_.TrimEnd() } $instrumentFailures++ $sections.Add("### $($spike.Name)`n`n**FAILED TO BUILD** -- the instrument is broken, not the machine.`n") @@ -159,7 +217,7 @@ windows-sys = { version = "0.61.2", default-features = false, features = [$featu else { $built = $true Write-Report "=== running $($spike.Name) ===" - $output = & cargo run --quiet 2>&1 | Out-String + $output = Invoke-Native { cargo run --quiet } | Out-String $runExit = $LASTEXITCODE Write-Report $output } From 8e2c47567ca11009f2e7acc34f5ee6c406ad4fd6 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 7 Sep 2026 21:17:49 -0400 Subject: [PATCH 359/361] feat(tools): share the native-command guard as dot-sourced tools/common.ps1 M34.4 queued this as a decision rather than a fix: the remaining sites needed a third and fourth copy of one guard, and `tools/` had no sharing convention. Adopting one, and finding out which one, is the substance of this change. The guard is against Windows PowerShell 5.1's rule that a native command writing to stderr under `$ErrorActionPreference = 'Stop'` raises a TERMINATING error when its stderr is redirected with `2>&1`. PowerShell 7 does not, which is why this class survives review and CI. The obvious answer -- a .psm1 -- is wrong, and wrong invisibly. A scriptblock carries the session state it was created in, so `Invoke-Native { cargo build }` runs in the CALLER's scope while a module copy flips the preference in the MODULE's scope; the flip never reaches the call. Measured with the identical body in a module: under 5.1 seven of eight cases failed while all eight passed under PowerShell 7. Dot-sourcing puts the function in the caller's own scope, where the plain assignment does reach the call. A module can be made to work through `$PSCmdlet.SessionState.PSVariable.Set`, and that was measured working on both hosts. Rejected: the guard would then rest on a subtlety that looks removable, and simplifying it back reintroduces a defect that still passes on PowerShell 7 and in CI. Dot-sourcing makes the property hold by construction rather than by counter-measure. - tools/common.ps1 -- `Invoke-Native` and `ConvertTo-OutputLines`, with the argument and its measurement recorded at the definition site. - tools/test-common.ps1 -- eight cases: capture, stream merging, exit-code survival, diagnostic text, record flattening, preference restoration (including when the command throws), and that `Stop` stays armed for non-native errors. It runs in the invoking host, then RE-INVOKES ITSELF in the other one, and treats a missing host as a failure rather than a skip -- a single-host pass is not the claim the file exists to make. - Every capture site routed: `run-numa-spikes.ps1` and `soak-flush-barrier.ps1` lose their local copies; `run-sabotage.ps1` (check-ignore) and `test-run-sabotage.ps1` (init, four `add -A`, and its child-process harness invocation) now go through the shared guard. `run-mutants.ps1` needs none -- it redirects nothing, confirmed by experiment on both hosts. - CI runs both shells. The sabotage job ran `shell: pwsh` only, which is precisely why the defect was invisible; it now runs test-common.ps1 plus test-run-sabotage.ps1 under both `pwsh` and `powershell`. Verified by sabotage: delivering the identical guard as a module turns the new suite red on 5.1 (7 of 8) while staying green on 7, so the suite detects the regression it was written for. Both consumer scripts and the full sabotage suite pass on both hosts; encoding and workflow-reference checks pass. Deliberately not shared: `Write-Report`. Six scripts define one and they are not duplicates -- they differ in level vocabulary and in rendering, with two emitting GitHub Actions annotations and four emitting console colours. Merging them would change six tools' output to remove a duplication that is only apparent. Recorded in DESIGN-NOTES so it is a decision, not an oversight. Completed item: M34.4: Guard the remaining native-command captures against Windows PowerShell 5.1's terminating-stderr rule, by deciding and adopting a sharing convention for tools/. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 16 ++++ CHECKLIST.md | 24 +---- COMPLETED-CHECKLIST.md | 53 ++++++++++- DESIGN-NOTES.md | 47 +++++++++ tools/common.ps1 | 92 ++++++++++++++++++ tools/run-numa-spikes.ps1 | 71 +++----------- tools/run-sabotage.ps1 | 9 +- tools/soak-flush-barrier.ps1 | 55 +++-------- tools/test-common.ps1 | 179 +++++++++++++++++++++++++++++++++++ tools/test-run-sabotage.ps1 | 43 +++++---- 10 files changed, 451 insertions(+), 138 deletions(-) create mode 100644 tools/common.ps1 create mode 100644 tools/test-common.ps1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6e1c85b..b9eda215 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,9 +86,25 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v7 + # The shared native-command guard, before the suites that depend on it: a + # failure here explains a failure below rather than being diagnosed twice. + # This one step covers both hosts on its own -- it re-invokes itself under + # the other shell and fails if it cannot find one. + - name: Run test-common.ps1 + shell: pwsh + run: ./tools/test-common.ps1 - name: Run test-run-sabotage.ps1 shell: pwsh run: ./tools/test-run-sabotage.ps1 + # And again under Windows PowerShell 5.1, which is not a duplicate of the + # step above. The harness and its suite both capture native output, and the + # rule that makes that capture terminate under `Stop` exists ONLY on 5.1 -- + # so a defect in that path is invisible to a pwsh-only job, which is + # exactly how one reached `main` and survived review. `powershell` is the + # Windows PowerShell 5.1 that ships on every windows runner. + - name: Run test-run-sabotage.ps1 (Windows PowerShell 5.1) + shell: powershell + run: ./tools/test-run-sabotage.ps1 # The edition, MSRV, and pinned channel are declared once in Cargo.toml and # rust-toolchain.toml, then restated a dozen times -- in this file's `msrv` diff --git a/CHECKLIST.md b/CHECKLIST.md index b0aa4194..c68326b8 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -181,27 +181,9 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. discussion thread, so "can this be captured and asserted end to end?" has real value there rather than being architectural tidiness. -- [ ] **M34.4** -- **Guard the remaining native-command captures in the sabotage scripts against - Windows PowerShell 5.1's terminating-stderr rule.** Under 5.1, a native command that writes to - stderr while `$ErrorActionPreference` is `Stop` raises a **terminating** error whenever its stderr - is redirected with `2>&1`; PowerShell 7 does not, which is why this survives review. Measured both - ways against a deliberately uncompilable crate: unguarded, 5.1 threw and captured nothing; with the - preference flipped to `Continue` around the call, it returned exit 101 with all seven diagnostic - lines. - **Fixed already, so this item is only the remainder:** [soak-flush-barrier.ps1](tools/soak-flush-barrier.ps1), - [test-run-sabotage.ps1](tools/test-run-sabotage.ps1) (its harness invocation only) and - [run-numa-spikes.ps1](tools/run-numa-spikes.ps1) each carry the guard. - [run-mutants.ps1](tools/run-mutants.ps1) needs none -- it redirects nothing. - **What remains is six `git ... 2>&1 | Out-Null` captures**: one in - [run-sabotage.ps1](tools/run-sabotage.ps1) (`check-ignore`) and five in - [test-run-sabotage.ps1](tools/test-run-sabotage.ps1) (`init`, `add -A`). They are **latent, not - firing** -- the sabotage suite passes under 5.1 today, because git stays silent on those operations - in a clean temp repository. They would fire the first time git warned there, and `| Out-Null` means - the thrown record would carry no diagnostic at all. - Queued rather than fixed in place because the fix is a third and fourth copy of the same guard, and - `tools/` has no module or dot-sourcing convention to share one. **Decide that first**: either adopt - a shared tools module and route all the copies through it, or accept the duplication deliberately - and apply it to the remaining six. +- [x] **M34.4** -- Share the native-command guard through a dot-sourced `tools/common.ps1`, route + every capture site through it, and prove it on both PowerShell hosts. + -> [completed 2026-09-07](COMPLETED-CHECKLIST.md#m344) ## M35 -- Measure what the long-path opt-in actually does diff --git a/COMPLETED-CHECKLIST.md b/COMPLETED-CHECKLIST.md index cc971610..676211f4 100644 --- a/COMPLETED-CHECKLIST.md +++ b/COMPLETED-CHECKLIST.md @@ -2184,4 +2184,55 @@ mutation wrapper. The conversion's three silent fallbacks are replaced by one ru the probe's real output, which is what makes it a refactor rather than a rewrite. Queued rather than left as a note precisely because a half-adopted abstraction is the state most likely to be forgotten -- the next probe author will see twelve neighbours printing directly and - reasonably conclude that is the house style. \ No newline at end of file + reasonably conclude that is the house style. +## Moved 2026-09-07 -- M34.4: the native-command guard became shared support, proven on both hosts + +### M34.4 -- Share the native-command guard through a dot-sourced `tools/common.ps1`, route every capture site through it, and prove it on both PowerShell hosts. *(completed 2026-09-07 21:16:31 -04:00)* + +Queued as a decision rather than a fix: the remaining sites needed a third and fourth copy of +one guard, and `tools/` had no sharing convention. The decision was to adopt one, and finding +out *which* one is the substance of this item. + +**Under Windows PowerShell 5.1, a native command that writes to stderr while +`$ErrorActionPreference` is `Stop` raises a terminating error when its stderr is redirected +with `2>&1`.** PowerShell 7 does not, which is why this class survives review and CI. + +**The obvious answer -- a `.psm1` -- is wrong, and wrong invisibly.** A scriptblock carries +the session state it was created in, so `Invoke-Native { cargo build }` runs in the caller's +scope while a module copy flips the preference in the module's scope; the flip never reaches +the call. Measured with the identical body in a module: **under 5.1 seven of eight cases +failed, while all eight passed under PowerShell 7.** Dot-sourcing puts the function in the +caller's own scope, where the plain assignment does reach the call. A module *can* be made to +work via `$PSCmdlet.SessionState.PSVariable.Set(...)` -- measured working on both hosts -- and +was rejected: the guard would rest on a subtlety that looks removable, and simplifying it back +reintroduces a defect that still passes on PowerShell 7. + +Delivered: + +- **[tools/common.ps1](tools/common.ps1)** -- `Invoke-Native` and `ConvertTo-OutputLines`, with + the "why not a module" argument and its measurement at the definition site. +- **[tools/test-common.ps1](tools/test-common.ps1)** -- eight cases covering capture, stream + merging, exit-code survival, diagnostic text, record flattening, preference restoration + (including when the command throws), and that `Stop` stays armed for non-native errors. It + runs its cases in the invoking host, then **re-invokes itself in the other one**, and treats + a missing host as a FAILURE rather than a skip -- a single-host pass is not the claim the + file exists to make. +- **Every capture site routed**: `run-numa-spikes.ps1` and `soak-flush-barrier.ps1` lost their + local copies; `run-sabotage.ps1` (`check-ignore`) and `test-run-sabotage.ps1` (`init`, + four `add -A`, and its child-process harness invocation) now go through the shared guard. + `run-mutants.ps1` needs none -- it redirects nothing, confirmed by experiment on both hosts. +- **CI runs both shells.** The `sabotage harness tests` job ran `shell: pwsh` only, which is + precisely why the defect was invisible; it now runs `test-common.ps1` plus + `test-run-sabotage.ps1` under **both** `pwsh` and `powershell`. + +Verified by sabotage: delivering the identical guard as a module turns the new suite red on +5.1 (7 of 8) while staying green on 7, so the suite detects the regression it was written for. +Both consumer scripts and the full sabotage suite pass on both hosts. + +**Deliberately not done: consolidating `Write-Report`.** Six scripts define one, and they are +not duplicates -- they differ in level vocabulary (`warn`/`warning`, `bad`/`error`, plus +`good`, `note`, `detail`, `heading`) and in rendering, with two emitting GitHub Actions +annotations and four emitting console colours. Merging them would change six tools' output to +remove a duplication that is only apparent. Recorded in +[DESIGN-NOTES.md](DESIGN-NOTES.md#tools-shared-support) so it is a decision rather than an +oversight. diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index 354f3374..02db8b2c 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -1815,3 +1815,50 @@ the caller's own parsing mode -- `GetFullPathNameW`'s output, or it after normalisation preserves what the path meant; doing it before silently reinterprets it. Both crates arrived at this independently, which is why it is written down once. + +## Shared support in `tools/` is dot-sourced, never a module + +`tools/common.ps1` holds the support every script there shares, and it is delivered by +dot-sourcing (`. (Join-Path $PSScriptRoot 'common.ps1')`). **Converting it to a `.psm1` and +importing it silently breaks it on Windows PowerShell 5.1, which is the only host it exists +to protect**, so the delivery mechanism is a correctness requirement rather than a +preference. + +The single thing it carries today is `Invoke-Native`, the guard against 5.1's treatment of +native stderr: there, a native command that writes to stderr while `$ErrorActionPreference` +is `Stop` raises a **terminating** error when its stderr is redirected with `2>&1`. +PowerShell 7 does not. The guard flips the preference to `Continue` around the call and +restores it afterwards. + +**Why a module fails.** A scriptblock carries the session state it was created in. +`Invoke-Native { cargo build }` builds that scriptblock in the *caller's* script scope, so +`& $Command` runs it there, not in the module's scope. A module copy sets +`$ErrorActionPreference` in the module's own scope, the flip never reaches the scriptblock, +and the native call still runs under `Stop`. Measured with the identical body in a `.psm1`: +under 5.1 seven of the eight cases in [tools/test-common.ps1](tools/test-common.ps1) failed, +while **all eight passed under PowerShell 7**. Dot-sourcing puts the function in the caller's +own scope, where the plain assignment does reach the call. + +A module *can* be made to work by reaching into the caller's session state +(`$PSCmdlet.SessionState.PSVariable.Set(...)`), and that was measured working on both hosts. +It is rejected because the guard would then rest on a subtlety that looks removable: anyone +simplifying it back to a plain assignment reintroduces a defect that still passes on +PowerShell 7 and in CI. Dot-sourcing makes the property hold by construction. + +**The test crosses hosts, and refuses to pass vacuously.** +[tools/test-common.ps1](tools/test-common.ps1) runs its cases in the invoking host and then +re-invokes itself in the other one, failing if it cannot find it. A single-host suite is +worthless for this defect class -- the whole hazard is that it is invisible on the host most +people run, and CI ran `shell: pwsh` only, which is how the original defect reached `main` +and survived review. The `sabotage harness tests` job now runs `test-run-sabotage.ps1` under +both shells for the same reason. + +**What is deliberately NOT shared: `Write-Report`.** Six scripts define a function by that +name, and they are *not* duplicates -- they differ in level vocabulary (`warn` against +`warning`, `bad` against `error`, plus `good`, `note`, `detail`, `heading`) and in rendering: +[run-numa-spikes.ps1](tools/run-numa-spikes.ps1) and +[soak-flush-barrier.ps1](tools/soak-flush-barrier.ps1) emit GitHub Actions annotations +(`::warning::`), the other four emit console colours. Consolidating them would mean unifying +those vocabularies, which changes the output of six tools to remove a duplication that is +only apparent. The shared name is a naming convention -- the repository's one-output-sink +rule -- not shared code, and it stays that way until some script needs another's rendering. diff --git a/tools/common.ps1 b/tools/common.ps1 new file mode 100644 index 00000000..5b9ccda5 --- /dev/null +++ b/tools/common.ps1 @@ -0,0 +1,92 @@ +# Copyright (c) Mike Grier. +<# +.SYNOPSIS + Shared support for the scripts in `tools/`. Dot-source it; do not run it. + +.DESCRIPTION + Dot-sourced rather than imported as a module, and that is a correctness + requirement rather than a style choice. See "Why not a module" below before + converting this to a `.psm1`. + + Every script here that captures a native command's output needs the same + guard against Windows PowerShell 5.1's treatment of stderr, and that guard + had been copied into three scripts by the time it was written down. This is + the one copy. + +.NOTES + Usage, from any script in this directory: + + . (Join-Path $PSScriptRoot 'common.ps1') + + `$PSScriptRoot` is populated in a script body on both hosts. It is NOT + populated while evaluating a parameter default on a `[CmdletBinding()]` + script under 5.1, so keep this call in the body, as the scripts here do for + their own `-OutputDirectory` defaults. +#> + +# Render a captured record as plain text. +# +# `2>&1` wraps a native command's stderr in ErrorRecords. One of those +# stringifies to the literal text `System.Management.Automation.RemoteException`, +# which lands in the middle of a captured compiler diagnostic and makes a +# transcript less legible than the console output of the same failure. +function ConvertTo-OutputLines { + param([Parameter(ValueFromPipeline = $true)] $Record) + process { + if ($Record -is [System.Management.Automation.ErrorRecord]) { + $Record.Exception.Message + } + else { + "$Record" + } + } +} + +# Run a native command, capturing merged stdout+stderr as plain strings. +# +# Under Windows PowerShell 5.1, a native command that writes to stderr while +# `$ErrorActionPreference` is `Stop` raises a TERMINATING error when its stderr +# is redirected with `2>&1`. PowerShell 7 does not. Flipping the preference to +# `Continue` around the call is what makes the capture work on both, and +# restoring it afterwards keeps `Stop` for everything that is not a native call. +# +# `$LASTEXITCODE` is global, so a caller still reads the command's exit code +# after this returns. That matters here: these scripts distinguish a broken +# instrument from a finding by exactly that code. +# +# ## Why not a module +# +# Moving this function into a `.psm1` and importing it SILENTLY BREAKS IT under +# 5.1, which is the only host it exists to protect. +# +# A scriptblock carries the session state it was created in. `Invoke-Native +# { cargo build }` builds that scriptblock in the CALLER's script scope, so +# `& $Command` runs it there -- not in the module's scope. A module copy of this +# function sets `$ErrorActionPreference` in the module's own scope, the flip +# never reaches the scriptblock, and the native call still runs under `Stop`. +# +# Measured, because the failure is invisible on the host most people run: with +# the identical body in a `.psm1`, 5.1 threw `RemoteException` and captured +# nothing while PowerShell 7 succeeded. Dot-sourcing lands the function in the +# caller's own scope, where the plain assignment below does reach the call, and +# both hosts then behave identically. +# +# A module CAN be made to work by reaching into the caller's session state +# (`$PSCmdlet.SessionState.PSVariable.Set(...)`), and that was measured working +# too. It is rejected because the guard would then depend on a subtlety that +# looks removable: anyone simplifying it back to a plain assignment would +# reintroduce a defect that still passes on PowerShell 7 and in CI. Dot-sourcing +# makes the property hold by construction instead of by counter-measure. +# +# [test-common.ps1](test-common.ps1) asserts this on both hosts. +function Invoke-Native { + param([Parameter(Mandatory = $true)][scriptblock] $Command) + $previous = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try { + & $Command 2>&1 | ConvertTo-OutputLines + } + finally { + $ErrorActionPreference = $previous + } +} diff --git a/tools/run-numa-spikes.ps1 b/tools/run-numa-spikes.ps1 index 0bb0d62a..4ea4f4a9 100644 --- a/tools/run-numa-spikes.ps1 +++ b/tools/run-numa-spikes.ps1 @@ -68,63 +68,22 @@ function Write-Report { } } -# Render a captured record as plain text. `2>&1` wraps a native command's stderr -# in ErrorRecords, and one of those stringifies to the literal text -# `System.Management.Automation.RemoteException` in the middle of a compiler -# diagnostic, so the transcript of a broken instrument was less legible than the -# console log of the same failure. -function ConvertTo-OutputLines { - param([Parameter(ValueFromPipeline = $true)] $Record) - process { - if ($Record -is [System.Management.Automation.ErrorRecord]) { - $Record.Exception.Message - } - else { - "$Record" - } - } -} - -# Run a native command, capturing merged stdout+stderr as plain strings. -# -# The ErrorActionPreference dance is what makes this work on Windows PowerShell -# 5.1. There, a native command writing to stderr under `Stop` raises a -# TERMINATING error. Restoring the preference afterwards keeps `Stop` for -# everything that is not a native call. +# `Invoke-Native` and `ConvertTo-OutputLines`, which every capture below goes +# through. Dot-sourced rather than imported: a module copy of that guard does +# not reach the scriptblock it is handed, and silently fails on 5.1 alone. The +# full argument, and the measurement behind it, is in that file. # -# Measured, because the shape of the failure is what makes it dangerous rather -# than merely annoying. `cargo --quiet` writes nothing to stderr on a clean -# build, so under 5.1 this script ran to completion for as long as every spike -# was healthy. It threw only when cargo did write there -- a warning, or a -# failed compile -- which is exactly the case this script exists to report. The -# throw landed before `$buildExit` was assigned, so the broken-instrument -# branch never ran: no transcript, no summary, and the one artifact somebody -# downloads to diagnose a rotted spike was the one case that never produced it. -# Confirmed both ways against a deliberately uncompilable crate: unguarded, 5.1 -# threw and captured nothing; guarded, it returned exit 101 with all seven lines -# of diagnostic. Under 7 both forms are fine, which is why this survived. -# -# `$LASTEXITCODE` is global, so a caller still reads the native command's exit -# code after this returns -- which is the whole point here, since this script -# distinguishes a broken instrument from a finding by exactly that code. -# -# This is the third copy of this guard in `tools/`; `soak-flush-barrier.ps1` and -# `test-run-sabotage.ps1` carry the same one. Kept as a copy rather than shared -# because `tools/` has no module or dot-sourcing convention, and inventing one -# here would change how every script in the directory is invoked. Written down -# so the duplication is a recorded choice rather than something a later reader -# has to rediscover -- three copies of one rule can drift. -function Invoke-Native { - param([Parameter(Mandatory = $true)][scriptblock] $Command) - $previous = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - & $Command 2>&1 | ConvertTo-OutputLines - } - finally { - $ErrorActionPreference = $previous - } -} +# What it costs THIS script, recorded here because the shape is specific to the +# spikes: `cargo --quiet` writes nothing to stderr on a clean build, so under +# 5.1 this ran to completion for as long as every spike was healthy. It threw +# only when cargo did write there -- a warning, or a failed compile -- which is +# exactly the case this script exists to report. The throw landed before +# `$buildExit` was assigned, so the broken-instrument branch never ran: no +# transcript, no summary, and the one artifact somebody downloads to diagnose a +# rotted spike was the one case that never produced it. Confirmed both ways +# against a deliberately uncompilable crate: unguarded, 5.1 threw and captured +# nothing; guarded, it returned exit 101 with all seven lines of diagnostic. +. (Join-Path $PSScriptRoot 'common.ps1') $repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..') $spikeDir = Join-Path $repoRoot 'crates\windows-ioring-sys\design-sessions\spikes' diff --git a/tools/run-sabotage.ps1 b/tools/run-sabotage.ps1 index ecf4d939..e2661478 100644 --- a/tools/run-sabotage.ps1 +++ b/tools/run-sabotage.ps1 @@ -171,6 +171,13 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# `Invoke-Native`, which the git calls below go through. Dot-sourced rather than +# imported: a module copy of that guard does not reach the scriptblock it is +# handed, and silently fails on Windows PowerShell 5.1 alone -- see +# [common.ps1](common.ps1), and [test-common.ps1](test-common.ps1) for the +# cross-host proof. +. (Join-Path $PSScriptRoot 'common.ps1') + $utf8NoBom = [System.Text.UTF8Encoding]::new($false) # Script scope so Invoke-Bounded reads it without threading it through three @@ -837,7 +844,7 @@ New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null # without bound and looks like nothing until it does. $outputFull = [System.IO.Path]::GetFullPath($OutputDirectory) if ($outputFull.StartsWith($repoRootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { - git -C $repoRoot check-ignore -q -- $outputFull 2>&1 | Out-Null + Invoke-Native { git -C $repoRoot check-ignore -q -- $outputFull } | Out-Null if ($LASTEXITCODE -ne 0) { Exit-WithMessage (@( "-OutputDirectory is inside the repository but git does not ignore it:" diff --git a/tools/soak-flush-barrier.ps1 b/tools/soak-flush-barrier.ps1 index 35ff2d8e..a67f073d 100644 --- a/tools/soak-flush-barrier.ps1 +++ b/tools/soak-flush-barrier.ps1 @@ -134,49 +134,22 @@ function Write-Report { } } -# Normalise one cargo invocation's merged output into plain strings. +# `Invoke-Native` and `ConvertTo-OutputLines`, which every capture site here goes +# through. Dot-sourced rather than imported: a module copy of that guard does not +# reach the scriptblock it is handed, and silently fails on 5.1 alone. The full +# argument, and the measurement behind it, is in that file. # -# With 2>&1, native stderr arrives as ErrorRecord objects. For the *empty* lines -# cargo emits between diagnostics the message is "" while ToString() falls back -# to the type name, so a bare "$_" renders those as -# "System.Management.Automation.RemoteException" scattered through the output -- -# which then reaches both the saved log and the Select-String that extracts the -# detail column. +# What it costs THIS script, recorded here because the shape is specific to the +# soak: cargo writes to stderr routinely -- "Compiling ...", and the "did not +# finalize incremental compilation session directory" notes this workspace emits +# constantly. Measured: under 5.1 the script died in the build step with +# NativeCommandError before running a single instrument, having written only the +# CSV header. Under 7 the same script completed. # -# Defined once and used by every capture site on purpose: this was originally -# fixed at the build step alone, leaving the per-instrument run with the same -# defect, which is how two copies of one rule drift apart. -function ConvertTo-OutputLines { - param([Parameter(ValueFromPipeline = $true)] $Record) - process { - if ($Record -is [System.Management.Automation.ErrorRecord]) { - $Record.Exception.Message - } else { - "$Record" - } - } -} - -# Run a native command, capturing merged stdout+stderr as plain strings. -# -# The ErrorActionPreference dance is what makes this work on Windows PowerShell -# 5.1. There, a native command writing to stderr under `Stop` raises a -# TERMINATING error, and cargo writes to stderr routinely -- "Compiling ...", -# and the "did not finalize incremental compilation session directory" notes -# this workspace emits constantly. Measured: under 5.1 the script died in the -# build step with NativeCommandError before running a single instrument, having -# written only the CSV header. Under 7 the same script completed. Restoring the -# preference afterwards keeps `Stop` for everything that is not a native call. -function Invoke-Native { - param([Parameter(Mandatory = $true)][scriptblock] $Command) - $previous = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { - & $Command 2>&1 | ConvertTo-OutputLines - } finally { - $ErrorActionPreference = $previous - } -} +# Used by every capture site rather than one: this was originally fixed at the +# build step alone, leaving the per-instrument run with the same defect, which is +# how two copies of one rule drift apart. +. (Join-Path $PSScriptRoot 'common.ps1') # The rotation, in the order described above. $instruments = @( diff --git a/tools/test-common.ps1 b/tools/test-common.ps1 new file mode 100644 index 00000000..aa222212 --- /dev/null +++ b/tools/test-common.ps1 @@ -0,0 +1,179 @@ +# Copyright (c) Mike Grier. +<# +.SYNOPSIS + Tests for [common.ps1](common.ps1), run on BOTH PowerShell hosts. + +.DESCRIPTION + Runs its cases in the host that invoked it, then re-invokes itself in the + other host and requires that run to pass too. + + The cross-host run is the point of this file, not a nicety. The defect + `Invoke-Native` exists to prevent appears ONLY under Windows PowerShell 5.1: + PowerShell 7 captures a native command's stderr under `Stop` without + complaint. A suite that tested one host would report green while the guard + was broken on the only host that needs it -- which is how the original + defect reached `main` and survived review, since CI runs `shell: pwsh`. + + It refuses to pass vacuously: if the other host cannot be found, that is a + FAILURE rather than a skip, because "tested one host" is not the claim this + file is here to make. + +.PARAMETER SingleHost + Run the cases in this process only, without re-invoking the other host. + Used internally for the child run; also useful when debugging one host. +#> +[CmdletBinding()] +param( + [switch] $SingleHost +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'common.ps1') + +$script:Failures = 0 +$script:Host51 = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' + +# The single output sink, per the repository's one-sink rule. +function Write-Report { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message, + [ValidateSet('info', 'good', 'bad', 'heading')][string] $Level = 'info' + ) + switch ($Level) { + 'good' { Write-Host $Message -ForegroundColor Green } + 'bad' { Write-Host $Message -ForegroundColor Red } + 'heading' { Write-Host $Message -ForegroundColor Cyan } + default { Write-Host $Message -ForegroundColor Gray } + } +} + +function Test-Case { + param([string] $Name, [scriptblock] $Body) + try { + & $Body + Write-Report " PASS $Name" -Level good + } + catch { + $script:Failures++ + Write-Report " FAIL $Name" -Level bad + Write-Report " $($_.Exception.Message)" + } +} + +function Assert-Equal { + param($Expected, $Actual, [string] $What) + if ("$Expected" -ne "$Actual") { + throw "$What -- expected '$Expected', got '$Actual'" + } +} + +Write-Report "common.ps1 tests on $($PSVersionTable.PSVersion)" -Level heading + +# The guard's whole purpose. A bare `& { ... } 2>&1` here would throw on 5.1. +Test-Case 'a native command writing to stderr is captured, not thrown' { + $out = Invoke-Native { cmd /c "echo to-stderr 1>&2" } + Assert-Equal 'to-stderr' ("$out".Trim()) 'captured text' +} + +Test-Case 'stdout and stderr are merged in one capture' { + $out = Invoke-Native { cmd /c "echo on-out & echo on-err 1>&2" } + $text = ($out -join ' ') + if ($text -notmatch 'on-out' -or $text -notmatch 'on-err') { + throw "both streams must appear, got '$text'" + } +} + +# The reason the script that motivated this cares: it tells a broken instrument +# from a finding by the exit code, so the guard must not swallow it. +Test-Case 'the exit code survives the capture' { + $null = Invoke-Native { cmd /c "echo boom 1>&2 & exit 3" } + Assert-Equal 3 $LASTEXITCODE 'LASTEXITCODE after a failing native command' +} + +Test-Case 'a failing command still yields its diagnostic text' { + $out = Invoke-Native { cmd /c "echo diagnostic-line 1>&2 & exit 1" } + if ("$out" -notmatch 'diagnostic-line') { + throw "the diagnostic must be captured, got '$out'" + } +} + +# Flattening is what keeps a transcript readable; without it a captured stderr +# line can stringify to `System.Management.Automation.RemoteException`. +Test-Case 'captured records are plain strings, not ErrorRecords' { + $out = @(Invoke-Native { cmd /c "echo to-stderr 1>&2" }) + foreach ($line in $out) { + if ($line -is [System.Management.Automation.ErrorRecord]) { + throw 'a raw ErrorRecord escaped ConvertTo-OutputLines' + } + } + if ("$out" -match 'RemoteException') { + throw "a record stringified to RemoteException: '$out'" + } +} + +Test-Case 'the caller''s ErrorActionPreference is restored afterwards' { + $before = $ErrorActionPreference + $null = Invoke-Native { cmd /c "echo to-stderr 1>&2" } + Assert-Equal $before $ErrorActionPreference 'ErrorActionPreference after the call' +} + +# The guard must not disarm `Stop` for anything that is not the native call. +Test-Case 'Stop still terminates a non-native error after the call' { + $null = Invoke-Native { cmd /c "echo to-stderr 1>&2" } + $threw = $false + try { Get-Item 'Q:\no\such\path\at\all.txt' | Out-Null } catch { $threw = $true } + if (-not $threw) { throw 'Stop was left disarmed for cmdlet errors' } +} + +# Restoration must survive the native call throwing for some other reason, or a +# later failure would run with the preference still flipped. +Test-Case 'ErrorActionPreference is restored even when the command throws' { + $before = $ErrorActionPreference + try { $null = Invoke-Native { throw 'deliberate' } } catch { } + Assert-Equal $before $ErrorActionPreference 'ErrorActionPreference after a throwing command' +} + +if (-not $SingleHost) { + # The other host, which is the claim this file exists to make. + $isSeven = $PSVersionTable.PSVersion.Major -ge 6 + $other = if ($isSeven) { $script:Host51 } else { 'pwsh' } + $otherName = if ($isSeven) { 'Windows PowerShell 5.1' } else { 'PowerShell 7' } + + $resolved = if ($isSeven) { + if (Test-Path $other) { $other } else { $null } + } + else { + $command = Get-Command $other -ErrorAction SilentlyContinue + if ($command) { $command.Source } else { $null } + } + + if (-not $resolved) { + $script:Failures++ + Write-Report '' + Write-Report "FAIL $otherName was not found, so the cross-host claim is untested." -Level bad + Write-Report ' This suite exists to prove the guard on BOTH hosts: the defect it' + Write-Report ' guards against appears only on 5.1, so a single-host pass is not' + Write-Report ' the result this file reports. Install the missing host or run it' + Write-Report ' there by hand rather than treating this as a skip.' + } + else { + Write-Report '' + Write-Report "=== re-running under $otherName ===" -Level heading + & $resolved -NoProfile -File $PSCommandPath -SingleHost + if ($LASTEXITCODE -ne 0) { + $script:Failures++ + Write-Report "FAIL the $otherName run reported failures." -Level bad + } + } +} + +Write-Report '' +if ($script:Failures -gt 0) { + Write-Report "$($script:Failures) failure(s)." -Level bad + exit 1 +} + +Write-Report 'All cases passed.' -Level good +exit 0 diff --git a/tools/test-run-sabotage.ps1 b/tools/test-run-sabotage.ps1 index 4e5a90c4..9b711a2d 100644 --- a/tools/test-run-sabotage.ps1 +++ b/tools/test-run-sabotage.ps1 @@ -74,6 +74,13 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' +# `Invoke-Native`, which every native call below goes through. Dot-sourced +# rather than imported: a module copy of that guard does not reach the +# scriptblock it is handed, and silently fails on 5.1 alone -- see +# [common.ps1](common.ps1), and [test-common.ps1](test-common.ps1) for the +# cross-host proof. +. (Join-Path $PSScriptRoot 'common.ps1') + $script:Harness = Join-Path $PSScriptRoot 'run-sabotage.ps1' $script:Passed = 0 $script:Failed = 0 @@ -153,12 +160,12 @@ function New-Fixture { [System.IO.File]::WriteAllText((Join-Path $root '.gitignore'), ".scratch/`n") [System.IO.File]::WriteAllText((Join-Path $root 'src\lib.rs'), $Source) - git -C $root init --quiet 2>&1 | Out-Null + Invoke-Native { git -C $root init --quiet } | Out-Null if ($null -ne $Manifest) { Set-Manifest -Root $root -Spec $Manifest } # Added to the index but not committed: `git ls-files` reads the index, # which is all the harness needs, and committing would demand identity # configuration this fixture has no reason to care about. - git -C $root add -A 2>&1 | Out-Null + Invoke-Native { git -C $root add -A } | Out-Null return $root } @@ -166,7 +173,7 @@ function Set-Manifest { param([string] $Root, $Spec) $json = $Spec | ConvertTo-Json -Depth 8 [System.IO.File]::WriteAllText((Join-Path $Root 'sabotage.json'), $json) - git -C $Root add -A 2>&1 | Out-Null + Invoke-Native { git -C $Root add -A } | Out-Null } # The default manifest: patches the fixture's marker line, expecting it caught. @@ -234,24 +241,24 @@ function Invoke-Harness { param([string] $Root, [string[]] $Arguments) Push-Location $Root - # $ErrorActionPreference is dropped to Continue for the call, and this is - # load bearing on Windows PowerShell 5.1. There, a native command's stderr - # redirected with 2>&1 arrives as an ErrorRecord, which under Stop is a - # TERMINATING error -- so every case testing a rejection path threw on the - # harness's own message instead of reading its exit code, and reported the - # harness's text as the failure. The harness writes to stderr deliberately - # (that is what Exit-WithMessage is for), so its output is data here, not a - # fault. PowerShell 7 does not do this, which is why the suite passed there - # and failed on 5.1 until it was run on both. - $previous = $ErrorActionPreference - $ErrorActionPreference = 'Continue' + # Through Invoke-Native, and that is load bearing on Windows PowerShell 5.1. + # The harness runs here as a CHILD PROCESS, so its `Exit-WithMessage` writes + # -- which go straight to the process stderr handle -- are native stderr to + # this script. Redirected with 2>&1 under Stop they arrive as ErrorRecords, + # which is a TERMINATING error: every case testing a rejection path threw on + # the harness's own message instead of reading its exit code, and reported + # the harness's text as the failure. The harness writes to stderr + # deliberately, so its output is data here, not a fault. PowerShell 7 does + # not do this, which is why the suite passed there and failed on 5.1 until + # it was run on both. try { $shell = if ($PSVersionTable.PSVersion.Major -ge 6) { 'pwsh' } else { 'powershell' } - $text = & $shell -NoProfile -File $script:Harness @Arguments 2>&1 | Out-String + $text = Invoke-Native { + & $shell -NoProfile -File $script:Harness @Arguments + } | Out-String return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $text } } finally { - $ErrorActionPreference = $previous Pop-Location } } @@ -800,7 +807,7 @@ Test-Case 'copies a file whose name git quotes, and drops it when the source doe try { $odd = Join-Path $root ('src\caf' + [char]0xE9 + '.rs') [System.IO.File]::WriteAllText($odd, "// unicode`n") - git -C $root add -A 2>&1 | Out-Null + Invoke-Native { git -C $root add -A } | Out-Null $stub = New-Stub -Behaviour 'fail' -Root $root Invoke-Harness -Root $root ` @@ -810,7 +817,7 @@ Test-Case 'copies a file whose name git quotes, and drops it when the source doe Assert-True (Test-Path -LiteralPath $copied) 'the copy must not silently omit it' Remove-Item -LiteralPath $odd -Force - git -C $root add -A 2>&1 | Out-Null + Invoke-Native { git -C $root add -A } | Out-Null Invoke-Harness -Root $root ` -Arguments @('-Manifest', 'sabotage.json', '-CargoCommand', $stub) | Out-Null From 0df58be5642d332ca60fda589feffa7e3c97f4c2 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 7 Sep 2026 21:29:51 -0400 Subject: [PATCH 360/361] feat(tools): report which Copilot reviews are still outstanding, and mark the ones that are not PR #56 has accumulated 196 Copilot reviews and nothing said which had been dealt with. Judging by eye does not scale, and judging by "did a commit follow it" is guesswork. tools/scan-pr-reviews.ps1 reports the two signals that are real and adds the one GitHub does not have. Findings arrive in two shapes and only one of them has state: - Inline comments become review threads, which can be RESOLVED. That flag is durable, visible and queryable, so it is the tag -- nothing to invent. - Suppressed comments exist only as prose in the review body's

block. They create NO thread, so there is nothing to resolve, and a review is not a reactable object either: POST /pulls/{n}/reviews/{id}/reactions returns 404, while the same call against an inline comment succeeds (verified both ways). Nothing anywhere records that a suppressed finding was read, which is the gap this closes. For those, -MarkProcessed posts a marker comment on the pull request: An HTML comment, so it does not render; on the pull request rather than in a file or a session, so it survives a new machine, contributor, or agent session; and read back by the script, so a handled review stops being reported. A -Summary is required alongside it, because a marker with no account of what was done is a claim with no evidence. The report separates unresolved threads into current and outdated. Outdated means the anchored line has since changed, which usually means the finding was fixed and the thread never resolved, so those are cheap to clear and are listed only under -IncludeOutdated. Exercised end to end on PR #80, which had four reviews all genuinely handled in this session: its six threads are now resolved, its one suppressed-only review (5136043258, the /MANIFESTINPUT: suggestion refuted by measurement) is marked, and a re-scan reads back clean at exit 0. Deliberately NOT done: back-filling PR #56's 131 suppressed-carrying reviews. A marker asserts the review was read. Almost all were addressed in the rounds that followed them, but "almost all" is not evidence, and marking them wholesale would convert an honest absence of information into a false record. Runs on both PowerShell hosts, through the shared Invoke-Native guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- DESIGN-NOTES.md | 41 +++++++ tools/scan-pr-reviews.ps1 | 248 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 tools/scan-pr-reviews.ps1 diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md index 02db8b2c..8e6ed276 100644 --- a/DESIGN-NOTES.md +++ b/DESIGN-NOTES.md @@ -1862,3 +1862,44 @@ name, and they are *not* duplicates -- they differ in level vocabulary (`warn` a those vocabularies, which changes the output of six tools to remove a duplication that is only apparent. The shared name is a naming convention -- the repository's one-output-sink rule -- not shared code, and it stays that way until some script needs another's rendering. + +## Which Copilot reviews are dealt with: resolve threads, and mark suppressed-only reviews + +A long-lived pull request accumulates hundreds of Copilot reviews -- PR #56 reached 196 -- and +GitHub records "this was dealt with" for only some of them. [tools/scan-pr-reviews.ps1](tools/scan-pr-reviews.ps1) +reports what is genuinely outstanding, and the convention below is what makes that report mean +something. + +**A review's findings arrive in two shapes, and only one of them has state.** + +- **Inline comments** become review threads, which can be **resolved**. That flag is durable, + visible, and queryable, so it is the tag -- there is nothing to invent. Resolve a thread when + its finding is dealt with, including when it is *refuted*: "we measured this and it is not a + defect" is a disposition, not an open question. +- **Suppressed comments** exist only as prose inside the review body's `
` block. They + create **no thread**, so there is nothing to resolve, and a review is not a reactable object + either: `POST /pulls/{n}/reviews/{id}/reactions` returns 404 while the same call against an + inline comment succeeds. Nothing anywhere records that a suppressed finding was read. + +**So suppressed-only reviews are tagged with a marker comment**, posted by the script: + +``` + +``` + +It is an HTML comment, so it does not render; it lives on the pull request rather than in a file +or a session, so it survives a new machine, a new contributor, and a new agent session; and the +script reads it back, which is what keeps the report from re-raising a review already handled. +The script requires a `-Summary` alongside it, because a marker with no account of what was done +is a claim with no evidence. + +**Reading the report.** It separates unresolved threads into *current* and *outdated*. Outdated +means the anchored line has since changed, which usually means the finding was fixed and the +thread simply never resolved -- so those are the cheap ones to clear, and they are listed only +under `-IncludeOutdated` to keep the default output about work that is actually open. + +**A marker asserts the review was read, so do not back-fill in bulk.** PR #56 carries 131 +reviews with suppressed comments and no marker. Almost all were addressed during the rounds +that followed them, but "almost all" is not evidence, and marking them wholesale would convert +an honest absence of information into a false record. Mark a historical review only when +somebody has actually read it. diff --git a/tools/scan-pr-reviews.ps1 b/tools/scan-pr-reviews.ps1 new file mode 100644 index 00000000..20857062 --- /dev/null +++ b/tools/scan-pr-reviews.ps1 @@ -0,0 +1,248 @@ +# Copyright (c) Mike Grier. +<# +.SYNOPSIS + Report which Copilot reviews on a pull request still need attention, and + record the ones that have been dealt with. + +.DESCRIPTION + A long-lived pull request accumulates hundreds of Copilot reviews, and + nothing in the GitHub UI says which have been dealt with. Judging by eye + does not scale, and judging by "did a commit follow it" is guesswork. This + reports the two signals that are real, and adds the one GitHub does not. + + A review carries findings in one of two shapes, and they need different + treatment because GitHub models only one of them: + + INLINE COMMENTS become review threads, which can be RESOLVED. That flag is + durable, visible in the UI, and queryable, so it is the tag for this shape + -- there is nothing to invent. A thread also reports `isOutdated`, meaning + the line it was anchored to has since changed; those are reported + separately, because an outdated finding is usually one that was fixed and + never resolved rather than one still waiting. + + SUPPRESSED COMMENTS exist only as prose inside the review body's `
` + block. They create no thread, so there is nothing to resolve; and a review + is not a reactable object either -- `POST /pulls/{n}/reviews/{id}/reactions` + is 404, while the same call on an inline comment succeeds. A review whose + findings were ALL suppressed therefore has no state anywhere saying it was + read, which is exactly the gap this script closes. + + For those, `-MarkProcessed` posts a pull-request comment carrying a marker: + + + + The marker is an HTML comment, so it does not render, and it lives on the + pull request rather than in a file or a session, which is what makes it + survive a new machine, a new contributor, and a new agent session. A later + run of this script reads those markers back and stops reporting the review. + +.PARAMETER Pr + The pull request number. + +.PARAMETER MarkProcessed + One or more review ids to record as processed. Posts a single comment + carrying a marker for each, with the summary as its visible text. + +.PARAMETER Summary + The visible text of the marker comment. Required with -MarkProcessed: + a marker with no account of what was done is a claim with no evidence. + +.PARAMETER IncludeOutdated + Also list unresolved threads whose anchor line has since changed. + +.EXAMPLE + .\tools\scan-pr-reviews.ps1 -Pr 56 + +.EXAMPLE + .\tools\scan-pr-reviews.ps1 -Pr 56 -MarkProcessed 5136043258 ` + -Summary 'Both suppressed findings were measured and refuted; see commit abc1234.' +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][int] $Pr, + [long[]] $MarkProcessed, + [string] $Summary, + [switch] $IncludeOutdated +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'common.ps1') + +$script:Owner = 'MikeGrier' +$script:Name = 'windows-threadpool-sys' + +# The single output sink, per the repository's one-output-sink rule. +function Write-Report { + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string] $Message, + [ValidateSet('info', 'detail', 'heading', 'warn')][string] $Level = 'info' + ) + switch ($Level) { + 'detail' { Write-Host $Message -ForegroundColor DarkGray } + 'heading' { Write-Host $Message -ForegroundColor Cyan } + 'warn' { Write-Host $Message -ForegroundColor Yellow } + default { Write-Host $Message -ForegroundColor Gray } + } +} + +function Invoke-GitHubJson { + param([string[]] $Arguments) + $text = Invoke-Native { gh @Arguments } + if ($LASTEXITCODE -ne 0) { + throw "gh $($Arguments -join ' ') failed: $($text -join ' ')" + } + return ($text -join "`n") | ConvertFrom-Json +} + +# --- marking ----------------------------------------------------------------- + +if ($MarkProcessed) { + if (-not $Summary) { + throw 'Summary is required with -MarkProcessed: a marker with no account of what was done is a claim with no evidence.' + } + $lines = @($Summary, '') + foreach ($id in $MarkProcessed) { $lines += "" } + $file = Join-Path ([System.IO.Path]::GetTempPath()) ("mark-" + [guid]::NewGuid().ToString('N') + '.md') + [System.IO.File]::WriteAllText($file, ($lines -join "`n"), [System.Text.UTF8Encoding]::new($false)) + try { + $url = Invoke-Native { gh pr comment $Pr --repo "$script:Owner/$script:Name" --body-file $file } + if ($LASTEXITCODE -ne 0) { throw "posting the marker comment failed: $($url -join ' ')" } + Write-Report "marked processed: $($MarkProcessed -join ', ')" + Write-Report ($url -join ' ') -Level detail + } + finally { Remove-Item $file -ErrorAction SilentlyContinue } + exit 0 +} + +# --- scanning ---------------------------------------------------------------- + +# `??` is PowerShell 7 only, and these tools run on 5.1 too. +function Get-Text { + param($Value) + if ($null -eq $Value) { return '' } + return [string]$Value +} + +Write-Report "scanning pull request #$Pr" -Level heading + +$reviews = Invoke-GitHubJson @('api', "repos/$script:Owner/$script:Name/pulls/$Pr/reviews?per_page=100", '--paginate') +$issueComments = Invoke-GitHubJson @('api', "repos/$script:Owner/$script:Name/issues/$Pr/comments?per_page=100", '--paginate') + +# Reviews already recorded as processed, by marker. +$processed = @{} +foreach ($c in $issueComments) { + foreach ($m in [regex]::Matches((Get-Text $c.body), '')) { + $processed[[long]$m.Groups[1].Value] = $true + } +} + +# Unresolved threads, which is the authoritative state for inline findings. +$query = @' +query($owner:String!, $name:String!, $pr:Int!, $cursor:String) { + repository(owner:$owner, name:$name) { + pullRequest(number:$pr) { + reviewThreads(first:100, after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { + isResolved isOutdated path line + comments(first:1) { + nodes { body author { login } pullRequestReview { databaseId } } + } + } + } + } + } +} +'@ + +$threads = @() +$cursor = $null +do { + $arguments = @('api', 'graphql', '-f', "query=$query", + '-F', "owner=$script:Owner", '-F', "name=$script:Name", '-F', "pr=$Pr") + if ($cursor) { $arguments += @('-F', "cursor=$cursor") } + $page = (Invoke-GitHubJson $arguments).data.repository.pullRequest.reviewThreads + foreach ($t in $page.nodes) { + $c = $t.comments.nodes[0] + if ($c.author.login -notmatch '[Cc]opilot') { continue } + $threads += [pscustomobject]@{ + Review = [long]$c.pullRequestReview.databaseId + IsResolved = $t.isResolved + IsOutdated = $t.isOutdated + Path = $t.path + Line = $t.line + Body = ($c.body -replace '\s+', ' ') + } + } + $cursor = if ($page.pageInfo.hasNextPage) { $page.pageInfo.endCursor } else { $null } +} while ($cursor) + +$copilotReviews = @($reviews | Where-Object { $_.user.login -match '[Cc]opilot' }) + +# A review's suppressed count is only in its body, as rendered prose. +function Get-SuppressedCount { + param([string] $Body) + if ($Body -match 'Suppressed comments?\s*\((\d+)\)') { return [int]$Matches[1] } + return 0 +} + +$openThreads = @($threads | Where-Object { -not $_.IsResolved }) +$current = @($openThreads | Where-Object { -not $_.IsOutdated }) +$outdated = @($openThreads | Where-Object { $_.IsOutdated }) + +$suppressedOnly = @() +foreach ($r in $copilotReviews) { + $count = Get-SuppressedCount (Get-Text $r.body) + if ($count -eq 0) { continue } + if ($processed.ContainsKey([long]$r.id)) { continue } + # A review whose inline threads are all resolved may still carry suppressed + # findings nobody read, so this is judged on the marker alone. + $suppressedOnly += [pscustomobject]@{ + Id = [long]$r.id + When = ([datetime]$r.submitted_at).ToString('yyyy-MM-dd HH:mm') + Suppressed = $count + Inline = @($threads | Where-Object { $_.Review -eq [long]$r.id }).Count + } +} + +Write-Report '' +Write-Report "Copilot reviews: $($copilotReviews.Count)" +Write-Report "unresolved threads: $($openThreads.Count) ($($current.Count) current, $($outdated.Count) outdated)" +Write-Report "reviews with suppressed: $(@($copilotReviews | Where-Object { (Get-SuppressedCount (Get-Text $_.body)) -gt 0 }).Count)" +Write-Report " of those, unprocessed: $($suppressedOnly.Count)" +Write-Report '' + +Write-Report '=== unresolved threads on current lines ===' -Level heading +if ($current.Count -eq 0) { Write-Report ' none' -Level detail } +foreach ($t in ($current | Sort-Object Path, Line)) { + Write-Report (" review {0} {1}:{2}" -f $t.Review, $t.Path, $t.Line) + Write-Report (' ' + $t.Body.Substring(0, [Math]::Min(200, $t.Body.Length))) -Level detail +} + +if ($IncludeOutdated) { + Write-Report '' + Write-Report '=== unresolved threads whose anchor line has changed ===' -Level heading + Write-Report ' Usually fixed and never resolved; resolve them to clear this list.' -Level detail + foreach ($t in ($outdated | Sort-Object Path, Line)) { + Write-Report (" review {0} {1}:{2}" -f $t.Review, $t.Path, $t.Line) + } +} + +Write-Report '' +Write-Report '=== reviews with suppressed comments and no processed marker ===' -Level heading +Write-Report ' Suppressed findings create no thread, so nothing else records that they' -Level detail +Write-Report ' were read. Mark one with -MarkProcessed once it has been dealt with.' -Level detail +if ($suppressedOnly.Count -eq 0) { Write-Report ' none' -Level detail } +foreach ($r in ($suppressedOnly | Sort-Object When)) { + Write-Report (" {0} {1} suppressed={2} inline={3}" -f $r.Id, $r.When, $r.Suppressed, $r.Inline) +} + +Write-Report '' +if ($current.Count -gt 0 -or $suppressedOnly.Count -gt 0) { + Write-Report 'Outstanding items above.' -Level warn + exit 1 +} +Write-Report 'Nothing outstanding.' +exit 0 From e1501f2236436a5878d762b55790dcac9eae6c22 Mon Sep 17 00:00:00 2001 From: Mike Grier Date: Mon, 7 Sep 2026 21:43:10 -0400 Subject: [PATCH 361/361] fix(platform-probes): correct four probe measurements and archive three checklist items The eleven open Copilot findings on PR #56, worked through together. Four were probes reporting a number that was not what its label said, which is the failure this crate exists to prevent. **request_cost measured duplicate-plus-close and called it duplication.** `time_loop` black-boxes its closure's return and drops it at the end of the statement, so the returned `CapturedHandle` -- which owns an `OwnedHandle` -- called `CloseHandle` inside the timed region. Capture and close are now timed separately, retaining the duplicates in a pre-sized Vec and timing the drop of that same Vec so the close is recovered as its own figure rather than discarded. Measured on this host in release: capture 322 ns, close 272 ns. The old combined figure was therefore reporting duplication as roughly 594 ns, nearly double, and the report and design notes read it as duplication alone. Both numbers are now printed and both appear in the JSON line. **topology derived a node count from GetNumaHighestNodeNumber.** That reports the largest node NUMBER and numbers may be sparse, so `highest + 1` prints three nodes for a machine with nodes 0 and 2 -- the same mistake `Observation::cross_check` was corrected to stop making, reintroduced in the output printed beside it. It now prints the identifier and says what it is. **topology read `None` from outermost_partitioning_cache as "no level partitions this machine".** It also means two levels partition incomparably, so the renderer turned a deliberate ambiguity result into a false claim about the hardware. It now reports the absence and names both possibilities. **doorbell_cost discarded SubmitIoRing's HRESULT.** A host where the call fails still produces a plausible timing -- a failing call costs a measurable transition -- which the report read as a successful empty submission. Both the status and the expected zero submitted entries are now asserted inside the measured operation, so a failure fails the probe instead of corrupting its evidence. **The outermost-cache test re-derived the selection from level numbers**, which is the rule the topology crate abandoned and which this module's own doc comment records abandoning; the synthetic test below it builds the counterexample. Its `None` arm made the same wrong inference as the renderer. Since these summaries discard processor membership, inclusion cannot be re-derived here at all, so it now asserts what survives the summarising: a selection partitions, is one of the surveyed levels, and agrees with the level the survey captured. **The park-and-wake handshake had no regression test** even though its own documentation records that the first implementation deadlocked. Two bounded tests: the deterministic `rounds == 0` contract, and a small round trip that must complete and report a positive finite average. **The two timing probes now run --release in CI**, and only those two. They compare operations tens of nanoseconds apart and the design reads their RATIOS; in an unoptimized build the loop and closure overhead around each measured call does not inflate them uniformly. Every other probe measures behaviour, which does not change with the optimisation level, so they keep the dev profile. The DOORBELL_NS_REFERENCE constant is NOT re-baselined: it is the figure the 2026-08-30 design session recorded, and replacing it would leave that session describing a number that exists nowhere. Its unrecorded build profile is now stated instead, with a pointer to the like-for-like comparison that does exist -- both probes' own output from the same release CI job. **Three checklist items archived** per the move-with-link rule: M35.1 here and M20.5 in windows-ioring-sys, each now a one-line stub pointing at an anchored archive entry. M35.1's body claimed the manifest embed leaves "the other thirteen probes" unaffected; there are sixteen, so the count was already stale and would have been written into the record as a false number. The archived text uses a count-free phrasing and says why. Verified: fmt clean, workspace clippy --all-targets --all-features clean, 75 package tests pass, encoding check 634 files clean, and probe-request-cost and probe-topology were run in release to confirm the corrected output rather than read from the diff. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 18 +++- CHECKLIST.md | 23 +---- COMPLETED-CHECKLIST.md | 34 +++++++ crates/windows-ioring-sys/CHECKLIST.md | 8 +- .../windows-ioring-sys/COMPLETED-CHECKLIST.md | 10 +++ .../src/bin/request_cost.rs | 37 +++++++- .../src/bin/topology.rs | 24 ++++- .../src/doorbell_cost.rs | 17 +++- .../src/request_cost.rs | 45 +++++++++- crates/windows-platform-probes/src/tests.rs | 90 +++++++++++++++++-- 10 files changed, 260 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9eda215..6274b8d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,17 +252,31 @@ jobs: # of logs mechanically rather than read by eye. - name: probe magnitudes (topology) run: cargo run -p windows-platform-probes --bin probe-topology --locked + # `--release` on the next two, and ONLY on those two, because they are the + # only probes here that report nanoseconds. Everything above measures + # BEHAVIOUR -- what an API refuses, what a handle reports, which errors + # appear -- and that does not change with the optimisation level, so those + # keep the dev profile and the build time it saves. + # + # These two do not have that luxury. They compare operations a few tens of + # nanoseconds apart, and in an unoptimized build the loop, the closure + # indirection, and the request construction around each measured call + # carry overhead that does not shrink uniformly across them. The RATIOS + # are what the design reads -- "a doorbell is x% of a submit", "a captured + # handle costs Nx a built request" -- and a ratio of two figures each + # inflated by a different amount is not the shipping one. + # # Decides how much machinery the two-layer ring's doorbell needs. Its # park-and-wake handshake is bounded rather than INFINITE on purpose: the # first version of it deadlocked, because an auto-reset event does not # count signals and the waiter's count never caught up. A probe that can # hang is a probe that can hang a build. - name: probe magnitudes (doorbell cost) - run: cargo run -p windows-platform-probes --bin probe-doorbell-cost --locked + run: cargo run -p windows-platform-probes --bin probe-doorbell-cost --locked --release # Read with the doorbell probe above: together they say whether the # queue's mechanics or the request's own cost deserves the attention. - name: probe magnitudes (request cost) - run: cargo run -p windows-platform-probes --bin probe-request-cost --locked + run: cargo run -p windows-platform-probes --bin probe-request-cost --locked --release # Both halves of the long-path pair, deliberately. Either alone says # nothing: the finding is the *difference* between two executables that # differ only in whether `build.rs` embedded the `longPathAware` manifest, diff --git a/CHECKLIST.md b/CHECKLIST.md index c68326b8..eb4d2c21 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -187,25 +187,10 @@ M22-M29, and [CHECKLIST-io-domains.md](CHECKLIST-io-domains.md) M30-M33. ## M35 -- Measure what the long-path opt-in actually does -- [x] **M35.1** -- **Measure whether the long-path opt-in lifts `MAX_PATH` for a *relative* path, and - whether it does so without changing how the path is parsed.** - **Done 2026-09-04, and it settles a question that had produced three wrong answers from reading.** - `probe-long-path-aware` and `probe-long-path-unaware` in - [windows-platform-probes](crates/windows-platform-probes/src/long_path.rs) are the same code - differing only in whether their manifest declares `longPathAware`; `build.rs` embeds it into that - one binary via `rustc-link-arg-bin`, so the other thirteen probes are unaffected. - **Result, on a host with `LongPathsEnabled=1`.** With the opt-in, a relative path of 429 characters - opens in every shape -- plain, containing `b\..`, and forward-slash separated. Without it, all three - are refused with `ERROR_PATH_NOT_FOUND` while the same shapes at 78 characters open. The targets are - created first, so a not-found from a file that provably exists is the length refusal. - **So the documented reading was right and the review finding was wrong**: the opt-in covers relative - paths, and `MAX_PATH` binds them only in a process that has not opted in. - **And the regularize-then-prefix hypothesis is falsified.** If the opt-in worked by prepending - `\\?\`, that prefix would disable `.`, `..` and forward-slash translation, so those shapes would - have failed past the ceiling while working below it. Both resolve at both lengths. The opt-in lifts - the length check without re-parsing, so there is no discontinuity at `MAX_PATH` for a caller of - `windows-file-watcher` to fall into. - The measurement is recorded where the claim lives, in `Session::subscribe`'s note. +- [x] **M35.1** -- Measure whether the long-path opt-in lifts `MAX_PATH` for a relative path, and + whether it does so without re-parsing it. It does both, and the regularize-then-prefix hypothesis + is falsified. -> [completed 2026-09-04](COMPLETED-CHECKLIST.md#m351) + ## M-inf -- Parked Ungated work with no identified predecessor deliverable. diff --git a/COMPLETED-CHECKLIST.md b/COMPLETED-CHECKLIST.md index 676211f4..5a339696 100644 --- a/COMPLETED-CHECKLIST.md +++ b/COMPLETED-CHECKLIST.md @@ -2236,3 +2236,37 @@ annotations and four emitting console colours. Merging them would change six too remove a duplication that is only apparent. Recorded in [DESIGN-NOTES.md](DESIGN-NOTES.md#tools-shared-support) so it is a decision rather than an oversight. + +## Moved 2026-09-07 -- M35.1: what the long-path opt-in actually does + +### M35.1 -- Measure whether the long-path opt-in lifts `MAX_PATH` for a relative path, and whether it does so without re-parsing it. It does both, and the regularize-then-prefix hypothesis is falsified. *(completed 2026-09-04, archived 2026-09-07 21:40:23 -04:00)* + +**Measure whether the long-path opt-in lifts `MAX_PATH` for a *relative* path, and whether it does +so without changing how the path is parsed.** + +**Done 2026-09-04, and it settles a question that had produced three wrong answers from reading.** +`probe-long-path-aware` and `probe-long-path-unaware` in +[windows-platform-probes](crates/windows-platform-probes/src/long_path.rs) are the same code +differing only in whether their manifest declares `longPathAware`; `build.rs` embeds it into that +one binary via `rustc-link-arg-bin`, so every other probe binary is unaffected. + +**Result, on a host with `LongPathsEnabled=1`.** With the opt-in, a relative path of 429 characters +opens in every shape -- plain, containing `b\..`, and forward-slash separated. Without it, all three +are refused with `ERROR_PATH_NOT_FOUND` while the same shapes at 78 characters open. The targets are +created first, so a not-found from a file that provably exists is the length refusal. + +**So the documented reading was right and the review finding was wrong**: the opt-in covers relative +paths, and `MAX_PATH` binds them only in a process that has not opted in. + +**And the regularize-then-prefix hypothesis is falsified.** If the opt-in worked by prepending +`\\?\`, that prefix would disable `.`, `..` and forward-slash translation, so those shapes would +have failed past the ceiling while working below it. Both resolve at both lengths. The opt-in lifts +the length check without re-parsing, so there is no discontinuity at `MAX_PATH` for a caller of +`windows-file-watcher` to fall into. + +The measurement is recorded where the claim lives, in `Session::subscribe`'s note. + +*(Archiving note: the original body said the embed leaves "the other thirteen probes" unaffected. +There were sixteen probe binaries by the time this was archived, so the count was already stale and +would have been written into the record as a false number. Replaced with the count-free phrasing, +which stays true as probes are added. Raised in review 5125955392 on pull request #56.)* diff --git a/crates/windows-ioring-sys/CHECKLIST.md b/crates/windows-ioring-sys/CHECKLIST.md index ec907f30..3424739b 100644 --- a/crates/windows-ioring-sys/CHECKLIST.md +++ b/crates/windows-ioring-sys/CHECKLIST.md @@ -128,11 +128,9 @@ conclusions belong to it until it converges. PDO advertises a proximity domain. Write the correction now (the documentation defect is independent of the measurement) and leave the empirical question open. -- [x] **M20.5** -- **Dissolved by [D-47](DESIGN-NOTES.md#d-47-detail), not decided.** This asked whether to - make the load-sensitive `flush_barrier` assertion load-independent or mark the test serial. Neither: the - assertion was measuring a claim the platform does not honour, so it was not a flaky test at all. Its own - contention hypothesis was disproved in the same measurement -- an idle ring failed too. Recorded in - [RESOLVED-TEST-FAILURES.md](RESOLVED-TEST-FAILURES.md). +- [x] **M20.5** -- Dissolved by [D-47](DESIGN-NOTES.md#d-47-detail) rather than decided: the + `flush_barrier` assertion was measuring a claim the platform does not honour, so it was never a + flaky test. -> [completed 2026-09-07](COMPLETED-CHECKLIST.md#m205) - [ ] **M20.6** -- Re-evaluate `CommitStrategy::AlternatingRings` and the epoch-log benchmark's conclusion against [D-47](DESIGN-NOTES.md#d-47-detail). The strategy comparison in diff --git a/crates/windows-ioring-sys/COMPLETED-CHECKLIST.md b/crates/windows-ioring-sys/COMPLETED-CHECKLIST.md index eb0eceda..47066e81 100644 --- a/crates/windows-ioring-sys/COMPLETED-CHECKLIST.md +++ b/crates/windows-ioring-sys/COMPLETED-CHECKLIST.md @@ -1494,3 +1494,13 @@ Independent of M17; may run in parallel. `unwrap_or_else(PoisonError::into_inner)`. `scope()` absorbs poisoning internally, matching what the wait callback's own drain already did, so the question no longer reaches callers at all. Verified: 148 tests pass, both affected examples (`model_a_delivery`, `epoch_log`) still run to exit 0. + +## Moved 2026-09-07 -- M20.5: dissolved by D-47 rather than decided + +### M20.5 -- Dissolved by [D-47](DESIGN-NOTES.md#d-47-detail) rather than decided: the `flush_barrier` assertion was measuring a claim the platform does not honour, so it was never a flaky test. *(completed 2026-09-07 21:40:23 -04:00)* + +**Dissolved by [D-47](DESIGN-NOTES.md#d-47-detail), not decided.** This asked whether to make the +load-sensitive `flush_barrier` assertion load-independent or mark the test serial. Neither: the +assertion was measuring a claim the platform does not honour, so it was not a flaky test at all. Its +own contention hypothesis was disproved in the same measurement -- an idle ring failed too. Recorded +in [RESOLVED-TEST-FAILURES.md](RESOLVED-TEST-FAILURES.md). diff --git a/crates/windows-platform-probes/src/bin/request_cost.rs b/crates/windows-platform-probes/src/bin/request_cost.rs index 48d7345a..1c65ceb1 100644 --- a/crates/windows-platform-probes/src/bin/request_cost.rs +++ b/crates/windows-platform-probes/src/bin/request_cost.rs @@ -14,9 +14,19 @@ use std::fmt::Write as _; use windows_platform_probes::report::{Stdout, emit}; use windows_platform_probes::request_cost::measure; -/// Measured by `probe-doorbell-cost` on the same machine. Restated here only to -/// render a ratio; the authoritative number is whatever that probe prints on -/// the host this runs on. +/// Measured by `probe-doorbell-cost` on the development machine, and recorded in +/// [the 2026-08-30 design session]. Restated here only to render a ratio; the +/// authoritative number is whatever that probe prints on the host this runs on. +/// +/// **The build profile behind these is not recorded**, which is why the report +/// below calls the ratios indicative rather than quoting them as results. They +/// are not re-baselined here: the figure is the one that session recorded, and +/// silently replacing it would leave the session describing a number that no +/// longer exists anywhere. CI runs both this probe and `probe-doorbell-cost` +/// under `--release` in the same job, so the like-for-like comparison a reader +/// actually wants is those two outputs, not this constant. +/// +/// [the 2026-08-30 design session]: ../../../../design-sessions/DESIGN-SESSION-2026-08-30-numa-sharded-io-execution-domains.md const DOORBELL_NS_REFERENCE: f64 = 164.9; const ATOMIC_NS_REFERENCE: f64 = 7.2; @@ -158,6 +168,23 @@ fn render() -> String { " a memory copy, and easy to under-count when thinking about what an" ); let _ = writeln!(out, " SQE holds."); + // Reported beside it because the duplication figure above excludes it by + // construction, and a reader sizing a request's real cost needs both: + // every captured handle is eventually closed, so the lifecycle is the + // pair. Keeping them separate is what stops either being quoted as the + // other. + if let Some(close) = observation.get("close_handle") { + let _ = writeln!( + out, + " Closing one costs a further {close:.0} ns, measured separately, so a" + ); + let _ = writeln!( + out, + " captured handle's whole lifecycle is {:.0} ns. The duplication figure", + capture + close + ); + let _ = writeln!(out, " above is the duplication alone."); + } if let Some(build) = build { if capture > build { let _ = writeln!( @@ -239,7 +266,8 @@ fn render() -> String { concat!( r#"{{"reason":"x-probe-request-cost","arch":"{}","prepare_short_ns":{},"#, r#""prepare_long_ns":{},"build_open_request_ns":{},"#, - r#""clone_prepared_units_ns":{},"capture_handle_ns":{}}}"# + r#""clone_prepared_units_ns":{},"capture_handle_ns":{},"#, + r#""close_handle_ns":{}}}"# ), std::env::consts::ARCH, get("prepare_short_path"), @@ -247,6 +275,7 @@ fn render() -> String { get("build_open_request"), get("clone_prepared_units"), get("capture_handle"), + get("close_handle"), ); out } diff --git a/crates/windows-platform-probes/src/bin/topology.rs b/crates/windows-platform-probes/src/bin/topology.rs index a61f80e1..7b8f8cc5 100644 --- a/crates/windows-platform-probes/src/bin/topology.rs +++ b/crates/windows-platform-probes/src/bin/topology.rs @@ -116,9 +116,20 @@ fn render() -> String { ); } None => { + // Two different topologies land here and the difference matters, so + // this reports the absence rather than explaining it. `None` means + // "no unique outermost partitioning cache", which is EITHER that no + // level partitions the machine OR that two levels partition it + // incomparably -- neither being a subset of the other, so neither is + // outermost. Naming only the first turns a reported ambiguity into a + // false claim about the hardware. let _ = writeln!( out, - "\nno cache level partitions this machine: every level is machine-wide" + "\nno unique outermost partitioning cache was established: either no" + ); + let _ = writeln!( + out, + "level partitions this machine, or two partition it incomparably." ); } } @@ -158,10 +169,17 @@ fn render() -> String { ); match observation.raw_highest_numa_node { Some(highest) => { + // The identifier, and deliberately no count derived from it. + // `GetNumaHighestNodeNumber` reports the largest node NUMBER, and + // node numbers may be sparse -- a machine with nodes 0 and 2 has two + // nodes and a highest of 2. `highest + 1` would print three, which + // is the same mistake `Observation::cross_check` was corrected to + // stop making; re-deriving it here would put it back in the output + // the cross-check is printed beside. + let _ = writeln!(out, " GetNumaHighestNodeNumber : {highest}"); let _ = writeln!( out, - " GetNumaHighestNodeNumber : {highest} (so {} nodes)", - highest + 1 + " (the largest node NUMBER, not a count: node numbers can be sparse)" ); } None => { diff --git a/crates/windows-platform-probes/src/doorbell_cost.rs b/crates/windows-platform-probes/src/doorbell_cost.rs index da0322ab..5d0ed95c 100644 --- a/crates/windows-platform-probes/src/doorbell_cost.rs +++ b/crates/windows-platform-probes/src/doorbell_cost.rs @@ -181,7 +181,22 @@ pub fn measure() -> Observation { let submit_nanos = ioring::Ring::new().map(|ring| { const SUBMIT_ITERATIONS: u32 = 20_000; let timing = time_loop("submit_io_ring_empty", SUBMIT_ITERATIONS, || { - let _ = ring.submit_and_wait(0); + // Both halves of the answer are checked, inside the timed region. + // Discarding them let a host where `SubmitIoRing` fails produce a + // perfectly plausible timing -- a failing call still costs a + // measurable transition -- which the report then read as the cost of + // a successful empty submission. That is the failure mode this whole + // crate exists to avoid: a number that looks like evidence and is + // not. The `submitted` count is checked too, because a call that + // succeeded while submitting entries did not measure what the label + // says it measured. + // + // The cost is a predictable branch against a syscall, which does not + // perturb the figure; leaving the check outside the loop would let + // the timing be taken before anything established it was valid. + let (hr, submitted) = ring.submit_and_wait(0); + assert!(hr >= 0, "SubmitIoRing(0) failed: {hr:#010x}"); + assert_eq!(submitted, 0, "SubmitIoRing(0) submitted entries"); }); timings.push(timing); timing.nanos_per_op diff --git a/crates/windows-platform-probes/src/request_cost.rs b/crates/windows-platform-probes/src/request_cost.rs index ffca9876..9a3a4486 100644 --- a/crates/windows-platform-probes/src/request_cost.rs +++ b/crates/windows-platform-probes/src/request_cost.rs @@ -179,11 +179,50 @@ pub fn measure() -> Observation { // The kernel transition a captured handle costs. Measured against a handle // this process already owns, so nothing here depends on the filesystem. + // + // Capture and close are timed SEPARATELY, and that separation is the whole + // point. `time_loop` black-boxes its closure's return value and drops it at + // the end of the statement, so a loop that captures and returns a + // `CapturedHandle` -- which owns an `OwnedHandle` -- also calls + // `CloseHandle` inside the timed region. That is two kernel transitions + // reported as one number, and the report reads that number as the cost of + // duplication alone, so it overstated it by however much a close costs. + // + // Retaining every duplicate in a pre-sized `Vec` keeps the close out of the + // capture loop, and timing the drop of that same `Vec` recovers the close as + // its own figure rather than discarding it. The `push` is a pointer bump + // into reserved capacity, which is not free but is nowhere near a syscall. let file = std::fs::File::open(&system_dll).expect("kernel32.dll is readable"); let borrowed = std::os::windows::io::AsHandle::as_handle(&file); - timings.push(time_loop("capture_handle", HANDLE_ITERATIONS, || { - CapturedHandle::capture(borrowed).expect("duplicating an owned handle") - })); + + // Warmed the same way `time_loop` warms, and for the same reason: the first + // pass pays for lazily resolved syscall stubs and the allocator's first + // touch of a fresh size class. + for _ in 0..256 { + let _ = + std::hint::black_box(CapturedHandle::capture(borrowed).expect("duplicating a handle")); + } + + let mut captured = Vec::with_capacity(HANDLE_ITERATIONS as usize); + let start = Instant::now(); + for _ in 0..HANDLE_ITERATIONS { + captured.push(CapturedHandle::capture(borrowed).expect("duplicating an owned handle")); + } + let capture_elapsed = start.elapsed(); + timings.push(Timing { + label: "capture_handle", + iterations: HANDLE_ITERATIONS, + nanos_per_op: capture_elapsed.as_nanos() as f64 / f64::from(HANDLE_ITERATIONS), + }); + + let start = Instant::now(); + drop(captured); + let close_elapsed = start.elapsed(); + timings.push(Timing { + label: "close_handle", + iterations: HANDLE_ITERATIONS, + nanos_per_op: close_elapsed.as_nanos() as f64 / f64::from(HANDLE_ITERATIONS), + }); Observation { timings } } diff --git a/crates/windows-platform-probes/src/tests.rs b/crates/windows-platform-probes/src/tests.rs index 41f45c71..0e0a937c 100644 --- a/crates/windows-platform-probes/src/tests.rs +++ b/crates/windows-platform-probes/src/tests.rs @@ -846,25 +846,54 @@ fn every_cache_level_reports_at_least_one_domain_with_at_least_one_processor() { fn the_outermost_partitioning_cache_is_the_deepest_level_that_splits_the_machine() { let observation = crate::topology::measure().expect("topology discovery"); + // What this can check, and deliberately no more. `Observation`'s caches are + // SUMMARIES -- a level, a domain count, and the size of each domain -- with + // processor membership discarded. "Outermost" is defined by set inclusion + // between partitions, so nothing here can re-derive the selection, and the + // two attempts to do so anyway were both wrong: + // + // - Ordering candidates by LEVEL NUMBER is the rule the topology crate + // abandoned, because a higher number is not always coarser. This module's + // own `outermost_partitioning_cache` doc records it, and the synthetic + // test below builds the counterexample: a valid L2 outermost partition + // alongside a finer L3 that still partitions. A level-number assertion + // here would fail that legitimate topology. + // + // - Reading `None` as "no level partitions this machine" is false for the + // same reason the renderer was corrected: `None` is also the answer when + // two partitionings are incomparable, which is a deliberate ambiguity + // result rather than a claim about the hardware. + // + // So this asserts the two properties that survive the summarising: a + // selection is one of the surveyed levels and genuinely partitions, and the + // selection agrees with the level the survey captured. match observation.outermost_partitioning_cache() { Some(chosen) => { assert!( chosen.domains > 1, "a level that does not partition cannot be the partitioning level" ); - // Nothing deeper may also partition, or the wrong one was chosen. + assert_eq!( + Some(chosen.level), + observation.partitioning_cache_level, + "the looked-up summary must be the level the survey selected" + ); assert!( - observation - .caches - .iter() - .all(|other| other.level <= chosen.level || other.domains <= 1), - "a deeper cache level also partitions this machine, so the outermost \ - one was mis-selected" + observation.caches.iter().any(|c| c.level == chosen.level), + "the selected level must be one this survey actually recorded" ); } None => assert!( - observation.caches.iter().all(|cache| cache.domains <= 1), - "no level was chosen even though one partitions the machine" + // Only the internal consistency, which is all that is knowable: a + // survey that captured no level must look one up, and a survey that + // captured one must find it. Why the level is absent is not + // recoverable from here, and guessing was the defect. + observation.partitioning_cache_level.is_none() + || !observation + .caches + .iter() + .any(|c| Some(c.level) == observation.partitioning_cache_level), + "a level was selected but its summary was not found" ), } } @@ -933,3 +962,46 @@ fn no_partitioning_level_is_a_real_answer_in_the_survey_too() { assert!(observation.outermost_partitioning_cache().is_none()); } + +// --- the doorbell's park-and-wake handshake --------------------------------- +// +// Its own documentation records that the first implementation DEADLOCKED: one +// thread setting an auto-reset event while the other waited, two signals +// collapsing into one, and the waiter blocking on INFINITE for ever. Nothing +// tested it, so the rewrite that fixed it could have been undone silently. +// +// Both tests are bounded by construction. A test that can hang is worse than +// the defect it guards, because it takes the whole suite with it: the handshake +// itself waits with a 5-second timeout and reports `None` rather than blocking, +// so a reintroduced deadlock surfaces here as a failed assertion within seconds +// rather than as a suite that never finishes. + +#[test] +fn a_zero_round_handshake_has_no_average_rather_than_a_meaningless_one() { + // The deterministic half of the contract, and the one a caller is most + // likely to break by "simplifying". With no rounds the elapsed time is zero + // and the average would be `0.0 / 0.0` -- NaN, wrapped in the `Some` this + // function documents as a meaningful number, against which every comparison + // a caller makes returns false with no indication why. + assert_eq!( + crate::doorbell_cost::measure_park_and_wake(0), + None, + "zero rounds has no average, and must not report one" + ); +} + +#[test] +fn a_small_handshake_completes_and_reports_a_positive_round_trip() { + // The liveness half: the two-event alternation actually runs to completion + // and produces a number. Deliberately few rounds -- this is a real + // cross-thread measurement, and the assertion is that it terminates and is + // sane, not that it is fast. A machine under load may make each round + // arbitrarily slow without making it wrong. + let average = crate::doorbell_cost::measure_park_and_wake(64) + .expect("a bounded handshake of 64 rounds must complete rather than time out"); + + assert!( + average.is_finite() && average > 0.0, + "a completed handshake must report a positive finite round trip, got {average}" + ); +}